Storing state as a log of events
Open in the interactive app: practice, quiz, mock interview →In one minute
- Event sourcing stores every state change as an immutable event in an append-only log. Current state is derived by replaying (folding) the events, not read from a mutable row.
- You get a complete audit trail, the ability to answer "what was the state on March 3rd?", and the freedom to build new read models from history at any time.
- Writes use optimistic concurrency per stream: append only if the stream is still at the version you read. That replaces row locks.
- The costs are real: event schemas live forever, long streams need snapshots, queries need projections (usually CQRS), and deleting personal data needs a plan.
How it works
Streams, events, and versions
Each aggregate (an account, an order, a cart) has its own stream. Events are named in the past tense and carry business meaning: FundsDeposited{amount: 500}, not BalanceUpdated{balance: 1200}. Each event in a stream has a sequence number, so the stream's version is the number of the last event. Mistakes are not edited away: a wrong FundsDeposited is cancelled by a new compensating event such as DepositReversed, and both stay in the stream (Azure Architecture Center).
Handling a command
To process Withdraw(account 42, 300), the handler loads stream 42, folds its events into current state (balance 1,200 at version 17), checks the rule (balance must cover the withdrawal), and appends FundsWithdrawn{300} with expected version 17. If another writer appended version 18 in the meantime, the store rejects the append and the handler reloads and retries. This optimistic check is the only concurrency control needed, and it is scoped to one stream, so streams scale independently. It also explains why a message broker is not an event store: Kafka can fan events out, but it has no per-aggregate stream reads and no append-if-version check, so it cannot stop two writers from both spending the same balance (Azure Architecture Center). Use Kafka to distribute events, and a store with expected-version appends to decide them.
Testing follows the same shape: given these past events, when this command arrives, then expect these new events. Such tests need no database, but projections, idempotency and upcasters still need their own integration tests.
Snapshots
Replaying 50,000 events on every command is slow. Periodically store a snapshot (state plus the version it reflects) and on load replay only the events after it. Snapshots are a cache: you can delete them and rebuild from events, so their format can change freely.
Projections and queries
The event log is bad at queries like "all accounts overdrawn in Spain". Projectors subscribe to the global event feed and build read models, which is why event sourcing is nearly always paired with CQRS. Because the log is complete, you can add a new projection years later and backfill it by replaying from the start, or answer a temporal query by replaying only up to a chosen time (Fowler).
Schema evolution
Events are facts about the past and should not be rewritten. To evolve, add optional fields with defaults, or introduce a new event version and upcast old events to the new shape when reading. Never change the meaning of an existing field.
Personal data
Immutable logs clash with deletion rights. The simplest fix is to keep personal data out of events entirely: store it in an ordinary table and put only a subject ID in the event, so deletion never touches the log. Where personal fields must be in events, use crypto-shredding: encrypt them with a per-subject key stored elsewhere, and delete the key to make the data unreadable. It costs encryption on every read and write and needs solid key management (Azure Architecture Center).
Down the stack
Layer 1The contract
An event store promises that appended events are durable and immutable, that events within a stream have consecutive version numbers, and that an append with an expected version succeeds only if the stream is exactly at that version. It also exposes a global ordering across streams so subscribers can resume from a position without missing events. Reads of one stream return all its events in order. Nothing promises fast queries across streams; that is the projections' job.
Layer 2The mechanism
In a dedicated store such as EventStoreDB (renamed KurrentDB), events are appended to the end of the latest chunk file, and the server closes it and opens a new one once it grows past 256 MiB (KurrentDB docs). Each event carries its stream ID, event number, and log position, with an index mapping streams to positions. Appends specify an expected state: a specific revision, or no_stream, stream_exists or any (no check) (KurrentDB client docs). In Postgres, the common design is an events table with a unique constraint on (stream_id, version); an append inserts rows with the next versions, and a concurrent writer gets a unique violation, which is the optimistic concurrency check. Global ordering needs care: a bigserial value is assigned before commit, so a later-numbered transaction can commit first and a subscriber reading by ID can skip an earlier one that commits a moment later. Waiting for the missing number does not work either, because a rolled-back transaction leaves a gap that is never filled. Solutions include a single writer (or an advisory lock held from allocation to commit), or storing each row's xid8 transaction ID and reading only rows below pg_snapshot_xmin(pg_current_snapshot()), the oldest transaction still in flight (Dudycz).
Layer 3The machine
Appending is a sequential write plus fsync at commit: about 0.05 to 0.2 ms on local NVMe, often 1 to 5 ms on network-attached cloud volumes, so stores batch concurrent appends into one flush. KurrentDB flushes every write by default; turning that off trades durability for speed (KurrentDB docs). Loading a stream is an index seek followed by a sequential range read; 500 events of 400 bytes is 200 KB, a few pages that are usually cached. Deserialising JSON at 500 events costs a fraction of a millisecond; a stream of 100,000 events costs tens of milliseconds and megabytes of allocation, which is what snapshots avoid. Storage grows forever, but at 3.5 TB per year on disks costing cents per GB-month, keeping history is cheap compared with losing it.
Why it's built this way
- History has business value and storage is cheap, so every change is kept instead of overwritten.
- Sequential appends are the fastest write a disk can do, so the store is an append-only log.
- Concurrent writers must not corrupt an aggregate, and locks across a network are fragile, so appends carry an expected version.
- Replaying long streams costs I/O and CPU on every load, so snapshots cache folded state.
- Querying a log is slow, so read models are projected into stores built for queries.
Diagram
sequenceDiagram
participant C as Command handler
participant S as Event store
participant P as Projector
participant V as Read model
C->>S: load stream acct-42
S-->>C: snapshot v15 + events 16..17
Note over C: fold to balance 1200 at v17
C->>S: append FundsWithdrawn(300), expected v17
alt stream still at v17
S-->>C: ok, now v18
S->>P: FundsWithdrawn v18
P->>V: update balance view
else another writer appended v18
S-->>C: version conflict
Note over C: reload and retry
endWorked example
A digital wallet has 8 million accounts. Each account averages 3 events per day; heavy merchant accounts reach 2,000 per day. At an average of 400 bytes per event, daily ingest is about 24 million events, roughly 10 GB, and 3.5 TB per year before replication.
Loading a typical account means reading a few hundred events in one sequential range scan, under 5 ms. Merchant accounts would accumulate 700,000 events a year, so the system snapshots every 500 events. A load then reads one snapshot and at most 499 events.
Concurrency conflicts are rare for consumers but common for merchants, where 30 payments per second can hit the same stream. The team measures a 6% conflict rate at peak on the busiest merchant, with retries adding a median 4 ms. To cut contention they split the merchant ledger into per-day sub-streams and compute the balance from a projection, keeping the per-stream append rate low.
Two years later, compliance asks for a report of every account whose balance dipped below zero for more than an hour. Because every change is in the log, a new projector replays 17 billion events over a weekend, partitioned by account across 64 workers at about 2,000 events per second each, roughly 37 hours.
In the wild
- LMAX, 2011: an exchange that is its event log. LMAX's retail trading platform kept all business state in memory on a single thread and processed about 6 million orders per second on one 3 GHz server. There was no database: input events were journaled to disk, a snapshot was taken every night, and a restart (JVM included) loaded the snapshot and replayed that day's journal in under a minute. Several replicas processed every event, so failover took microseconds (martinfowler.com).
- LangWatch, 2026: folding events against stale reads. Migrating a live observability product to event sourcing on ClickHouse, the team saw trace summaries randomly reset to empty with no errors. Folds were loading prior state from a replica that had not yet received the latest write and applying new events to an empty state. Sequentially consistent reads cost 10 to 14 seconds; the fix was a Redis write-through cache of folded state, which cut each fold step from about 200 ms to 1.5 ms. The system now handles about 6,000 events per second (LangWatch).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| State-oriented storage (CRUD) | Simple, easy queries, mature tooling | History lost unless separately audited | Most business data |
| CRUD plus audit table or CDC | History for compliance, normal queries | Audit is secondary and can drift from intent | You need an audit trail but not replays |
| Event sourcing | Full history, temporal queries, new views from the past | Event versioning, projections, snapshots, learning curve | Ledgers, workflows, domains where history is the product |
Pitfalls in production
- Events as database diffs.
RowUpdated{column: "status"}events carry no business intent, so projections must reverse-engineer meaning. Model domain events. - Hot streams. One aggregate receiving hundreds of writes per second produces constant version conflicts. Redesign aggregate boundaries or split streams by time or sub-entity.
- Unbounded replays. Loading a stream with a million events takes seconds. Snapshot on a fixed event count and monitor stream lengths.
- Breaking event changes. Renaming or repurposing a field breaks old projections and replays. Use upcasters and additive changes only, and keep a schema registry.
- Side effects during replay. Rebuilding a projection re-sends emails or re-charges cards. Keep side effects out of projectors, or put external calls behind a gateway that is switched off during replay and guarded by idempotency keys (Fowler).
Leadership lens
- Ask whether the business actually values history. If nobody will ever query the past, event sourcing is mostly cost.
- Insist on a schema evolution policy and an owner for event contracts before the first event ships.
- Plan GDPR and retention up front. Retrofitting crypto-shredding onto years of events is painful.
- Budget team learning time. Engineers new to the model commonly put logic in the wrong place or model events as CRUD diffs.
Check yourself
Why does event sourcing not need row locks for concurrent writers on the same aggregate?
Show answer
Each append states the expected stream version, and the store rejects it if the stream moved on, so conflicting writers detect the race and retry with fresh state.
Can you delete all snapshots safely? Why?
Show answer
Yes. Snapshots are derived caches of the event stream, so state can always be rebuilt by replaying the events.
How do you change the shape of an event that has been stored for three years?
Show answer
Leave stored events untouched and add an upcaster that converts old versions to the new shape at read time, or add only optional fields with defaults.
Sources and further study
Primary
- Martin Fowler, The LMAX Architecture (2011): event sourcing with in-memory state, nightly snapshots and journal replay at millions of events per second.
- Oskar Dudycz, How Postgres sequences issues can impact your messaging guarantees (2022): why
bigserialpositions skip events and the transaction-ID fix.
Systems docs
- KurrentDB: database settings: chunk files, flushing and verification on startup.
- KurrentDB: appending events: expected stream state and optimistic concurrency in a client.
Books
- Designing Data-Intensive Applications (2nd ed.), Martin Kleppmann and Chris Riccomini, Ch 3 "Data Models and Query Languages", section "Event Sourcing and CQRS"; in the 1st ed., Ch 11 "Stream Processing", section "Databases and Streams".
- Clean Architecture (1st ed.), Robert C. Martin, Ch 6 "Functional Programming", section "Event Sourcing": storing transactions instead of state as a way to segregate mutability.
- Domain-Driven Design (1st ed.), Eric Evans, Ch 6 "The Life Cycle of a Domain Object": aggregates, the consistency boundary that becomes one event stream.
Other curricula
- roadmap.sh: System Design: a one-paragraph node (immutable events, replay, audit trail) that points to the Azure pattern page; nothing on concurrency, snapshots or schema evolution.
- donnemartin/system-design-primer: does not cover event sourcing; its closest material is message queues under "Asynchronism".
Watch
- Event Sourcing, Greg Young (GOTO 2014): the mechanics and motivation from one of the pattern's main proponents.
- A Decade of DDD, CQRS, Event Sourcing, Greg Young (DDD Europe): lessons and mistakes from ten years of real systems.
Free resources
- Event Sourcing (martinfowler.com): the canonical description with temporal queries and replays.
- Event Sourcing pattern (Azure Architecture Center): practical issues such as versioning, snapshots, and consistency.
- Event sourcing (microservices.io): how the pattern fits with sagas and CQRS in microservices.
- What is Event Sourcing? (Event Store blog): an introduction from the makers of a dedicated event database.