Back to blog
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
Share:š•

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 = $750

Events 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 transitions

When 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 history

Key Takeaways

  1. Event Sourcing stores events (what happened) not current state (what is)
  2. Current state is reconstructed by replaying all events in order
  3. Events are immutable — you never update or delete them
  4. The aggregate encapsulates state mutation via Apply() — business rules in commands, state changes in apply
  5. 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?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.