---
title: 'Event Sourcing'
source: 'https://academia.sh/en/courses/domain-driven-design/event-sourcing'
course: 'Domain-Driven Design'
language: en
updated: '2026-08-23T07:01:18+00:00'
license: 'CC BY-SA 4.0'
---

# Event Sourcing

Storing the event sequence instead of state: comparing how many of four questions the state table and the event sequence can answer, counting how many events are read to rebuild state from events, measuring how the number of events read and snapshots written changes as the snapshot interval changes, and comparing the byte size of the two persistence forms.

The previous lesson's read model was built from the deltas commands produce, but the deltas
themselves were discarded once applied. All that remained on the write side was the current
state. This leaves later questions unanswered: how many times was a delivery's address
corrected, how many transfer points did it pass through, how many attempts did delivery take?
None of these questions was in the record's schema, because the schema was designed for the
tracking screen.

**Event sourcing** reverses the order: what is actually stored is not the state but the
sequence of events that produced it. State becomes a derivative — the events are folded in
sequence to rebuild it.

## The Event as a Persistence Form

Queuing events, their delivery to consumers, and delivery guarantees were covered in the
Caching, Queues and Asynchronous Processing course. That is not this lesson's subject: here
the event sequence is treated as a **persistence form**, and the only question is — can state
be rebuilt from this sequence, and how many events does rebuilding read?

The events' names also follow the standard set in this course's Domain Events lesson:
`entered-transfer`, `address-corrected`, `delivery-attempted` are words the domain expert
uses, not the names of database operations. Every event corresponds to a transition.

```js
// fold-state.mjs — domain-language transitions, and folding state from the event sequence
export const EMPTY = {
  state: "none", point: null, address: null, transferCount: 0, attemptCount: 0, addressCorrections: 0,
};

const TRANSITION = {
  "accepted": (d, e) => ({ ...d, state: "accepted", address: e.address }),
  "entered-transfer": (d, e) => ({ ...d, state: "in-transfer", point: e.point, transferCount: d.transferCount + 1 }),
  "left-transfer": (d) => ({ ...d, state: "in-transit" }),
  "went-out-for-delivery": (d) => ({ ...d, state: "out-for-delivery" }),
  "delivery-attempted": (d) => ({ ...d, attemptCount: d.attemptCount + 1 }),
  "address-corrected": (d, e) => ({ ...d, address: e.address, addressCorrections: d.addressCorrections + 1 }),
  "delivered": (d) => ({ ...d, state: "delivered" }),
};

export function foldState(start, events) {
  let d = start;
  for (const e of events) {
    const transition = TRANSITION[e.type];
    if (transition === undefined) throw new RangeError(`unknown event: ${e.type}`);
    d = transition(d, e);
  }
  return d;
}
```

The fold function is pure and takes the starting state as a parameter. This detail is what
makes a snapshot possible: it can start from a state in the middle of the sequence.

## State-Storing Persistence

The other persistence to compare keeps only the current state. Its schema was chosen to
answer the tracking screen's questions: where is the package, what is its state, what is its
address.

```js
// state-model.mjs — persistence keeping only the current state: a schema designed for the tracking screen
const ROW = new Map();

export const stateModel = {
  apply(id, event) {
    const s = ROW.get(id) ?? { id, state: "none", point: null, address: null };
    if (event.type === "accepted") { s.state = "accepted"; s.address = event.address; }
    if (event.type === "entered-transfer") { s.state = "in-transfer"; s.point = event.point; }
    if (event.type === "left-transfer") s.state = "in-transit";
    if (event.type === "went-out-for-delivery") s.state = "out-for-delivery";
    if (event.type === "address-corrected") s.address = event.address;
    if (event.type === "delivered") s.state = "delivered";
    ROW.set(id, s);
  },
  read: (id) => structuredClone(ROW.get(id) ?? null),
  size: () => JSON.stringify([...ROW.values()]).length,
};
```

## The Event Store and the Snapshot

The event store accepts only appends; a written event is never modified. A **snapshot** keeps
the state computed at a given event count on a shelf, so a rebuild starts from that point
instead of the start of the sequence.

```js
// event-store.mjs — an append-only event sequence, a snapshot shelf, and a rebuild counter
import { EMPTY, foldState } from "./fold-state.mjs";

const SEQUENCE = new Map();
const SNAPSHOT = new Map();

export const eventStore = {
  append(id, event) {
    const sequence = SEQUENCE.get(id) ?? [];
    sequence.push(event);
    SEQUENCE.set(id, sequence);
  },
  ids: () => [...SEQUENCE.keys()],
  eventCount: (id) => SEQUENCE.get(id).length,
  totalEvents: () => [...SEQUENCE.values()].reduce((s, d) => s + d.length, 0),
  size: () => JSON.stringify([...SEQUENCE.entries()]).length,

  prepareSnapshots(interval) {
    SNAPSHOT.clear();
    if (interval === 0) return 0;
    for (const [id, sequence] of SEQUENCE) {
      for (let boundary = interval; boundary <= sequence.length; boundary += interval) {
        SNAPSHOT.set(`${id}:${boundary}`, foldState(EMPTY, sequence.slice(0, boundary)));
      }
    }
    return SNAPSHOT.size;
  },

  rebuild(id, interval) {
    const sequence = SEQUENCE.get(id);
    const boundary = interval === 0 ? 0 : Math.floor(sequence.length / interval) * interval;
    const snapshot = SNAPSHOT.get(`${id}:${boundary}`);
    const remaining = sequence.slice(boundary);
    return {
      state: foldState(snapshot ?? EMPTY, remaining),
      eventsRead: remaining.length,
      snapshotsRead: snapshot === undefined ? 0 : 1,
    };
  },
};
```

In a real system, snapshots are taken as events are appended. Because the interval is a
variable in this measurement, `prepareSnapshots` computes them all at once and returns how
many snapshots were written; this number stands in for the cost.

## Writing the Same History to Both Persistences

For the comparison to be valid, both persistences have to see the same events. The generator
works from a fixed number sequence; every twenty-fifth delivery is a problem shipment routed
through many transfers.

```js
// generate-history.mjs — a fixed generator writing the same events to both persistences
import { eventStore } from "./event-store.mjs";
import { stateModel } from "./state-model.mjs";

const POINTS = ["34", "06", "35", "01", "16"];

export function generateHistory(deliveryCount) {
  let x = 13;
  const next = () => (x = (x * 48271 + 11) % 2147483647);
  for (let i = 1; i <= deliveryCount; i += 1) {
    const id = `T${i}`;
    const write = (event) => { eventStore.append(id, event); stateModel.apply(id, event); };
    write({ type: "accepted", address: `A${(next() % 900) + 100}` });
    const transfers = i % 25 === 0 ? 14 + (next() % 8) : 1 + (next() % 4);
    for (let a = 0; a < transfers; a += 1) {
      write({ type: "entered-transfer", point: POINTS[next() % POINTS.length] });
      write({ type: "left-transfer" });
    }
    write({ type: "went-out-for-delivery" });
    const attempts = 1 + (next() % 3);
    for (let d = 0; d < attempts; d += 1) {
      write({ type: "delivery-attempted" });
      if (d < attempts - 1 && next() % 2 === 0) write({ type: "address-corrected", address: `A${(next() % 900) + 100}` });
    }
    write({ type: "delivered" });
  }
  return eventStore.totalEvents();
}
```

## Measurement

The measurement counts three things. First, answerability: how many of four questions each
persistence can answer. Second, rebuild cost: the events read, snapshots read, and snapshots
written across four different snapshot intervals. Third, byte size. Because a rebuild's
duration depends on hardware and the run environment, the measure used is not duration but
the **number of events read**; the workload grows directly with this number.

```js
// event-count.mjs — four questions' answerability, events read for a rebuild, and the snapshot effect
import { eventStore } from "./event-store.mjs";
import { stateModel } from "./state-model.mjs";
import { generateHistory } from "./generate-history.mjs";

const total = generateHistory(400);
console.log(`deliveries = 400, events = ${total}, event sequence size = ${eventStore.size()} bytes` +
  `, state table size = ${stateModel.size()} bytes`);

const QUESTIONS = [
  ["current state", (d) => d.state, (s) => s.state],
  ["address corrections", (d) => d.addressCorrections, (s) => s.addressCorrections],
  ["transfer count", (d) => d.transferCount, (s) => s.transferCount],
  ["delivery attempts", (d) => d.attemptCount, (s) => s.attemptCount],
];
const longest = eventStore.ids().reduce((a, b) => (eventStore.eventCount(a) >= eventStore.eventCount(b) ? a : b));
console.log(`longest history = ${longest}, events = ${eventStore.eventCount(longest)}`);

const fromEvents = eventStore.rebuild(longest, 0).state;
const fromRow = stateModel.read(longest);
console.log(`four questions for ${longest}:`);
let answered = 0;
for (const [name, eventPath, rowPath] of QUESTIONS) {
  const e = eventPath(fromEvents), s = rowPath(fromRow);
  if (s !== undefined) answered += 1;
  console.log(`  ${name.padEnd(20)} event sequence = ${String(e).padEnd(13)} state table = ${s ?? "no answer"}`);
}
console.log(`questions the state table answers = ${answered} / 4, the event sequence's = 4 / 4`);

const complete = JSON.stringify(eventStore.ids().map((id) => eventStore.rebuild(id, 0).state));
for (const interval of [0, 20, 10, 5]) {
  const written = eventStore.prepareSnapshots(interval);
  let eventsRead = 0, snapshotsRead = 0;
  const states = eventStore.ids().map((id) => {
    const r = eventStore.rebuild(id, interval);
    eventsRead += r.eventsRead; snapshotsRead += r.snapshotsRead;
    return r.state;
  });
  const single = eventStore.rebuild(longest, interval).eventsRead;
  console.log(`interval = ${String(interval).padStart(2)}  snapshots written = ${String(written).padStart(4)}` +
    `, events read = ${String(eventsRead).padStart(4)}, snapshots read = ${String(snapshotsRead).padStart(3)}` +
    `, events read for ${longest} = ${String(single).padStart(2)}, state same = ${JSON.stringify(states) === complete}`);
}
```

```sh
node event-count.mjs
```

```
deliveries = 400, events = 4699, event sequence size = 156377 bytes, state table size = 25493 bytes
longest history = T375, events = 49
four questions for T375:
  current state        event sequence = delivered     state table = delivered
  address corrections  event sequence = 1             state table = no answer
  transfer count       event sequence = 21            state table = no answer
  delivery attempts    event sequence = 3             state table = no answer
questions the state table answers = 1 / 4, the event sequence's = 4 / 4
interval =  0  snapshots written =    0, events read = 4699, snapshots read =   0, events read for T375 = 49, state same = true
interval = 20  snapshots written =   26, events read = 4179, snapshots read =  16, events read for T375 =  9, state same = true
interval = 10  snapshots written =  297, events read = 1729, snapshots read = 255, events read for T375 =  9, state same = true
interval =  5  snapshots written =  776, events read =  819, snapshots read = 400, events read for T375 =  4, state same = true
```

## Reading the Numbers

The four questions show the divide. The state table answered 1 of 4 questions; the remaining
three were unanswerable because they were not in its schema. The event sequence answered all
4, and did so without adding a single field to its schema: 1 address correction, 21 transfers,
3 delivery attempts. The difference between them is not the amount of stored information but
**which questions have to be known in advance**. In state-storing persistence, the question
must be known when the schema is designed; in an event sequence, it is answered the moment it
is asked.

The rebuild rows show the snapshot's effect. Without a snapshot, rebuilding the state of 400
deliveries reads 4699 events. At interval 20, the total drops to only 4179: because the
average history length is 11.7, most deliveries never reach twenty events, and only 16 of the
400 deliveries can use a snapshot. At interval 10, events read drops to 1729; at interval 5, to
819. The rule fits in one sentence: the number of events read does not fall until the snapshot
interval drops below the average history length.

The cost sits in the other column. As the interval drops to 5, events read falls from 4699 to
819 while the number of snapshots written rises from 0 to 776, and every rebuild adds one
snapshot read on top. A snapshot, in other words, is a trade-off that turns read load into
write load.

The longest history shows this on a single delivery. T375 has 49 events; a rebuild without a
snapshot reads 49 events, with interval 20 it reads 9, with interval 10 it reads 9, with
interval 5 it reads 4. At all four intervals, the state built came out identical to a full
rebuild; a snapshot is a shortcut, not a separate source of truth.

## Cost and Limit

The event sequence took up 156377 bytes, the state table 25493 bytes: roughly 6.1 times as
much. This ratio grows over time, because the event sequence only lengthens. The state table,
in contrast, grows with the delivery count, not with history length.

The second cost is schema evolution. Because a written event is never modified, if an event
type's fields change later, the old events keep their old shape, and the fold function has to
recognize both shapes; the `TRANSITION` table accumulates more than one version per event
type. In state-storing persistence, the same change is finished with a single migration.

The third is a domain constraint: the event sequence makes a record's history permanent. In
cases where a record must be fully deleted, the sequence's append-only nature is an obstacle,
and this shows that the design decision has to be made together with the domain.

The gain, against these three costs, arises under two conditions: the history itself has
value for the domain, and the questions that will later be asked are not known in advance. In
a record where only the current state is ever asked for, the event sequence takes up 6.1 times
the space and a fold function produces not one new answer in return.

## Summary

- Event sourcing stores not state but the event sequence that produces it; state is derived
  by the fold function, and because the fold is pure, it can be started from the middle of the
  sequence.
- Of four questions, the state table answered 1 and the event sequence answered 4; the
  difference is not the amount of stored information but whether the questions have to be
  known when the schema is designed.
- Rebuilding the state of 400 deliveries without a snapshot read 4699 events; at intervals 20,
  10, and 5, this number became 4179, 1729, and 819. The gain is small until the interval
  drops below the average history length.
- A snapshot turns read load into write load: as events read fell to 819, snapshots written
  rose to 776. At all four intervals, the state built came out identical to a full rebuild.
- The cost is roughly 6.1 times the space, an accumulating version count per event type, and
  an append-only sequence that makes deletion harder; the gain depends on the history having
  value for the domain.

## Next Step

This topic's four lessons all proceeded the same way: a model was built, a measure was
chosen, two arrangements were compared by that same measure. But none of the decisions were
made once and left alone. The number of decisions in the use case, the ports' names, the read
model's rows, the list of event types — each of these changes as the domain expert is
consulted further, and every change asks for a cost in code. Whether a model improvement is
truly an improvement is known only once that cost is counted. The next lesson takes up the
model's evolution together with the implementation: it measures the improvement made when one
of the domain expert's sentences has no counterpart in the model, the drop in a rule's number
of writing sites, the overlap rate of names, and the number of files an improvement edits.
