Backend Systemsbeginner
Event Sourcing vs CRUD ā the Core Difference
Understand what Event Sourcing is, how it differs from traditional CRUD, when it's worth the complexity, and how to implement a simple event-sourced aggregate in C#.
Asma HafeezApril 17, 20264 min read
architectureevent-sourcingcqrsdotnetdomain-events
Event Sourcing vs CRUD
In traditional CRUD, you store current state: "Account balance is $750."
In Event Sourcing, you store every event that led to that state: "Deposited $1000, Withdrew $250."
The Core Difference
CRUD database:
accounts table: { id: 1, owner: "Alice", balance: 750.00 }
(history is gone ā you can't know how it got to 750)
Event Sourced:
events table:
{ accountId: 1, type: "AccountOpened", amount: 0, at: "2026-01-01" }
{ accountId: 1, type: "MoneyDeposited", amount: 1000, at: "2026-01-15" }
{ accountId: 1, type: "MoneyWithdrawn", amount: 250, at: "2026-02-01" }
current balance = replay all events = $750Events as the Source of Truth
C#
// Events ā immutable facts that happened
public abstract record AccountEvent(Guid AccountId, DateTimeOffset Timestamp);
public record AccountOpened(Guid AccountId, string Owner, DateTimeOffset Timestamp)
: AccountEvent(AccountId, Timestamp);
public record MoneyDeposited(Guid AccountId, decimal Amount, DateTimeOffset Timestamp)
: AccountEvent(AccountId, Timestamp);
public record MoneyWithdrawn(Guid AccountId, decimal Amount, string Reason, DateTimeOffset Timestamp)
: AccountEvent(AccountId, Timestamp);The Aggregate ā Rebuilt from Events
C#
public class BankAccount
{
public Guid Id { get; private set; }
public string Owner { get; private set; } = string.Empty;
public decimal Balance { get; private set; }
private readonly List<AccountEvent> _uncommittedEvents = [];
public IReadOnlyList<AccountEvent> UncommittedEvents => _uncommittedEvents;
// Reconstruct from event history
public static BankAccount Restore(IEnumerable<AccountEvent> events)
{
var account = new BankAccount();
foreach (var e in events) account.Apply(e);
return account;
}
// Commands ā validate and record events
public void Open(string owner)
{
if (!string.IsNullOrWhiteSpace(Owner))
throw new InvalidOperationException("Account already open");
var e = new AccountOpened(Guid.NewGuid(), owner, DateTimeOffset.UtcNow);
Apply(e);
_uncommittedEvents.Add(e);
}
public void Deposit(decimal amount)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive");
var e = new MoneyDeposited(Id, amount, DateTimeOffset.UtcNow);
Apply(e);
_uncommittedEvents.Add(e);
}
public void Withdraw(decimal amount, string reason)
{
if (amount <= 0) throw new ArgumentException("Amount must be positive");
if (amount > Balance) throw new InvalidOperationException("Insufficient funds");
var e = new MoneyWithdrawn(Id, amount, reason, DateTimeOffset.UtcNow);
Apply(e);
_uncommittedEvents.Add(e);
}
// Apply ā mutate state from an event
private void Apply(AccountEvent e)
{
switch (e)
{
case AccountOpened opened:
Id = opened.AccountId;
Owner = opened.Owner;
break;
case MoneyDeposited deposited:
Balance += deposited.Amount;
break;
case MoneyWithdrawn withdrawn:
Balance -= withdrawn.Amount;
break;
}
}
}Event Store
C#
public interface IEventStore
{
Task AppendAsync(Guid aggregateId, IEnumerable<AccountEvent> events, long expectedVersion);
Task<IEnumerable<AccountEvent>> LoadAsync(Guid aggregateId);
}
public class AccountRepository(IEventStore store)
{
public async Task<BankAccount> LoadAsync(Guid id)
{
var events = await store.LoadAsync(id);
return BankAccount.Restore(events);
}
public async Task SaveAsync(BankAccount account, long expectedVersion)
{
await store.AppendAsync(account.Id, account.UncommittedEvents, expectedVersion);
}
}When Event Sourcing Is Worth It
ā Audit trail is legally required (banking, healthcare, compliance)
ā Business needs to replay history or undo operations
ā Temporal queries: "What was the balance on March 15?"
ā Event-driven integration: publish events to other services
ā Complex domain with many state transitionsWhen It's Not Worth It
ā Simple CRUD apps ā the overhead is massive
ā Teams without distributed systems experience
ā When you just need an audit log (add a log table instead)
ā Data that changes constantly with no business need for historyKey Takeaways
- Event Sourcing stores events (what happened) not current state (what is)
- Current state is reconstructed by replaying all events in order
- Events are immutable ā you never update or delete them
- The aggregate encapsulates state mutation via
Apply()ā business rules in commands, state changes in apply - Use Event Sourcing when audit trail, temporal queries, or event-driven integration are first-class requirements ā not as a default choice
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.