← blog

Event Sourcing — the Ledger Is the Truth

Store every change as an event and derive current state by replaying them. The dual-write problem disappears by construction — and you get an audit trail and time travel for free.

Your bank does not store your balance. Think about that: the most correctness-obsessed industry on earth, and the number you care most about is not sitting in a balance column being updated. What the bank stores is the ledger — every deposit and withdrawal, forever, append-only. Your balance is what you get by replaying the ledger. If the balance and the ledger ever disagreed, nobody would even ask which one is right.

You already use a second system built this way every day: git. It doesn’t store your project “as it is” — it stores every commit ever made, and any version of the project is just a replay up to some point. History isn’t a feature bolted onto git; history is git.

Event sourcing is that idea applied to your domain: stop storing what things are; store everything that happened.

It’s also the third — and most radical — answer to the dual-write problem. The outbox adds a second row, CDC reads the database’s journal — event sourcing goes all the way and makes the journal the database. And it solves problems the other two don’t touch: audit trails, “how did we get into this state” debugging, and rebuilding views you haven’t invented yet.

The pattern’s one rule: every change is appended as an event to a permanent log, current state is computed by replaying the events — and because appending the event IS the save, there’s no second write left to lose.

The move#

The events are the vocabulary of things that happen to an order — past tense, because each one is a fact by the time it’s written:

public sealed interface OrderEvent permits OrderPlaced, OrderPaid, OrderShipped, OrderCancelled {}

public record OrderPlaced(OrderId id, List<OrderLine> lines)  implements OrderEvent {}
public record OrderPaid(OrderId id, PaymentId payment)        implements OrderEvent {}
public record OrderShipped(OrderId id, TrackingId tracking)   implements OrderEvent {}
public record OrderCancelled(OrderId id, String reason)       implements OrderEvent {}

Writing is only ever appending — no UPDATE, no overwriting, one write to one system:

public void markPaid(OrderId id, PaymentId payment) {
    eventStore.append(id, new OrderPaid(id, payment));   // this is the entire save
}

And current state is a replay — start from empty, apply each event in order:

public Order load(OrderId id) {
    Order order = Order.empty();
    for (OrderEvent event : eventStore.history(id)) {
        order = order.apply(event);   // PLACED after OrderPlaced, PAID after OrderPaid...
    }
    return order;
}
the log (append-only)order #4211replay1 OrderPlaced2 OrderPaid3 OrderShippedemptystatus: PLACEDstatus: PAIDstatus: SHIPPED
State is a replay. Events append on the left, one at a time — and the state box on the right is nothing but the log folded up. Delete the state and nothing is lost; delete one event and the state was never true.

Why the dual-write problem can’t exist here#

Walk back through the problem post‘s two lines — save the fact, announce the fact. Here, the appended event is both at once. Saving and announcing stopped being two writes and became two readers of one write: your own load() replays the log to get state, and downstream consumers subscribe to the same log to hear the news. There is no second write to crash before. The bug isn’t fixed; it’s unrepresentable.

And the log pays for itself twice more, which is why this pattern outgrows the bug that introduced it:

  • The audit trail is the data. “Who cancelled this order, when, and why” isn’t a logging afterthought that someone forgot to add — it’s a row that had to exist for the system to work at all. This is why finance was event-sourced decades before software named it.
  • Time travel. “What did this order look like last Tuesday?” — replay up to Tuesday. “We shipped a bug that corrupted state” — fix the code, replay the log, state is reborn correct. And a view you invent next year (orders-per-region, fraud features) can be built retroactively over all of history, because nothing was ever thrown away.

The honest costs#

This is the heaviest pattern in these notes, and it’s the wrong default:

  • Reads get harder. “Replay the events” is fine for one order and absurd for “all unshipped orders over $5,000.” Real systems maintain projections — ordinary read tables continuously built from the log (state as a cache of the log). Keeping write-side log and read-side views separate has its own name — CQRS — and its own notes.
  • Events are forever. Rename a field and you still own every event written in the old shape, replayable years later. Schema evolution here is a discipline (upcasters, versioned events), not a migration script.
  • Long histories need snapshots — periodic saved checkpoints so replay is “snapshot + recent events,” not ten thousand rows.

Older than software#

  • Your bank statement — the original event store; the balance is a projection.
  • git — commits are events, checkout is replay, branches are alternate replays.
  • Redux DevTools’ time-travel debugging — actions are events, state is a fold, the slider is replay.
  • Kafka used as a log of record, and EventStoreDB — infrastructure built for exactly this shape.
  • Accounting, medical records, legal case files — every domain where “what happened” legally outranks “what is.”

When NOT to use it#

  • CRUD is genuinely enough. A product catalog, a user-profile service — if nobody asks “how did it get this way,” storing current state is simpler in every dimension.
  • You only came for the dual-write fix. An outbox is a table and a loop; event sourcing is a different data model for your whole service. Don’t re-architect to fix a bug with a two-page fix.
  • The team hasn’t lived with it. Projections, replays, versioned events — the concepts are simple, the operational maturity isn’t. Bad event sourcing is worse than good CRUD.

Comments

Signed in with GitHub. Be kind.