Phase 10 · Algorithm 56 of 60

Event Sourcing

Stop storing what is. Store everything that happened, and derive what is.

Distributed systems Persistence pattern ~12 min read Visual style: technical-poster
Technical poster infographic: Event Sourcing — an append-only event stream folded by replay into a derived current-state read model, with snapshots and a CRUD-versus-event-log contrast panel.
Hero generated with gpt-image-2 · style: technical-poster

Core concept

A normal database stores the current answer and throws away the question. Event Sourcing inverts that: the only source of truth is an ordered, append-only log of facts that already happened — and current state is a derived value, computed by replaying that log.

The shift is small in code and large in consequence. UPDATE accounts SET balance = 300 destroys information: you now know the balance and nothing about how it got there. Appending MoneyWithdrawn(200) instead keeps the balance obtainable and keeps the history, the cause, the ordering, and the timestamp. Nothing is ever mutated; nothing is ever deleted.

The whole pattern in one line state = events.reduce(apply, initialState)

Current state is a fold over history. If you can write that fold, you can throw the state away and get it back — which is exactly why this pattern buys you time travel for free.

Key components

01

Event

An immutable fact in the past tense — OrderPlaced, ItemShipped. It carries what happened, not what to do. Past tense is a design rule, not style: a fact cannot be rejected or retried.

02

Event Store

The append-only log itself, ordered per stream with a monotonic sequence number. Writes only ever append. The sequence number is what gives you optimistic concurrency and deterministic replay.

03

Aggregate

The consistency boundary that validates a command against its own replayed state, then emits events. Commands can fail; events cannot. This is where business rules live.

04

Projection

A read model built by consuming the stream — a table, cache, or search index shaped for one query. Disposable by design: delete it, replay, and it rebuilds. Add a new one whenever a new question appears.

A fifth piece is operational rather than conceptual: the snapshot. Replaying 4 million events to answer one balance query is not viable, so you periodically persist "state as of sequence N" and replay only the tail after it. Snapshots are a cache — they must always be re-derivable from the log, never authoritative.

How it works

  1. A command arrives“Withdraw 200 from account A.” A command is a request — it is imperative, it can be refused, and it is not yet a fact.
  2. Load the aggregateReplay account A's events (from its latest snapshot forward) to rebuild just enough state to decide. Balance = 300.
  3. Validate, then emitBusiness rules run against that state. If 200 ≤ 300, emit MoneyWithdrawn(200). If not, reject the command — and write no event at all.
  4. Append with an expected versionAppend at sequence N+1, asserting the stream is still at N. If a concurrent writer got there first, the append fails and the command retries against fresh state. This is optimistic concurrency with no locks.
  5. Fan out to projectionsSubscribers consume the new event and update their read models — balance table, statement view, fraud index. Each projection tracks its own position in the stream, so they can lag, catch up, or be rebuilt independently.
  6. Query the projection, never the logReads hit the shaped read model. The log is for writing truth and for replay; it is a terrible thing to query directly.
# the entire read path, conceptually
events = store.read("account-A", from=snapshot.seq)
state  = snapshot.state
for e in events:
    state = apply(state, e)     # pure function, no I/O

# apply is a total function over event types
def apply(s, e):
    if e.type == "MoneyDeposited":  return s + e.amount
    if e.type == "MoneyWithdrawn":  return s - e.amount
    return s                     # unknown event: ignore, don't crash
CRUD row
  • Stores the latest answer only
  • Update overwrites — history gone
  • "Why is this 300?" is unanswerable
  • Audit trail is a separate, bolt-on table that can silently drift from truth
  • New query shape → migrate schema
Event log
  • Stores every transition, in order
  • Append only — nothing is lost
  • State at any past instant is computable
  • The audit trail is the data model, so it cannot disagree with it
  • New query shape → new projection, replay

Where it actually earns its cost

The trap beginners fall into

Treating events as a schema you own. Events are immutable and permanent, which means a v1 event you wrote two years ago must still be readable by today's code — forever. You cannot migrate the past. Plan for versioned events and upcasting from day one.

And: applying it everywhere. Event sourcing costs you eventual consistency between write and read side, harder queries, replay tooling, and real schema-evolution discipline. Use it where history is part of the domain — money, ownership, compliance, workflow. For a settings page, a plain row is the right answer, and reaching for this pattern is how teams manufacture two years of accidental complexity.

Checkpoint — answer before you move on

Say the answers out loud. If one is fuzzy, that is the sentence to reread — not the whole card.

  1. Why must an event be phrased in the past tense, and what is the substantive difference between a command and an event? Hint: one of the two is allowed to be rejected. Which, and why does that matter for what you're permitted to store?
  2. If current state is always derivable by replaying the log, what problem do snapshots solve — and why is a snapshot still not a source of truth? Hint: think about what breaks if you fix a bug in apply() after snapshots were written.
  3. Name one system you've built where event sourcing would have paid for itself, and one where it would have been pure overhead. What distinguishes them? Hint: ask whether anyone would ever need to ask "what was true last Tuesday, and why did it change?"