Pattern — Event Sourcing
The problem it solves
A read-modify-write that stores an absolute value silently loses concurrent updates: two operators both read balance 100, one writes 150, the other writes 70, and the later write clobbers the earlier — the +50 is gone with no error (Drill 70). Storing only current state means every write destroys the state it replaces, so a stale write wins by default and the history that would explain the drift no longer exists.
The pattern
Stop overwriting state; append immutable events to a log and derive state by folding over them. The event store holds one row per fact (credit +50, fee −30) — never updated, never deleted. Current balance is not a column you SET, it's a projection: a SUM (or fold) over the stream. Because appends only ever add rows, two concurrent corrections both survive regardless of order — there is no cell to clobber. The log is also the audit trail: every state the system was ever in is reconstructable by replaying events up to a point in time.
How to apply it
- Model changes as events, not assignments. Persist
AccountCredited{amount: 50}andFeeCharged{amount: 30}, notbalance = 70. Each event is an immutable row in an append-only table:(stream_id, version, event_type, payload_json, occurred_at), with aUNIQUE (stream_id, version)constraint. - Append with an expected version (optimistic concurrency). A writer that read the stream at version 7 appends at version 8 via
INSERT ... (stream_id, version=8, ...); the unique constraint makes a second writer'sversion=8fail. On conflict, re-read and retry — bounded at, say, 3 attempts with jittered backoff. This guards conflicting decisions (two people editing the same known value) while commuting events (money deltas) still simply both land. - Serve reads from a projection, not the log. Maintain a
balancesread model updated as events are appended, or a materialized viewSELECT stream_id, SUM(signed_amount) FROM events GROUP BY stream_id. Rebuild it any time by replaying — the log is the source of truth, projections are disposable caches. - Snapshot to bound replay cost. Replaying a 50,000-event stream on every read is O(n). Write a snapshot every N = 100 events (
{version: 3400, balance: 8820}); a read loads the latest snapshot and folds only the ≤ 100 events after it. Tune N so worst-case fold stays under your read latency budget (e.g. < 5 ms). - Keep events forever; version the schema. Events are immutable, so payloads accrete fields over years — carry a
schema_versionper event type and upcast old shapes on read. Retention is effectively infinite (that's the audit value); size projections and snapshots, not the log, for cost.
flowchart LR
A["Command: credit +50"] --> B["Append event<br/>(stream, version=n+1)"]
C["Command: charge -30"] --> D["Append event<br/>(stream, version=n+2)"]
B --> E["Append-only event log<br/>(immutable, never overwritten)"]
D --> E
E --> F["Fold / SUM<br/>(+ latest snapshot)"]
F --> G["Projection: balance = 120"]
When to use it — and when not
Reach for it when history is the product — money movements, ledgers, order lifecycles, anything audited or where "how did we get here?" is a real question, and where concurrent writers must not silently lose each other's work (Drill 70). It also unlocks temporal queries (balance as of any date) and rebuildable read models for free.
Skip it when the extra machinery isn't paid back. Pure CRUD with no audit need (a user's display-name field) is cheaper as a plain row — event sourcing adds a projection layer, eventual-consistency lag between append and read model, snapshot maintenance, and schema-upcasting forever. It is also not a fix for cross-aggregate transactions or for high-cardinality, low-value state. For a single additive counter, an atomic increment (SET balance = balance + :delta) gives you the same "no lost update" guarantee at a fraction of the cost; for edit-a-known-value with no history need, a version-checked compare-and-swap is enough. Event sourcing earns its keep when you want all of: no lost writes, full audit, and time travel.
Signals it's working
- Zero destructive updates. The events table takes only
INSERTs —UPDATE/DELETEcounts on it hold flat at 0; alarm if either goes above 0. - Append-conflict retries are nonzero but small. Version-conflict retries firing means the optimistic guard is catching real collisions; page if the retry rate spikes past ~10× baseline (a hot stream or stuck loop).
- Projection lag stays bounded — append-to-read-model latency under, say, 1 s p99; a rising lag means the projector is falling behind.
- A concurrency contract test in CI: append two conflicting events to the same stream in parallel and assert the folded balance equals the sum of both deltas — never a last-write-wins value.
Seen in the wild
- Double-entry accounting ledgers are the original event source: you never edit a posted entry, you append a reversing one — the balance is the running sum.
- Kafka / event logs as source of truth (the "log-centric" / Kappa architecture, Debezium CDC): the immutable log is authoritative and materialized views are rebuilt by replay.
- Git is event sourcing for code — an append-only commit log; your working tree is a projection folded from it.
- EventStoreDB, Axon, Marten (Postgres) are purpose-built event stores exposing streams, expected-version appends, and snapshots.
See it
The ChainScene shows two concurrent commands (+50, −30) both appending to one immutable log and folding to 120 — nothing overwritten; CardsScene and TilesScene carry the load-bearing config (append with expected version, UNIQUE (stream_id, version), snapshot every 100 events, 3-attempt retry, 0 UPDATEs on the log).
Named concepts: event sourcing, append-only event log, projections, snapshots.
