Most applications store the current state of things — “the account balance is $150”. Event sourcing says: store what happened, not what is. “Deposit $100. Withdraw $20. Deposit $70.” Current balance is derived. This article explains the idea, the trade-offs, and when it earns its complexity.

The core shift

Traditional storage:

accounts table
  id: 123
  balance: 150.00
  last_modified: 2023-01-15

Event-sourced storage:

events table
  id=1, account=123, type=Deposited,  amount=100, at=2023-01-10
  id=2, account=123, type=Withdrew,   amount=20,  at=2023-01-12
  id=3, account=123, type=Deposited,  amount=70,  at=2023-01-15

Current balance? Replay all events, sum the deltas. For performance, cache snapshots periodically.

That’s it. The entire idea of event sourcing in one picture.

What you gain

Full history. Every change is preserved. You can answer “what did the balance look like on June 5 at 3 PM?” — something impossible with state-only storage unless you added audit logs manually.

Audit and compliance. Regulated industries (finance, healthcare) often require this. Event sourcing gives it to you as a natural consequence, not a bolt-on.

Debugging. When state is wrong, you can replay the events and see exactly what happened. Reproduce incidents deterministically.

Time travel. Build projections at any point in time. New reporting requirements? Replay events and compute.

Decoupling. Multiple projections can be built from the same event stream, each optimized for a different read pattern.

What you pay

Complexity. More code than “insert into users”. Need to model events, handlers, snapshots.

Event schema evolution. Once an event is stored, you can’t change its format. Breaking changes require versioned events + migration. Plan for this from day one.

Querying is indirect. “Find all accounts with balance > 1000” isn’t a query against events — it’s a query against a projection. If the projection doesn’t exist, build it.

Storage growth. Never deletes. Need archive/compaction strategy for long-lived aggregates.

Learning curve. Teams accustomed to CRUD find it disorienting at first.

When it’s worth it

  • Financial or regulatory domains where audit is mandatory
  • Domains with legitimately complex temporal questions (what was the state at time X?)
  • Collaborative systems where conflicting concurrent edits need reconciliation
  • Systems where the event stream itself is the product — activity feeds, notifications

When it’s usually not:

  • Simple CRUD apps — audit log is cheaper
  • Systems where “what happened” is boring — a catalog browsing service doesn’t benefit
  • Small teams early in a product — premature complexity

A minimal implementation in Java

Events are records:

sealed interface AccountEvent {
    record Opened(UUID accountId, String owner) implements AccountEvent {}
    record Deposited(UUID accountId, long cents, Instant at) implements AccountEvent {}
    record Withdrew(UUID accountId, long cents, Instant at) implements AccountEvent {}
    record Frozen(UUID accountId, String reason, Instant at) implements AccountEvent {}
}

The aggregate rebuilds state by folding events:

public class Account {
    UUID id;
    String owner;
    long balanceCents;
    boolean frozen;

    public static Account rehydrate(List<AccountEvent> events) {
        Account a = new Account();
        for (AccountEvent e : events) a.apply(e);
        return a;
    }

    void apply(AccountEvent e) {
        switch (e) {
            case AccountEvent.Opened(var id, var owner) -> {
                this.id = id; this.owner = owner;
            }
            case AccountEvent.Deposited(var id, var cents, var at) -> balanceCents += cents;
            case AccountEvent.Withdrew(var id, var cents, var at)  -> balanceCents -= cents;
            case AccountEvent.Frozen(var id, var reason, var at)   -> frozen = true;
        }
    }
}

Writes produce events, never directly mutate state:

public List<AccountEvent> deposit(long cents) {
    if (frozen) throw new IllegalStateException("Account frozen");
    if (cents <= 0) throw new IllegalArgumentException("Amount must be positive");
    return List.of(new AccountEvent.Deposited(id, cents, Instant.now()));
}

Storage is just an append-only events table — INSERT INTO events(aggregate_id, version, type, payload, at).

Snapshots

Replaying 100,000 events on every load is slow. Every N events, persist a snapshot. Rehydrate = load latest snapshot + events after it.

Event schema evolution

Never modify a stored event. To change semantics:

  1. Define a new event type (DepositedV2)
  2. Handle both old and new during transition
  3. Optionally rewrite old events in place (be careful)

Upcasters — small functions that transform old events to new — are a common pattern.

Event sourcing + CQRS

They compose naturally. Events are the write model. Projections are the read models, built by subscribing to events. Multiple projections from one event stream = classic CQRS.

Closing note

Event sourcing is a powerful pattern with real costs. For domains where the “why did this change” question matters, the investment pays back tenfold. For domains where it doesn’t, plain CRUD with an audit log is simpler and sufficient. Don’t apply it because it’s fashionable — apply it because the audit, replay, and temporal-query capabilities it provides solve a problem you actually have.