Skip to main content

02 - Event Sourcing: State as an Immutable Sequence of Events

You save events instead of state and rebuild it by rereading them: with SQLite you see optimistic concurrency, time travel, snapshots and when it does not pay.

What do you think about this article?

At a Glance

You save events instead of state and rebuild it by rereading them: with SQLite you see optimistic concurrency, time travel, snapshots and when it does not pay.

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

AspectClassic CRUDEvent sourcing
What is savedThe current state, overwrittenThe sequence of events, append only
HistoryLost, unless separate audit tables existIntrinsic
State at a past instantNot availableYou replay the sequence up to that instant
Reading the stateA queryA computation over the events, or a projection
Complex queriesDirectly on the tablesRequire a separate read model
Correcting an errorThe data is modifiedA 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

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.

Try These Tools

Free developer tools related to this topic.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects