← Architecture
Data Record change as history

Event sourcing

Keep what happened, and work out where you are.

You already trust one event store every day. A git repository keeps commits, not files: the files are what the history adds up to, and git log can tell you why any line is there. Let’s keep a credit union’s accounts the same way, and see what it buys and what it costs.

TypeScriptGoOne account, three designs, two recorded builds and a follow-up ticket.

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:

What each part of the ledger owns
PartOwnsPromises
The event storeEvery account’s streamAppend-only; one event per version, refused if the version has moved
The withdrawal handlerThe decision to payDecides from a replay, appends at the version it replayed, and tries again on refusal
The replayThe balance and the statementDerived, so it can be thrown away and rebuilt; refuses a missing version
The upcasterEvery old event shapeReads 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.

Event sourcing

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

  1. v1 Deposited 10000
Balance shown
$100.00
Money that moved
$100.00
01/ 04
The ATM and the app at once

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.

TypeScriptReading
store.ts
/**
 * 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;
}
GoAlongside
store.go
// 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.

accounts.ts
/** 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;
	}
}
accounts.ts
/** 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.

The agent’s pull request

“New deposits are stored in integer cents. I migrated the 41,000 stored deposit events to the new shape so the replay only has to handle one. All ledger tests pass.”

// migrations/2026-09-deposits-in-cents.ts
			(added) for (const event of await store.readAll('Deposited')) {
			(added)   event.data = { cents: toCents(event.data.amount) };
			(added)   event.schema = 2;
			(added)   await store.overwrite(event);
			(added) }
			// ledger.ts
			(removed) const cents = upcast(event);
			(added) const cents = event.data.cents; // every event is schema 2 now
			
The ledger tests start from an empty store. What do you do with this change?

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.

Failure modes of keeping a credit union’s accounts
What happensBalance rowEvents, no checkEvent store
Two tellers pay $30 each from one reading of $100Shows $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 $100Shows $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 $75Nothing to show but the number.Every event, with its version.
A deploy changes how deposits are storedA 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 twiceEdit the number, and the evidence is gone.Append a reversing event; the statement shows the charge and the refund. Not modeled.
A stream grows longNot 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 forgottenDelete 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.

The shared four changes, against a balance row with a transactions table
ChangeBalance rowEvent store
A second entry point: a teller app at the branchIt 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 replacedA migration of two tables.A copy of one append-only table. No difference that matters.
A new rule: monthly statements for the past two yearsOnly 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 channelA 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.

What the checker found, run 2026-09-23
QuestionPlain promptEvent-sourcing promptControl (not an agent)
A day at the counterCorrectCorrectCorrect
Two copies, ten $80 withdrawals from $100One paid per account, $20.00 left, five times out of fiveOne paid per account, $20.00 left, five times out of fivePaid more than once on 4 of 5 accounts, up to 10 times from $100
Stored history edited in the database fileThe ledger table accepts an update and a deleteThe events table accepts an update and a deleteThe lines table accepts an update and a delete
The cents ticket, on old accounts3 of 3 old accounts exact in cents; 0 stored rows changed3 of 3 old accounts exact in cents; 0 stored rows changedNot asked
Its own tests19 of 19; after the ticket 20 of 2027 of 27; after the ticket 26 of 26None

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:

server.ts · plain prompt
  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.

ledger.ts · event-sourcing prompt
  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 /tmp and 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.

  1. 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.

  2. 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-only

    The 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.

  3. 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.

ReactAlready in your code
MonthBudget.tsx
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

Take the ledger into your editor. Add a snapshot of the balance every 100 events, and a test that a replay from the snapshot and a replay from the start agree.

Back to architecture →