A normal table stores the current state. UPDATE cart SET total = 52 overwrites
the old value, and it is gone.
Event sourcing stores the facts that happened and computes the state from them.
- ItemAdded sku-1 ×2
- ItemAdded sku-9 ×1
- ItemRemoved sku-1 ×1
- CouponApplied SAVE10
- CheckedOut
- derived state
- items
- 2
- total
- £24
- coupon
- —
- status
- open
The state on the right is not stored anywhere — it is the events on the left, folded. Stop the replay at any point and you have the cart exactly as it was at that moment, which is a query a table of current values cannot answer.
The state on the right is not stored. It is the events, folded.
const state = events.reduce(apply, EMPTY);What you gain
A complete audit trail, by construction rather than by discipline. You cannot forget to log something, because the log is the data.
Time travel. Stop the fold anywhere and you have the state as of that moment. “What did this cart look like before the coupon?” is a query, not an investigation.
New questions about old data. Realise in March that you want to know how often coupons are applied before checkout versus after — replay January’s events through a new projection. A table of current values simply cannot answer that; the information was overwritten.
Debugging. Reproduce a bug by replaying the exact event sequence.
What you pay
Reads are more expensive. Folding thousands of events per query does not work, so you maintain projections — read models updated as events arrive — and now you have a denormalised copy to keep correct.
Those projections are eventually consistent. A write is acknowledged when the event is stored; the read model catches up after. A user can complete an action and not see it, which is the replica lag problem again.
Everything is unfamiliar. No UPDATE, no ad-hoc SELECT against the truth,
and every new engineer needs the model explained.
Snapshots
An aggregate with 50,000 events cannot be folded on every request.
Store the state periodically — every 100 events — and fold forward from the latest snapshot. Snapshots are strictly a cache: deleting them must be safe, because the events remain the truth. If your system cannot rebuild from events alone, it is not event sourced.
Naming events
This is where most of the design quality lives.
Past tense, business language. OrderShipped, not SetShippedFlag. The
event records something that happened in the domain, not a database operation.
Facts, not commands. PaymentReceived, not ReceivePayment. Commands can be
rejected; events already happened.
Self-contained. Include what a consumer needs. An event that says only
{orderId} forces every consumer back to the database and couples them to its
schema.
Not too fine. FieldChanged events carry no meaning — you have reinvented a
row-level audit log and lost the business intent that was the whole point.
When it is worth it
Yes — when history is the domain. Ledgers, order lifecycles, medical records, anything with a regulatory audit requirement. Also when several independent read models need the same underlying facts.
No — for CRUD. A settings page, a user profile, a content management system. The complexity is real and constant, and the benefit only appears when someone actually asks a historical question.
A useful middle ground: keep normal tables, and emit events alongside them via the outbox pattern. You get the integration and audit benefits without making the event log your source of truth — and you can go further later if the questions start arriving.