Guide

In a conventional system, the database stores the current state. Account 42 has a balance of $140, and the history is gone unless you built audit tables. Event sourcing reverses that. The source of truth is an append-only sequence of events, and the current state is derived by replaying them.

Concept

Replaying means folding the events in order, starting from an empty state, to produce the current state. The state is never stored as the truth. It is a cache of the events.

Walking through an account

Here is the event stream for account 42:

#1 AccountOpened      { owner: "ana" }
#2 MoneyDeposited     { amount: 200 }
#3 MoneyWithdrawn     { amount: 50 }
#4 MoneyDeposited     { amount: 40 }
#5 MoneyWithdrawn     { amount: 50 }

Current balance = 0 + 200 − 50 + 40 − 50 = 140

State is a left fold over the events. A command checks the current state, then appends new events:

TypeScript
type AccountEvent =
  | { type: "AccountOpened"; owner: string }
  | { type: "MoneyDeposited"; amount: number }
  | { type: "MoneyWithdrawn"; amount: number };

type Account = { owner: string; balance: number; version: number };

function apply(state: Account, e: AccountEvent): Account {
  switch (e.type) {
    case "AccountOpened":  return { owner: e.owner, balance: 0, version: state.version + 1 };
    case "MoneyDeposited": return { ...state, balance: state.balance + e.amount, version: state.version + 1 };
    case "MoneyWithdrawn": return { ...state, balance: state.balance - e.amount, version: state.version + 1 };
  }
}

// Appending with expectedVersion is optimistic concurrency. If another writer
// appended event #6 first, this append fails and the command is retried.
async function withdraw(store: EventStore, id: string, amount: number) {
  const events = await store.read(id);
  const acct = events.reduce(apply, { owner: "", balance: 0, version: 0 });
  if (acct.balance < amount) throw new Error("insufficient funds");
  await store.append(id, [{ type: "MoneyWithdrawn", amount }], { expectedVersion: acct.version });
}

The expectedVersion check is what keeps two concurrent withdrawals from both succeeding on the same balance.

What you gain

  • A complete audit log for free. Ledgers, banking and accounting have always worked this way.
  • Time travel. Rebuild the state at any past moment by replaying up to that point. This helps debugging and regulators alike.
  • New read models from old data. Build a projection nobody planned for last year by replaying the full history.

What it costs

  • Snapshots. Replaying 50,000 events to load one account is slow. Save a snapshot every few hundred events, and replay only what follows. Replaying 500 events in memory is cheap, at about 1 ms per 1,000 events. Reading 50,000 events from storage on every command is not.
  • Schema evolution. Events are immutable and kept forever, so an old MoneyDeposited without a currency field must still be readable. Options: add only optional fields, version the event types (such as MoneyDepositedV2), or use upcasting, which converts old shapes into the current one as they are read. You can't casually rewrite history.
  • Querying. "All accounts with a balance over $1,000" can't be answered from a stream. You need read models, which leads to CQRS.
  • Complexity. Every developer meets it in daily work. Use it where history is the business, such as ledgers, inventory movements and collaborative editing. Don't use it as a default for CRUD apps.

Common mistakes

  • Using event sourcing for simple CRUD. The cost lands on every feature, and the gain is small.
  • Editing old events. Fix the history by appending a correcting event, not by rewriting.
  • Skipping snapshots on long streams. Load time grows with the stream, and every command pays for it.

Check yourself

1. Why does an event-sourced ledger make an audit cheap? The history is the storage. You can replay to any past point and see exactly how the balance got there.

2. An old MoneyDeposited event has no currency field. What do you do? Upcast it on read, converting the old shape to the current one. Don't rewrite history.

Key takeaways

  • Events are the source of truth. Current state is derived by replaying them.
  • You gain a full audit log, time travel and new read models from old data.
  • The costs are snapshots, schema evolution and querying, which needs read models.
  • Use it where history is the business, not as a default.

Further learning

Next, see how read models answer the queries that event streams can't, in CQRS and read models.