Every system in this series so far has stored the current state: the balance is 100, the avatar
is avatar-v3.png, the order is in the shipped state. Every time something
changes, we overwrite the old value. This lesson reverses that: store the
sequence of things that happened, and treat the current state as merely something
computed from that sequence.
The lab builds a real event store on PostgreSQL with 200,000 events. Three measurements stand out. First, a projection that is not idempotent, run a second time, produces a balance exactly 2.00 times too high โ 9,132,892 instead of 4,566,446 โ with no error thrown at all; the read model is simply wrong. Second, rebuilding the entire read model from 200,000 events takes 235 ms when replayed in application code, meaning replay at this scale is routine, not an emergency operation. Third, a snapshot makes a long aggregate 6.6 times faster but makes a short aggregate slower โ it is a conditional tool, not a mandatory step.
node:22-alpine, no
dependencies, reusing minipg.js โ the hand-written PostgreSQL client from
Lesson 7.All the figures come from
worker/eventstore.js with 200,000 events spread across 1,000
aggregates. Both the database and the worker sit on the same machine, so the millisecond figures here
do not include real network cost; read them as ratios between the options, not as
absolute values for your own system.
14.1 State is the result of history
The same bank account, two ways to store it. The familiar way: one row, a
balance = 100 column, every transaction is an UPDATE. The event-sourcing way:
three rows, Deposited(+50), Deposited(+70), Withdrawn(-20), and 100
is the number we add up when we need it.
This bank account is an example of a concept that recurs throughout the lesson: an aggregate โ the unit that an event history is grouped around, each with its own sequence of events and its own version number, entirely separate from every other unit. Your account is one aggregate; someone else's account is a different aggregate with its own event sequence. An order, a shopping cart, an insurance policy โ each of those is an aggregate in exactly this sense too. By contrast, a row of system configuration or an entry in a product catalog usually does not need to become an aggregate this way: nobody needs to know who changed a product's description three months ago โ see the pitfall right below.
The difference is not which way is "more correct" but what each preserves and what each loses. From a
sequence of events, you can always compute the current state. From the current state, you
cannot reconstruct the history โ that information was overwritten by the UPDATE
and no longer lives anywhere. This transformation is one-way, and event sourcing's entire value sits in
the direction that gets lost.
Three abilities come nearly free from storing data this way. A full audit trail: every
change is a row with a timestamp and a cause, not an updated_at column that just says
"something changed". Debugging by rewinding: fold the log up to the exact moment right
before the incident and look at the state then, instead of guessing from application logs.
Answering questions nobody thought to ask at design time โ and this is the most valuable
ability: when the business side asks "how many people deposited money and then withdrew it all within 24
hours", with a state table you have to answer "we don't store that data"; with an event log, the answer is
already sitting in the history.
SELECT that should have been one line now has to go through a
read model.The test question: has anyone ever asked you "what was this value before"? If not, and there is no legal requirement to keep history, you are paying a very high price for an ability nobody uses. Event sourcing usually only earns its keep for a handful of core aggregates โ money, orders, permissions โ not for the whole system.
14.2 Events are immutable facts
One very small naming convention decides almost everything that follows: events are named in the
past tense. OrderPlaced, not PlaceOrder. The second one is a
command โ a request, which can be rejected, which can fail. The first one is a fact โ it
already happened, and there is no way to make it not have happened.
| Command | Event | |
|---|---|---|
| Naming | PlaceOrder, Withdraw |
OrderPlaced, Withdrawn |
| Can it be rejected? | Yes โ validation, insufficient balance, insufficient permission | No โ it already happened |
| Number of recipients | Exactly one โ the handler that processes it | As many as you like โ every projection is a listener |
| Fixing a mistake | Don't send it, or send it again | Write a compensating event |
The last row is the one violated most often. When you discover an event recorded the wrong amount, the
natural reflex is to UPDATE it to the correct value. Don't. Write a new
AmountCorrected event โ exactly the way an accountant never erases an entry but always writes
a reversing entry. The reason is not purity but very practical: every projection already built from the
old event still exists, and every projection built in the future will read the corrected one. The two
worlds no longer agree, and nobody can trace why.
UPDATE on the event table to "quickly fix a bug"
The cheapest and most effective defense: revoke
UPDATE and
DELETE privileges on the event table at the database layer, so even you cannot do it, not
even at 3 in the morning. This lab's entire worker/eventstore.js does not have a single
UPDATE statement touching the events table โ that is a self-imposed
constraint, and it is the precondition for every number in this lesson being reproducible.
Immutability throws in one more thing for free: optimistic concurrency control. If every
event carries a version number within its aggregate, and there is a
UNIQUE(aggregate, version) constraint, then two processes that both read "the account is at
version 1" and both try to write version 2 will produce exactly one winner. The lab runs exactly that
scenario:
{
"role": "concurrent",
"ketQua": [
{ "who": "A", "ketQua": "THANG โ su kien duoc ghi" },
{ "who": "B", "ketQua": "THUA โ bi tu choi",
"loi": "postgres: duplicate key value violates unique constraint \"events_aggregate_version_key\"" }
],
"soSuKienCuoiCung": 2,
"ghiChu": "Dung 2 su kien: version 1 va version 2. Nguoi thua KHONG de mat du lieu cua nguoi thang."
}
Worth comparing with an ordinary state table: two UPDATE balance = ... commands running at
the same time mean the later one overwrites the earlier one, both report success, and
nobody knows a change went missing. Here, the loser gets an error immediately โ and
"getting an error immediately" is something you can act on: reread the latest version, reapply the
business rule, retry. This is exactly the lost update that
Lesson 10 had to use a distributed lock to avoid; here it is
blocked by a single UNIQUE constraint, with no lock at all.
14.3 Projections: read the log, build a read table
Nobody queries the event log directly to render a UI โ folding up several thousand events on every page load is not viable. Instead, a projection worker reads the log and builds tables that are already optimized for each screen. A balances table is one projection. A recent-transactions list is another projection. A monthly report is a third projection. All three are built from the same log.
Lab measures both ways to build a read model from 200,000 events:
| Replay method | Time | Speed | When it matches reality |
|---|---|---|---|
One SQL GROUP BY |
43 ms | ~4.6 million events/s | Only when the projection folds down to one aggregation |
| Pull into the application and fold in code | 235 ms (174 ms is pulling the data) | ~850 thousand events/s | Closer to reality โ a real projection has business rules, branching by event type |
The number worth remembering is the second one, and it says this: at a scale of hundreds of thousands of events, rebuilding the entire read model is a matter of a few hundred milliseconds. At a scale of hundreds of millions, the same speed gives a few minutes. Both fall in the range of "runs during business hours", not "needs a maintenance window scheduled". That is why "fix the bug and replay" is a true statement rather than a slogan.
But it is only true when the projection is idempotent. The lab runs exactly one naive projection โ the kind that simply accumulates every event it reads โ and then runs it a second time:
// IDEMPOTENT=0 โ projection ngay tho
{
"soSuKienTrongLog": 200000,
"dungPhaiLa": 4566446,
"lan1": { "eventsApplied": 200000, "ms": 44, "tongSoDu": 4566446 },
"lan2": { "eventsApplied": 200000, "ms": 39, "tongSoDu": 9132892 },
"ketLuan": "SAI โ read model lech 4566446 (gap 2.00 lan)"
}
// IDEMPOTENT=1 โ co checkpoint `projection_state.last_seq`
{
"lan1": { "eventsApplied": 200000, "ms": 43, "tongSoDu": 4566446 },
"lan2": { "eventsApplied": 0, "ms": 1, "tongSoDu": 4566446 },
"ketLuan": "DUNG โ replay bao nhieu lan cung ra cung ket qua"
}
The most alarming detail is not the doubled number but the line "ms": 39: the wrong run
succeeded, faster than the first run, with no warning at all. The read model is simply wrong from
that second onward. The idempotent version applies 0 events on the second run and
finishes in 1 ms โ because it remembers which seq it already processed.
This is exactly why Lesson 11 had to come before this lesson. The two cheapest ways to be idempotent: store a checkpoint (the
last_seq already processed, as the lab does), or write the update as
setting a value instead of accumulating (SET balance = <recomputed total>
instead of SET balance = balance + x). Accumulation is never idempotent.
14.4 CQRS: splitting the write path from the read path
Once you have an event log and projections, the system naturally splits into two halves with two quite different sets of requirements. CQRS (Command Query Responsibility Segregation) is just the name for acknowledging that split and designing for it properly.
| WRITE path (command) | READ path (query) | |
|---|---|---|
| What it does | Validate business rules, produce an event, append it | Read a table already built for the right screen |
| Optimized for | Correctness and order | Read speed โ data duplication is perfectly fine |
| How it scales | Hard โ order within each aggregate must be preserved | Easy โ replicate as many read copies as you want (Lesson 7) |
| Consistency | Strong โ enforced by the database | Eventual โ always a beat behind the log |
The last row is the one you pay for. The read model is built after the event has been written, so there is always a window where the log already has the event but the read table does not yet. That window is usually a few milliseconds and nobody notices โ except exactly one person: the one who just clicked the button.
With CQRS, that fix does not exist, because the read model is not a slow copy of the write table โ it has an entirely different structure, and there is no "primary" to pin back to. Three approaches are commonly used instead: (1) the write path always returns the computed result, and the UI uses it without reading again; (2) optimistic update โ the UI draws the new state itself and syncs later; (3) the write path returns a version number, and the UI waits until the read model reaches that version before reading. What they share: all three must be designed from the start at the UI layer โ they cannot be patched in afterward.
The reverse is true too: you can have event sourcing without CQRS if you only need a single read model. The expensive part is event sourcing, not CQRS โ so if your problem is simply "reads are slow", try CQRS first and keep your current way of storing state.
14.5 Running it for real: snapshots, schema change, and what you lose
Section 14.3 said replaying 200,000 events takes 235 ms. But that was replaying the whole system to build a read model โ a rare event. The more frequent job is reading the current state of one aggregate to process a command, and once an aggregate accumulates tens of thousands of events, folding it from scratch every time is unacceptable. A snapshot is the answer: store the state at version N ahead of time, then fold only the events after it.
| Aggregate | Fold from scratch | Snapshot + tail | Result |
|---|---|---|---|
| Long โ 100,000 events, snapshot at v90,000 | 7.398 ms | 1.120 ms | Faster by 6.6 times |
| Short โ 200 events, snapshot at v180 | 0.377 ms | 0.981 ms | Slower by 2.6 times |
The second result is the more memorable one. For a short aggregate, a snapshot makes it slower โ because it adds one more round of querying (read the snapshot, then read the tail), and the cost of that round is larger than what it saves. A snapshot is not a mandatory step of event sourcing; it is a conditional optimization, and the condition is that the aggregate has to be long enough. The right approach is to measure the distribution of aggregate length in your own system and set a threshold, instead of turning on snapshots for everything.
The most important practical consequence: when you fix the event-folding logic, you must delete every snapshot at the same time you deploy. Skip this step and the system will mix state computed under the old rules (from the snapshot) with events folded under the new rules (the tail) โ a kind of error that is very hard to spot because it only shows up on aggregates that already have a snapshot.
The second operational issue is that event schemas change over time. Last year's event
had an amount field in dong; this year you add currency. The log is immutable so
you cannot edit the old events โ instead, the reading code has to understand both formats, usually through
an upcasting layer: read an old-version event and lift it into the new shape right at
load time. That layer only ever grows over the years and can never be deleted โ it is a real
debt, and it needs to be counted into the cost from the moment you decide.
WHERE balance < 0. With a plain event log, that question has no direct answer โ the
balance does not exist anywhere until someone folds the log. You can only answer questions you have
already built a projection for.For whoever operates the system, this is genuinely painful: every question that comes up mid-incident turns into "write a new projection, then wait for the replay". A common way to reduce the pain is to always keep one flat "current state" read model around, close enough to the relational model to query freely โ but then you are paying the cost of both models at once, and that is a trade-off worth stating up front, not discovering afterward.
Reproduce the measurements yourself
cd blog/sysdesign/sysdesign-lab
# --- Write 200,000 events into the append-only log ---
./tools/eventstore-test.sh seed 200000 # 1802ms ยท 111,003 events/s
# --- Run the projection AGAIN: idempotency decides everything ---
./tools/eventstore-test.sh replay2x 0 # naive: run 2 -> 9,132,892 (DOUBLED)
./tools/eventstore-test.sh replay2x 1 # idempotent: run 2 -> 0 events, 1ms
# --- Replay in application code: a more honest number than one GROUP BY ---
./tools/eventstore-test.sh projectApp # 200k events / 235ms (174ms is pulling data)
# --- Snapshot only pays off for a LONG aggregate ---
./tools/eventstore-test.sh snapshot # 100k events: 7.398ms -> 1.120ms (6.6x)
./tools/eventstore-test.sh snapshot acc-0 # 200 events: 0.377ms -> 0.981ms (SLOWER)
# --- Two people writing version 2 at once: UNIQUE(aggregate, version) blocks the lost update ---
./tools/eventstore-test.sh concurrent # A wins ยท B gets a duplicate-key error
In summary
Event sourcing changes the question "what is the current state" into "what happened". That transformation is one-way: from the log you can always compute the state, from the state you cannot reconstruct the log. All the value โ full audit trail, rewinding to debug, answering questions nobody thought to ask at design time โ sits in the direction that gets lost.
The price is real too and needs saying in full: every engineer who touches that part has to learn one more model; the upcasting layer only ever grows over the years; and ad-hoc querying โ the cheapest thing with a relational table โ turns into "write a new projection, then wait for the replay". That is why event sourcing usually only earns its keep for a handful of core aggregates, not the whole system.
On operations, two numbers are worth carrying with you. Replaying 200,000 events in application code takes 235 ms โ fast enough that "fix the bug and rebuild the read model" is a business-hours task, and that is this model's real superpower. But it is only true when the projection is idempotent: the naive version, run a second time, produces a balance exactly 2.00 times too high, successfully, faster than the first run, with no warning at all.
And a snapshot is not a mandatory step but a conditional optimization: 6.6 times faster with a 100,000-event aggregate, but slower by 2.6 times with a 200-event aggregate. It is a cache, so every rule of caching from Lesson 5 applies without exception โ including the most annoying one: change the logic and you must wipe every snapshot at the same time you deploy.
Lesson 15 steps back to look at the whole picture: after paying for network hops, distributed locks, idempotency, and eventual consistency yourself, you finally have enough evidence to weigh the question every team runs into โ whether to split a system into microservices, and what that split actually costs. This time the cost will be measured, not guessed at.
๐ References
- Martin Fowler โ Event Sourcing: the original article that shaped the term, the source behind all of sections 14.1 and 14.2
- Martin Fowler โ CQRS: the original definition of Command Query Responsibility Segregation, the source for section 14.4
- Microsoft Azure Architecture Center โ Event Sourcing pattern: the official description of projections, replay, and snapshots, the source for sections 14.3 and 14.5
- Microsoft Azure Architecture Center โ CQRS pattern: eventual consistency between the write path and the read path, the source for the read-your-writes callout in section 14.4
-
Wikipedia โ Optimistic concurrency control: the mechanism behind the
UNIQUE(aggregate, version)constraint in section 14.2 - PostgreSQL โ UNIQUE Constraints: the official docs for the constraint used to block lost updates in the lab
- Wikipedia โ Domain-driven design: the origin of the aggregate concept defined in section 14.1
Download the lab source
The full event store used in the lab: an append-only log, two kinds of projection (idempotent and not), snapshots, and the two-writers-same-version measurement mode โ every number in this lesson comes from this file:
Download eventstore.js (event log, projection, snapshot โ 0 dependencies)
Comments