The balance is not a number: it is the sum of what happened
The balance of your current account is not a value that the bank overwrites with every operation: it is the result of all the
movements ever recorded. If you want to know how much you had at 14:00 yesterday, the bank takes the movements up to that hour
and adds them up. Event Sourcing applies the same idea to software: instead of saving the current state of an
entity and updating it with an UPDATE, you save the sequence of events that produced it, and you derive the state
by reading them again.
The advantage is that you never lose the history: you know how an order got into that state, you can rebuild it as it was at any hour, and you can correct an error by adding an event instead of rewriting the past.
The price is that reading the state costs a computation, that old events must remain readable forever and that complex queries require a second read model (the topic of the article on CQRS). You need to have read the fundamentals on domain events. Here we build a small order out of events, save it in an event store on SQLite, rebuild it at a past hour and try to write to it from two places at once.
CRUD and event sourcing compared
| Aspect | Classic CRUD | Event sourcing |
|---|---|---|
| What is saved | The current state, overwritten | The sequence of events, append only |
| History | Lost, unless separate audit tables exist | Intrinsic |
| State at a past instant | Not available | You replay the sequence up to that instant |
| Reading the state | A query | A computation over the events, or a projection |
| Complex queries | Directly on the tables | Require a separate read model |
| Correcting an error | The data is modified | A compensating event is added |
In the table, the deciding row is the last one. In CRUD, correcting a datum erases it; in event sourcing the error stays in the history with its correction next to it, which is the reason the model is widespread where an auditor must be able to reconstruct what happened: finance, healthcare, logistics.
Where nobody has ever asked “what did it look like yesterday?”, the model only adds complexity. Martin Fowler describes the model as the guarantee that all the changes to the application state are stored as a sequence of events; the Azure Architecture Center lists its costs, including the need for snapshots for long sequences.
Aggregate: deciding and applying are two different functions
An aggregate is the boundary within which the business rules must always hold: an order cannot be confirmed if it is empty, and cannot be modified if it is already confirmed. In event sourcing the aggregate breaks down into two distinct operations. The decision looks at the current state and a command and produces one or more events, or refuses.
The evolution takes the state and an event and produces the new state, without checks: an event is a fact, and facts are not up for debate. Keeping them separate has a practical consequence: when you replay the history you apply only the evolution, so an event recorded five years ago is not re-evaluated with today's rules.
export type OrderEvent =
| { type: 'OrderCreated'; orderId: string; customerId: string }
| { type: 'ItemAdded'; productId: string; quantity: number; unitPrice: number }
| { type: 'ItemRemoved'; productId: string }
| { type: 'OrderConfirmed' }
| { type: 'OrderCancelled'; reason: string };
export interface OrderState {
id: string;
customerId: string;
items: Record<string, { quantity: number; unitPrice: number }>;
status: 'Draft' | 'Confirmed' | 'Cancelled';
total: number;
}
function totalOf(items: OrderState['items']): number {
return Object.values(items).reduce((sum, i) => sum + i.quantity * i.unitPrice, 0);
}
// Pure function: (state, event) -> new state. It validates nothing, it does not read the clock.
export function evolve(state: OrderState | null, e: OrderEvent): OrderState {
switch (e.type) {
case 'OrderCreated':
return { id: e.orderId, customerId: e.customerId, items: {}, status: 'Draft', total: 0 };
case 'ItemAdded': {
const items = { ...state!.items, [e.productId]: { quantity: e.quantity, unitPrice: e.unitPrice } };
return { ...state!, items, total: totalOf(items) };
}
case 'ItemRemoved': {
const { [e.productId]: _removed, ...items } = state!.items;
return { ...state!, items, total: totalOf(items) };
}
case 'OrderConfirmed':
return { ...state!, status: 'Confirmed' };
case 'OrderCancelled':
return { ...state!, status: 'Cancelled' };
}
}
export function replay(events: OrderEvent[], from: OrderState | null = null): OrderState {
return events.reduce(evolve, from) as OrderState;
}
// The decisions: they look at the state and return events, or refuse.
export function addItem(state: OrderState, productId: string, quantity: number, unitPrice: number): OrderEvent[] {
if (state.status !== 'Draft') throw new Error('Items cannot be added to an order that is not a draft');
if (quantity <= 0) throw new Error('Invalid quantity');
return [{ type: 'ItemAdded', productId, quantity, unitPrice }];
}
export function confirm(state: OrderState): OrderEvent[] {
if (state.status !== 'Draft') throw new Error('Only draft orders can be confirmed');
if (Object.keys(state.items).length === 0) throw new Error('Empty order');
return [{ type: 'OrderConfirmed' }];
}
evolve is a pure function: it does not read the clock, it does not throw exceptions, and it returns a new state
using spread instead of modifying the old one. This is why the replay is deterministic: the same sequence always gives the
same state. replay is a reduce over evolve. The functions addItem and
confirm are the decisions: if the state does not allow them they throw an error and produce no events.
Note that the total is not a field
that is summed at every event, but is recalculated from the map of items: an ItemAdded event for a product that
is already present replaces its line, and you must keep this in mind when choosing the semantics of your events (here the
event says “the line for this product is now worth this”).
The event store: a table where you only write at the end
An event store is a database that does a few things well: it appends events to a stream (the
sequence of one aggregate, here Order-789) and reads them back in order. It does not update and does not delete.
The delicate part is concurrency: two operators can read the order at version 4 and both decide to modify it. If the second
writes without checks, its decision is based on a state that no longer exists.
The solution is optimistic
concurrency: the writer declares the version it has read, and the append fails if the stream has moved on in the
meantime. The listing uses SQLite, included in Node 24 as the experimental module node:sqlite, in place of
PostgreSQL: the logic is the same, what changes is the type of the error and the way the database serializes writes.
import { DatabaseSync } from 'node:sqlite';
import type { OrderEvent } from './order.ts';
export class ConcurrencyError extends Error {}
export class EventStore {
private db = new DatabaseSync(':memory:');
constructor() {
this.db.exec(`CREATE TABLE event_store (
id INTEGER PRIMARY KEY AUTOINCREMENT,
stream_id TEXT NOT NULL,
version INTEGER NOT NULL,
event_type TEXT NOT NULL,
event_data TEXT NOT NULL,
created_at TEXT NOT NULL,
UNIQUE (stream_id, version)
)`);
}
// Appends events to the stream, but only if the latest version is the one the caller has read.
append(streamId: string, expectedVersion: number, events: OrderEvent[], at: string): number {
this.db.exec('BEGIN IMMEDIATE');
try {
const row = this.db.prepare('SELECT COALESCE(MAX(version), 0) AS v FROM event_store WHERE stream_id = ?').get(streamId) as { v: number };
if (row.v !== expectedVersion) {
throw new ConcurrencyError(`${streamId}: expected v${expectedVersion}, found v${row.v}`);
}
const insert = this.db.prepare('INSERT INTO event_store (stream_id, version, event_type, event_data, created_at) VALUES (?, ?, ?, ?, ?)');
events.forEach((e, i) => insert.run(streamId, expectedVersion + i + 1, e.type, JSON.stringify(e), at));
this.db.exec('COMMIT');
return expectedVersion + events.length;
} catch (err) {
this.db.exec('ROLLBACK');
throw err;
}
}
load(streamId: string, opts: { afterVersion?: number; until?: string } = {}): { version: number; event: OrderEvent }[] {
const rows = this.db
.prepare('SELECT version, event_data FROM event_store WHERE stream_id = ? AND version > ? AND created_at <= ? ORDER BY version')
.all(streamId, opts.afterVersion ?? 0, opts.until ?? '9999') as { version: number; event_data: string }[];
return rows.map((r) => ({ version: r.version, event: JSON.parse(r.event_data) as OrderEvent }));
}
}
BEGIN IMMEDIATE takes the SQLite write lock right away (the documentation says that it starts a write without
waiting for the first statement), so the MAX(version) check and the INSERTs are atomic with respect
to the other writers. If the version found does not match the expected one it throws ConcurrencyError and the
ROLLBACK undoes everything.
The UNIQUE (stream_id, version) constraint is a second line of defence that holds even if someone writes while
skipping the check. On PostgreSQL there is no BEGIN IMMEDIATE: you let the INSERT fail on the constraint
and translate the error (SQLSTATE code 23505, uniqueness violation) into a conflict. In both cases the caller must be able to
reload the stream and redo the decision.
Experiment: rebuild, go back in time, write from two places
The following program creates an order, adds two items, confirms it and then shows the three things an event store must be able to do. Every append carries the instant at which it happened, passed explicitly, so that the result is repeatable.
import { addItem, confirm, replay } from './order.ts';
import { EventStore, ConcurrencyError } from './store.ts';
const store = new EventStore();
const stream = 'Order-789';
// History: each append carries the instant at which it happened.
let v = store.append(stream, 0, [{ type: 'OrderCreated', orderId: '789', customerId: 'cli-3' }], '2026-11-12T09:00:00Z');
let state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, addItem(state, 'pen', 2, 3.5), '2026-11-12T09:05:00Z');
state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, addItem(state, 'notebook', 1, 4.0), '2026-11-12T09:10:00Z');
state = replay(store.load(stream).map((r) => r.event));
v = store.append(stream, v, confirm(state), '2026-11-12T09:20:00Z');
console.log('stream version :', v);
const finale = replay(store.load(stream).map((r) => r.event));
console.log('current state :', finale.status, finale.total, Object.keys(finale.items).join(','));
// Time travel: all the events with created_at up to 09:07.
const alle0907 = replay(store.load(stream, { until: '2026-11-12T09:07:00Z' }).map((r) => r.event));
console.log('state at 09:07 :', alle0907.status, alle0907.total, Object.keys(alle0907.items).join(','));
// Optimistic concurrency: two operators read v4, only the first one writes.
const letto = store.load(stream).at(-1)!.version;
try {
store.append(stream, letto, [{ type: 'OrderCancelled', reason: 'customer' }], '2026-11-12T10:00:00Z');
store.append(stream, letto, [{ type: 'OrderCancelled', reason: 'error' }], '2026-11-12T10:00:01Z');
} catch (e) {
console.log(e instanceof ConcurrencyError ? 'conflict: ' + (e as Error).message : 'other error');
}
// A refused decision writes nothing.
try {
addItem(finale, 'eraser', 1, 1);
} catch (e) {
console.log('refused:', (e as Error).message);
}
// Output:
// stream version : 4
// current state : Confirmed 11 pen,notebook
// state at 09:07 : Draft 7 pen
// conflict: Order-789: expected v4, found v5
// refused: Items cannot be added to an order that is not a draft
The final version is 4 because the four appends each wrote one event. The current state (Confirmed, 11
euros: two pens at 3.5 plus a notebook at 4) is obtained by reading the whole stream again. The “at 09:07” line
uses until, which filters on created_at: at that hour the order had only the pens, so 7 euros and
still a draft.
The conflict is the most instructive line: both operators read version 4, the first wrote (taking it
to 5), the second was refused with expected v4, found v5. The last line shows that a refused decision
writes nothing. On a production database, time travel by instant requires an index on created_at; my
store does not have one, and for a stream of thousands of events the scan would be linear.
Snapshot: do not reread a thousand events to know the total
An aggregate with thousands of events becomes expensive to load, because every read applies the whole history. A snapshot is a photograph of the state at a given version of the stream: to rebuild you start from the photograph and apply only the later events. It does not replace the events, which remain the source of truth; it is a cache you can throw away and redo. The program counts the events applied in the two ways on an order with a thousand items, and verifies that the result is identical.
import { evolve, replay } from './order.ts';
import type { OrderEvent, OrderState } from './order.ts';
// An order with a very long draft: 1 creation event and 1000 item additions.
const eventi: OrderEvent[] = [{ type: 'OrderCreated', orderId: 'big', customerId: 'cli-1' }];
for (let i = 0; i < 1000; i++) eventi.push({ type: 'ItemAdded', productId: `p${i}`, quantity: 1, unitPrice: 2 });
let applicati = 0;
const conteggio = (s: OrderState | null, e: OrderEvent) => { applicati++; return evolve(s, e); };
// Without a snapshot: all the events are applied again.
const pieno = eventi.reduce(conteggio, null as OrderState | null)!;
console.log('without snapshot: events applied =', applicati, '| total =', pieno.total);
// Snapshot at stream version 1001 (all the events above), then 5 new events.
const snapshot = { version: 1001, state: replay(eventi) };
const nuovi: OrderEvent[] = Array.from({ length: 5 }, (_, i) => ({ type: 'ItemAdded', productId: `n${i}`, quantity: 1, unitPrice: 2 }));
applicati = 0;
const ripreso = nuovi.reduce(conteggio, snapshot.state as OrderState | null)!;
console.log('with snapshot : events applied =', applicati, '| total =', ripreso.total);
// Equivalence check: snapshot + tail == full replay.
const completo = replay([...eventi, ...nuovi]);
console.log('same state :', JSON.stringify(completo) === JSON.stringify(ripreso));
// Output:
// without snapshot: events applied = 1001 | total = 2000
// with snapshot : events applied = 5 | total = 2010
// same state : true
The numbers are counts, not timings, and therefore reproducible: 1001 applications of evolve against 5. The last
line is the check that matters: a wrong snapshot (for instance one saved before a correction) would give a different state
without anyone noticing, so in production it is wise to compare snapshot and full replay now and then. The
moment to create it is a choice: every N events is the simplest, but the Azure Architecture Center points to
intervals of N events as a common technique. There is no absolutely right N; you choose it by measuring what a replay costs
on your load.
When event sourcing yes and when no
It pays off when the history is a requirement of the domain and not a luxury. The cases in which it pays are these:
- an audit trail required by regulation or by contract;
- retrospective questions, such as “how many orders were drafts at 16:00?”;
- the need to understand how an anomalous state came about, by reproducing the sequence;
- an event-driven architecture already in place, in which the events exist anyway.
The first point is the only one that alone justifies the cost; the others are benefits that add up but that can often be had with an audit table too. If the domain is a registry with no tracking obligations, CRUD is the right choice: remember that every published event becomes a contract to maintain (see the versioning in the previous article) and that complex reads require a separate model, which are covered by CQRS and its combination with event sourcing.
What you can do now
You can write an aggregate as a pair of functions, decision and evolution, and rebuild it by rereading the events. You can design an event store with a version per stream and optimistic concurrency, you can run a query at a past instant and you know when and how a snapshot reduces the work. What is missing is what is not in this article: the distribution of events to the other services, which needs a broker and the outbox pattern. When a view built from the events is late, the user sees old data: the article on eventual consistency covers it.
Sources
- Martin Fowler, Event Sourcing - the definition: all the changes to the state stored as a sequence of events, consulted on 2026-11-12
- Azure Architecture Center, Event Sourcing pattern - costs of the model, snapshots at intervals of N events, dedicated event stores with optimistic concurrency, consulted on 2026-11-12
- SQLite, BEGIN TRANSACTION - behaviour of
BEGIN IMMEDIATEand the possible SQLITE_BUSY error, consulted on 2026-11-12
The three programs (es.ts, snapshot.ts and the modules order.ts and store.ts that they import) were run with Node.js v24.11.0; the outputs in the comments are the real ones. The node:sqlite module is marked experimental: on other Node versions the behaviour may change. The PostgreSQL details cited in the text (code 23505) were not run.
Commenti
Caricamento commenti...
Sign in to comment