System Design
Event Sourcing
Store every change as an event, derive state by replay. Power, audit, complexity — pick which you can afford.
Event sourcing
Most systems store the answer and throw away the question. The accounts
table says balance = 150 and the path that got there — a deposit, a
refund, a disputed charge that was reversed — is gone the instant the row
is overwritten. That's fine right up until someone asks why, and you
have nothing to hand them but the number.
Event sourcing inverts the default. You don't store state; you store the changes that produced it, as an immutable, ordered sequence:
Account 42
1 AccountOpened {at: 09:01}
2 MoneyDeposited {amount: 100, at: 09:05}
3 MoneyDeposited {amount: 80, at: 11:20}
4 MoneyWithdrawn {amount: 30, at: 14:02}
The current balance isn't a column. It's a reduce over those four
events — 0 + 100 + 80 - 30 = 150. State becomes a projection: a pure
function of the event log. The log is the truth; everything else is a
cached opinion derived from it.
A CRUD row tells you what is true now. An event log tells you everything that was ever true, and in what order. The second one is strictly more information — you can always compute the first from it, but never the reverse.
That "never the reverse" is the whole pitch. Once you've overwritten
status = 'shipped' on top of status = 'paid', the fact that it was
ever paid, and when, is unrecoverable. With an event log it's two
rows you can never lose.
The append-only mechanic
An event store is the simplest database you'll ever reason about: rows go in, rows never change, rows never leave. The one piece of machinery that matters is optimistic concurrency on the stream version.
When you load account 42 you also learn it's at version 4. To record a withdrawal you append "event 5, expecting the stream to still be at 4." If a concurrent request already wrote event 5, your append is rejected and you retry against the new state. No locks, no lost updates — the version check is the entire concurrency story.
append(stream="account-42", expectedVersion=4, event=MoneyWithdrawn{30})
-> ok, now at version 5
append(stream="account-42", expectedVersion=4, event=MoneyWithdrawn{20})
-> CONFLICT: stream already at 5, reload and retry
What you gain
| Property | Why it matters |
|---|---|
| Perfect audit | Every change is preserved by construction, not by a logging side-channel you can forget to write |
| Time travel | "State as of yesterday at 3pm" is a replay up to a timestamp |
| Multiple projections | The same events feed a balance view, a history view, and an analytics view — each shaped for its reader |
| Debugging with a literal answer | "How did we get into this state?" is the log, not a guess |
| Decoupled reads/writes | Pairs naturally with [CONCEPT]caching-patterns and CQRS |
| Domain language | MoneyWithdrawn is how the business talks; UPDATE balance is not |
What you pay
| Cost | Why it matters |
|---|---|
| Read cost | Folding events into state is slower than reading one row (snapshots fix this — see below) |
| Schema migration | Old events live forever; readers must handle every shape they ever emitted |
| Tooling immaturity | EventStoreDB, Axon, Marten exist, but far fewer engineers have run them than have run Postgres |
| Mental model shift | Everyone defaults to CRUD; this is a genuinely different way to think |
| Eventual consistency | Projections lag the log by N ms — the read model is behind the write |
| Storage growth | You never delete; bytes accumulate forever |
A war story: the order that cancelled itself
A team ran order management as plain CRUD: one orders row, a status
column, overwritten on each transition. One morning support escalated a
furious customer — their order showed cancelled, they swore they never
cancelled it, and they wanted to know who did.
The team genuinely could not answer. The row said cancelled. When it
flipped, what it was before, which service wrote it — all of that had
been overwritten into oblivion the moment the status changed. They
reconstructed a guess from application logs over two days, found a
retry-storm bug in a payment webhook that had auto-cancelled on a false
timeout, and shipped a fix. But they never could tell that first customer
what happened to their order, because the data to answer simply didn't
exist anymore.
Had orders been event-sourced, the answer was four rows:
OrderPlaced → PaymentTimedOut → OrderCancelled{by: "payment-webhook"}.
The bug would still have happened — but it would have been visible, and
answerable, in seconds.
The version of this that hurts most: a background job or one-off script writes a corrected status directly to the table to "fix" a stuck order. In CRUD, that overwrite is indistinguishable from a legitimate transition. In an event log, the correction is its own visible event — you can see that a human (or a script) intervened, and exactly when.
Snapshots: paying down the read cost
The honest objection to event sourcing is read latency. An account with 40,000 events should not replay all 40,000 on every balance check.
The fix is a snapshot: every N events, persist the folded state plus the version it represents. To load, you read the latest snapshot and replay only the events after it.
loadAccount(id):
snap = snapshotStore.latest(id) # {state, version}
events = eventStore.after(id, snap.version) # just the tail
return events.reduce(apply, snap.state)
The snapshot is a cache, never the truth — you can delete every snapshot and rebuild from the log. A common default is a snapshot every 50–200 events; tune it so the replayed tail stays in the low hundreds.
How much does the log actually cost?
Run the back-of-envelope before you worry about storage.
Say a busy order system records 1M events/day, each ~500 bytes serialized:
| Quantity | Math | Result |
|---|---|---|
| Per day | 1M × 500B | 500 MB/day |
| Per year | 500 MB × 365 | ~180 GB/year |
| Indexed on disk (×2–3 overhead) | 180 GB × 2.5 | ~450 GB/year |
180 GB of append-only data per year is unremarkable — it compresses well, it's cold after a few weeks, and object storage is cheap. The cost that actually bites is not bytes; it's the read amplification if you forget snapshots, and the schema discipline every one of those billion events demands forever.
The hardest part: schema versioning
You added a currency field to MoneyDeposited last year. There are now
two billion events without it. Your reader must handle both shapes,
forever — you cannot go back and edit history. Three strategies, in
rough order of how teams adopt them:
- Weak schema (JSON, no contract). Easy to add fields, easy to ship a reader that silently mishandles an old shape. Fine early, painful at scale.
- Versioned event types (
MoneyDeposited.v1,.v2). Explicit, but every reader grows aswitchover versions. - Upcasting: read the old shape, transform it to the latest on the way in, so the rest of your code only ever sees the current version.
function upcast(raw):
if raw.type == "MoneyDeposited" and raw.version == 1:
return { ...raw, version: 2, currency: "USD" } # the v1-era default
return raw
# Readers only ever fold v2. The v1 events on disk never change.
Upcasting keeps reader code clean at the cost of one transformation layer that has to know the history of every event type.
You cannot edit a committed event — immutability is the entire point.
Found a bug in a past event? Append a compensating event
(MoneyCorrection{from: 50, to: 55}), don't rewrite the original. History
is a record of what happened, including the mistakes and their fixes.
When it pays for itself
| Workload | Fit |
|---|---|
| Banking / accounting | Perfect — the audit is the product |
| Order management (e-commerce) | Great — orders are a chain of state transitions |
| Turn-based game state | Great — replay is intrinsic to the domain |
| Multiplayer collaboration | Great — events are literally how you sync clients |
| Simple CRUD admin panel | Bad — pure overhead, no audit pressure |
| Read-heavy social feed | Bad — projection cost dominates the workload |
| Analytics / reporting | Bad — model it as a star schema instead |
Event sourcing vs change-data-capture
A frequent shortcut: do you really need event sourcing, or do you need change-data-capture? CDC tails an existing CRUD database's write-ahead log and streams row changes as events. It bolts an audit stream onto a system that's still fundamentally CRUD — roughly 80% of the audit benefit for 20% of the rebuild cost.
The line: CDC tells you a row changed; event sourcing tells you why, in domain terms, because the event was the intent in the first place. If you mostly want an audit log and a change stream, reach for CDC. Choose full event sourcing when the events are the model — when "what happened" is the thing the business actually cares about.
The events themselves ride to projections over a broker — [CONCEPT]message-queues is the pipeline — and the [CONCEPT]outbox-pattern is how you publish them without losing any when the writer crashes between commit and publish.