01 / The prompt
“Build our credit union’s accounts: deposits, withdrawals, a balance, and a statement.”
A small credit union. Priya has an account; she deposits at the branch, withdraws at the ATM and in the phone app, and once a year she disputes something on her statement. The obvious build keeps a balance in a row and adds or subtracts on every change, with a list of transactions beside it for the statement. It works, and every test passes, because every test sends one request at a time.
Two things the tests never do happen in the first month. The ATM and the app each read Priya’s balance before either writes, and the row keeps whichever write came last: in the lesson’s example it shows $70.00 when $40.00 is left. And a deploy changes how deposits are stored, while years of deposits in the old shape stay in the database: code that reads only the new shape shows $15.00 for an account holding $140.50.
The brief never answered: which record is the truth, what stops two decisions made from the same reading, and what reads a record written by code that no longer exists?
Event sourcing answers the first question by making the history the only record. Martin Fowler’s 2005 description is still the plainest: “Capture all changes to an application state as a sequence of events”, so that “we can discard the application state completely and rebuild it by re-running the events” (Event Sourcing, fetched 23 September 2026). The other two questions it does not answer for you.
02 / Name the shape
The events are the record. The balance is what they add up to.
In event sourcing, each account is a stream of events, facts in the past tense such as Deposited and Withdrew, appended in order and never changed. The balance is not stored as the
truth; it is a replay, a fold over the stream. A mistake is corrected the
way an accountant corrects one, by a new entry that reverses it.
Append only at the version you read, so two decisions from one reading cannot both land. Every event keeps the schema it was written with, and the code reads every schema it has ever written.
Who owns what:
| Part | Owns | Promises |
|---|---|---|
| The event store | Every account’s stream | Append-only; one event per version, refused if the version has moved |
| The withdrawal handler | The decision to pay | Decides from a replay, appends at the version it replayed, and tries again on refusal |
| The replay | The balance and the statement | Derived, so it can be thrown away and rebuilt; refuses a missing version |
| The upcaster | Every old event shape | Reads schema 1 for as long as a schema 1 event exists |
Words to put in a prompt or a review
- Event
- A fact that happened, named in the past tense and never edited.
- Stream
- The events of one thing, such as one account, in the order they happened.
- Expected version
- The version a writer read. The store appends only if the stream is still there.
- Replay
- Folding a stream from the start to get the current state.
- Projection
- A view built by replaying events, such as a balance or a monthly statement.
- Upcaster
- The code that reads an old event shape as today’s, without rewriting it.
Isn’t this just a transactions table?And how it differs from CQRS
A balance column with a transactions table beside it is two records of the same money, and they can disagree: a bug that updates one and not the other leaves you asking which to believe. With an event store there is one record, and the balance is computed from it. A transactions table written in the same database transaction as the balance, and never edited, is most of the way there.
Event sourcing is also not CQRS. CQRS gives reads their own model; the write side can be an ordinary table. Event sourcing chooses the write side’s record. They are often used together because a replayed stream is a natural source for read models, but either works alone.
03 / Two tellers and a deploy
Same account, same steps. Which balance is still true?
Each column runs one of the lesson’s designs on the same steps, and compares the balance it shows with the money that actually moved. Watch four situations, then open Try it and be the two tellers yourself.
One account, two tellers, and a deploy
A balance in a row
Reply: ok
What is stored
- balance = 10000
- no history
- Balance shown
- $100.00
- Money that moved
- $100.00
Event store: versions and an upcaster
Reply: ok
What is stored
- v1 Deposited 10000
- Balance shown
- $100.00
- Money that moved
- $100.00
Priya deposits $100.00.
Priya has $100. The ATM and the app each read $100 and pay out $30. The row takes the last write, $70, and the credit union is $30 short without a trace. The store refuses the app’s append at a version that has moved on; the app reads again and pays from $70.
Reduced motion: choose a scene to see its completed state.
Read this scene
Priya has $100. The ATM and the app each read $100 and pay out $30. The row takes the last write, $70, and the credit union is $30 short without a trace. The store refuses the app’s append at a version that has moved on; the app reads again and pays from $70.
A balance in a row: shows $100.00, money moved $100.00, no history.
Event store: versions and an upcaster: shows $100.00, money moved $100.00, 1 stored events.
Watch restarts the story when you come back. Step through shows where each chapter ends. Try it opens a new account whenever you change the design or press Reset.
04 / Read the shape
An append that checks a version, and a replay that reads every schema.
Basic form is the append. In the wild is the replay with its upcaster. At the call site is the withdrawal handler. Notice that no line anywhere updates a stored event.
The append: an event joins the account’s stream only if the stream is still at the version the caller read. Nothing is ever updated, so the check is the whole concurrency story.
/**
* Append one event to the account's stream, but only if the stream is still
* at the version the caller read. Someone else appended in between? Refuse,
* so the caller reads again and decides again. Nothing is ever updated.
*/
append(expected: number, event: Omit<StoredEvent, 'version'>): number {
if (this.events.length !== expected)
throw new Conflict(`at ${this.events.length}, not ${expected}`);
this.events.push({ version: expected + 1, ...event });
return expected + 1;
} // Append adds one event to the account's stream, but only if the stream is
// still at the version the caller read. Someone else appended in between?
// Refuse, so the caller reads again and decides again. Nothing is ever updated.
func (s *EventStore) Append(expected int, e StoredEvent) (int, error) {
if len(s.Events) != expected {
return 0, fmt.Errorf("%w: at %d, not %d", ErrConflict, len(s.Events), expected)
}
e.Version = expected + 1
s.Events = append(s.Events, e)
return e.Version, nil
} The two simpler designsA balance row, and events with no check
The balance row writes what it read minus the withdrawal, so the later of two writes wins. The event log keeps a true history but appends without a version check and reads events in whatever shape today’s code writes.
/** The balance is a number in a row, overwritten on every change. */
export class BalanceRow extends Account {
row = 0;
balance() {
return this.row;
}
deposit(cents: number) {
this.row += cents;
}
decide(cents: number): Decision {
return { cents, balance: this.row, version: 0 };
}
commit(d: Decision): Reply {
if (d.balance < d.cents) return 'declined';
this.row = d.balance - d.cents; // what it read, minus the withdrawal
return 'paid';
}
history() {
return null;
}
} /** Events appended with no expected version, read by whatever the code knows today. */
export class EventLog extends Account {
events: StoredEvent[] = [];
cents(event: StoredEvent): number {
return this.deployed === 1 ? toCents(event.data.amount ?? '') : (event.data.cents ?? 0);
}
balance() {
return this.events.reduce(
(sum, e) => sum + (e.type === 'Deposited' ? this.cents(e) : -this.cents(e)),
0
);
}
deposit(cents: number) {
this.events.push({
version: this.events.length + 1,
...encode('Deposited', cents, this.deployed)
});
}
decide(cents: number): Decision {
return { cents, balance: this.balance(), version: this.events.length };
}
commit(d: Decision): Reply {
if (d.balance < d.cents) return 'declined';
this.events.push({
version: this.events.length + 1,
...encode('Withdrew', d.cents, this.deployed)
});
return 'paid';
}
history() {
return this.events.map((e) => `v${e.version} ${e.type} ${this.cents(e)}`);
}
} The behavior these examples promiseChecked by 12 shared scenarios
- A balance row that two tellers read before either writes keeps the later write, and shows more money than is left. It has no history to show.
- Events appended without a version check record both payouts truthfully, including two that together overdraw the account.
- Code that reads only the shape it writes today counts every older event as zero.
- The event store refuses an append at a version that has moved; the teller replays and decides again. Its upcaster reads both schemas, so the balance is right before and after the deploy.
Every expectation was generated by a separate model written from the contract in the examples’ README, not copied from either implementation, and it is kept beside the examples. The row’s lost update does not need an event store to fix: a conditional update does it. It is in the comparison because it is what the obvious handler does.
Reading the TypeScriptErrors for refusals
append throws a Conflict when the version has moved, and replay throws a Gap when a version is missing; the handler
catches only the first. toCents splits the string at the point, because Number("0.29") * 100 is not 29.
Reading the GoWrapped sentinel errors
Append returns ErrConflict wrapped with the versions, and the
handler tests it with errors.Is. The three designs satisfy one Account interface. An event without a cents field decodes as zero
in Go, which is exactly how the naive replay goes wrong without complaint.
Run it yourselfNo dependencies
Save the complete files at the paths in their banners. Then run node --experimental-strip-types run.ts (Node 22.18 or later), or go run . in the Go folder. Both print:
balance-row · the ATM and the app at once: balance 7000, money moved 4000, history none -> wrong-balance event-store · the ATM and the app at once: balance 4000, money moved 4000, history 3 events -> consistent event-log · two withdrawals that do not both fit: balance -6000, money moved -6000, history 3 events -> overdrawn event-store · two withdrawals that do not both fit: balance 2000, money moved 2000, history 2 events -> consistent event-log · deposits change to cents: balance 1500, money moved 14050, history 4 events -> wrong-balance event-store · deposits change to cents: balance 14050, money moved 14050, history 4 events -> consistent
05 / Review the agent’s diff
“I migrated the old events so the replay only handles one shape.”
The upcaster has been in the code for a year, and an agent was asked to move deposits to integer cents. Read what its change does to the record.
06 / How it fails
An event store fails at its two edges: the append, and the old events.
The first four rows are shared scenarios the tests run; the last three are not modeled.
| What happens | Balance row | Events, no check | Event store |
|---|---|---|---|
| Two tellers pay $30 each from one reading of $100 | Shows $70 with $40 left. Nobody finds out until the books are reconciled. | Both payouts recorded; $40. | The second append is refused; the app re-reads $70 and pays; $40. |
| Two withdrawals of $80 from $100 | Shows $20; $160 paid out. | A true record of an overdraft: -$60. | The second is refused, re-reads $20, and declines. |
| A member asks why her balance is $75 | Nothing to show but the number. | Every event, with its version. | |
| A deploy changes how deposits are stored | A migration rewrites the row once. | Old deposits read as zero; balances drop at the deploy. | The upcaster reads both schemas; nothing changes. |
| A wrong event is appended, such as a fee charged twice | Edit the number, and the evidence is gone. | Append a reversing event; the statement shows the charge and the refund. Not modeled. | |
| A stream grows long | Not applicable. | Every read replays more events. A snapshot of the balance at a version, rebuilt from the events, bounds it. Not modeled. | |
| A member asks to be forgotten | Delete the row. | Events are never deleted, so personal data should not be in them, or be encrypted with a per-member key that can be destroyed. Not modeled. | |
The append check is optimistic concurrency, explained on its own in Optimistic concurrency, and old event shapes are a case of Versioning and compatibility: a stored event is a message to code that has not been written yet.
07 / Is it worth it?
A history and an upcaster, against a number you can read in one query.
| Change | Balance row | Event store |
|---|---|---|
| A second entry point: a teller app at the branch | It must take the same lock or conditional update as the others. | It appends with an expected version like everything else; the store refuses a stale one. |
| The database is replaced | A migration of two tables. | A copy of one append-only table. No difference that matters. |
| A new rule: monthly statements for the past two years | Only as far back as the transactions table was kept correctly. | Replay each stream up to each month’s end. |
| A fraud team wants every withdrawal, with its channel | A new table, a trigger, or a second write in every handler. | They read the streams, from the start, at their own pace. |
The costs are real: every read is a replay or a projection that can lag, every old event shape stays in the code as an upcaster, deleting personal data needs a plan made before the first event is written, and a query like “every account over $10,000” needs a projection built for it. When nobody will ask why a value is what it is, and two writers cannot race, keep the row.
Measure before and after:
- Reconciliation mismatches: the balance shown against deposits minus payouts, per account, once a day. With a row and a race, this is the number that is not zero.
- Append conflicts per thousand writes, and how many of them ended in a decline after the re-read.
- Replay time for the longest streams, which tells you when snapshots are due, and schemas still present in the store, which tells you which upcasters must stay.
This lesson did not measure a real credit union, and gives no numbers.
08 / Ask for it
One brief, two prompts, then one ticket.
Two agents running Claude Sonnet each got the brief from section 01. One prompt added an Event sourcing block: append-only streams with a version per event, the balance and statement derived by replay, an append at the expected version that is refused and retried, and a schema number on every event. Then each build went to a fresh agent with a ticket: money in the API becomes integer cents, and every existing account must keep its balance. A script ran two copies of each server on one database, filled a database with the old build and started the new one on it, and did the same to a control build we wrote to fail.
| Question | Plain prompt | Event-sourcing prompt | Control (not an agent) |
|---|---|---|---|
| A day at the counter | Correct | Correct | Correct |
| Two copies, ten $80 withdrawals from $100 | One paid per account, $20.00 left, five times out of five | One paid per account, $20.00 left, five times out of five | Paid more than once on 4 of 5 accounts, up to 10 times from $100 |
| Stored history edited in the database file | The ledger table accepts an update and a delete | The events table accepts an update and a delete | The lines table accepts an update and a delete |
| The cents ticket, on old accounts | 3 of 3 old accounts exact in cents; 0 stored rows changed | 3 of 3 old accounts exact in cents; 0 stored rows changed | Not asked |
| Its own tests | 19 of 19; after the ticket 20 of 20 | 27 of 27; after the ticket 26 of 26 | None |
Both agents got concurrency right, and neither needed the event-sourcing block to do it. The
brief said two copies of the server share one database and a balance must never go below zero,
and those two sentences were enough. The plain build put the read, the decision, and the write
in one BEGIN IMMEDIATE transaction, so the second copy waits:
return withTransaction(db, () => {
const row = db
.prepare("SELECT balance_cents FROM accounts WHERE id = ?")
.get(id) as { balance_cents: number } | undefined;
if (!row) throw new NotFoundError();
const newBalance = row.balance_cents + amountCents;
db.prepare("UPDATE accounts SET balance_cents = ? WHERE id = ?").run(
newBalance,
id
);
db.prepare(
`INSERT INTO ledger (account_id, at, kind, amount_cents, balance_cents, channel)
VALUES (?, ?, 'deposit', ?, ?, NULL)`
).run(id, new Date().toISOString(), amountCents, newBalance);
return { balanceCents: newBalance };
});
} The ledger build appended at the version it replayed, behind a primary key on the account and the version, and replayed again when the key refused it. It retries forever; nothing in the brief said how many times is enough.
while (true) {
const events = this.readStream(accountId);
const projection = this.project(events);
if (!projection) throw new NotFoundError(accountId);
const expectedVersion = events[events.length - 1].version;
const at = new Date().toISOString();
const ok = this.append(accountId, expectedVersion, "Deposited", { amountCents }, at);
if (ok) {
return {
id: accountId,
member: projection.member,
balance: formatAmount(projection.balanceCents + amountCents),
};
}
// Lost the race to append at expectedVersion + 1; replay and retry.
}
}
withdraw(accountId: string, amountCents: number, channel: Channel): AccountView {
if (amountCents <= 0) throw new BadRequestError("amount must be positive");
// eslint-disable-next-line no-constant-condition
while (true) {
const events = this.readStream(accountId);
const projection = this.project(events);
if (!projection) throw new NotFoundError(accountId);
if (projection.balanceCents - amountCents < 0) { The ticket was meant to be the schema change the story ends on, and it was not. Both agents had stored integer cents from the first day, although the API spoke dollar strings, so the new code read every old row as it was and changed none of them. The ledger build had left an upgrade function ready for a second schema that never came. That is a good outcome, and it means these runs say nothing about what an agent does when an event’s stored shape does change; section 05 is the diff to watch for.
What the checker could see apart was the record itself. The plain build keeps a balance column
and a transactions table, written together; the ledger build keeps only events. And neither
database refuses an edit: an UPDATE or DELETE on the history table,
run straight against the file, went through in both. The missing line is the one section 09
enforces: the events table refuses UPDATE and DELETE, and a retry after a refused append gives up after
a stated number of tries.
How the runs were made and checkedFour builds, two rounds, recorded as written
- Each pair of agents was launched at the same time; no agent was told about the other, the lesson, or the checker. Round two started from byte-for-byte copies of round one.
- All four builds are kept with checksums. For every question the checker restores a build into a fresh folder with its own database file.
- The checker’s first run counted the plain build’s upgrade as changing a stored row; that was the checker’s own deposit, made before it compared. It was fixed and re-run, and both runs are kept.
- One agent ran debugging servers on ports it was not given and wrote scratch files to
/tmp, against the prompt, then deleted them; another wrote curl replies to/tmpand deleted them. No agent stopped a process by name or pattern. - One run of each prompt is a sample, not a measurement of the model.
09 / Hold it there
An event store breaks when someone edits an event. Make the table refuse.
The store refuses a stale append
A purpose-built store has the check in its API. KurrentDB’s client lets you “supply a stream state” with an append, and “If the stream isn’t in that state, an exception will be thrown” (Appending events, fetched 23 September 2026). In a relational database, a primary key on the stream and the version does the same job: two appends at one version cannot both commit.
The table refuses an edit
A code review rule (“nothing updates the events table”) holds until the first migration. Put it in the schema. Run against SQLite in this repository’s Node by a script committed with the lesson, the table below refused a stale append, a rewrite, and a delete:
examples/checks/schema.sql CREATE TABLE events ( stream TEXT NOT NULL, version INTEGER NOT NULL, type TEXT NOT NULL, schema INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY (stream, version) -- one event per version ); CREATE TRIGGER events_no_update BEFORE UPDATE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END; CREATE TRIGGER events_no_delete BEFORE DELETE ON events BEGIN SELECT RAISE(ABORT, 'events are append-only'); END;output $ node src/lib/content/lessons/event-sourcing/examples/checks/append-only.mjs a stale append at version 1: UNIQUE constraint failed: events.stream, events.version rewriting the old deposit: events are append-only deleting it: events are append-onlyThe same rule can run as an import-and-query check in CI, the way Architecture as rules turns a sentence into a check: no file outside the store module mentions the events table.
Replay real events of every schema
Keep a fixture of stored events of each schema, taken from production with personal data removed, and the balances they produced. A test replays it on every change. The deploy in the story fails that test before it ships; a test that starts from an empty store, like the agent’s in section 05, never can.
Where this lives in React and SvelteA reducer is already a replay. Saving its actions is where you inherit the old ones.
Where it already is in your components
A useReducer or a Redux store is a fold over actions: the state is computed
from what happened, never saved on its own. Redux DevTools replays the same actions and
lands on the same state. In Svelte, an array of actions with a $derived total is
the same idea. Nothing there outlives the tab, so the actions can change shape whenever you
like.
When you have to own it
It becomes yours the day the actions are saved: an undo history kept across reloads, or an offline queue replayed later. Now last month’s saved entries meet this month’s code. Give each saved entry a schema number, upcast old ones when you load them, and treat undo as one more entry rather than an edit to the past.
A month’s budget built from actions. React folds them in a reducer; the Svelte version keeps the actions and derives the total.
import { useReducer } from 'react';
type Action =
| { type: 'spent'; id: string; cents: number; category: string }
| { type: 'recategorized'; id: string; category: string }
| { type: 'removed'; id: string };
type Entry = { cents: number; category: string };
type Budget = Record<string, Entry>;
// A reducer is a fold: the budget on screen is every action so far, applied in
// order. Redux DevTools can replay the same actions and land on the same state.
function budget(state: Budget, action: Action): Budget {
switch (action.type) {
case 'spent':
return { ...state, [action.id]: { cents: action.cents, category: action.category } };
case 'recategorized':
return { ...state, [action.id]: { ...state[action.id], category: action.category } };
case 'removed': {
const { [action.id]: _gone, ...rest } = state;
return rest;
}
}
}
export function MonthBudget() {
const [entries, dispatch] = useReducer(budget, {});
const total = Object.values(entries).reduce((sum, e) => sum + e.cents, 0);
return (
<section>
<button
type="button"
onClick={() =>
dispatch({ type: 'spent', id: crypto.randomUUID(), cents: 1250, category: 'groceries' })
}
>
Add $12.50 groceries
</button>
<p>This month: ${(total / 100).toFixed(2)}</p>
</section>
);
}
10 / Make the call
Keep the history when people will ask why.
Use an event store when the history is part of the product: money, stock, access, medical or legal records, anything someone will dispute, audit, or want to see as it was on a date. Keep a row, with a conditional update for concurrent writers, when the current value is all anyone will ever ask about. Reopen the decision when a new question about the past arrives that the row cannot answer, or when the upcasters outnumber the event types.
Take it with you
Explain it without saying “event sourcing”: “We never change what we wrote down. Each account is a list of things that happened, and the balance is what the list adds up to. Two people can’t both add to the list from the same page; the second is told to read it again. And when we change how we write things down, we still read the old pages the old way.” Then find a value in your own code that is overwritten in place, and ask who will want to know how it got there.
Paste into your next prompt, and fill in the blanks
Store each [account] as an append-only stream of events ([Deposited, Withdrew]), each with the [account] id, a version (1, 2, 3, …), a type, a schema number, and its data. No code updates or deletes a stored event; the table refuses it. [The balance and the statement] are derived by replaying the stream. A [withdrawal] replays, decides, and appends with the version it read as the expected version. If the stream has moved on, the append is refused and the handler replays and decides again, at most [3] times. When an event's shape changes, write the new shape under a new schema number and convert old events when they are read. Keep a fixture of real events of every schema, and a test that replays it. Correct a mistake by appending an event that reverses it, never by editing the one that was wrong.
Connections to follow nextRelated lessons
- CQRS gives the replayed history read models of their own.
- Change data capture gets a stream of changes out of a database that was not built as one.
- Event-driven architecture is other parts of the system following the same facts.
- Expand and contract is the table-side version of changing a shape while old code still runs.
- Optimistic concurrency is the expected-version check on its own.