Lesson 16 / 25

Events as the Source of Truth

Store state as a sequence of events and rebuild it by replaying them.

Store what happened, derive what is

Traditional systems store current state and overwrite it: the account balance is 4,500, and how it got there is lost unless you log it separately. Event sourcing stores the sequence of events for each entity (an aggregate) as the system of record: AccountOpened, MoneyDeposited 5000, MoneyWithdrawn 500. Current state is computed by replaying (folding) the events in order. To handle a command, load the aggregate's events, rebuild its state, check business rules, and append new events. Appends use optimistic concurrency: you state the version you read, and the store rejects the append if someone else appended in the meantime. Benefits: a complete audit trail, the ability to answer "what was the state last Tuesday?", new read models built by replaying history, and natural integration with event-driven systems. Event stores include EventStoreDB (KurrentDB), Marten on PostgreSQL, Axon Server, or a well-designed table in a relational database.

State as a fold over events

Each event moves the state forward; replaying the list from the start reproduces current state.

A horizontal row of event tiles with an arrow running through them into a single summary box on the right.
Figure 6.1 — Replaying an event stream to compute current state.

An event-sourced bank account

State comes only from applying events; commands decide which new events to append.

from dataclasses import dataclass, field

@dataclass
class Account:
    id: str
    balance: int = 0
    version: int = 0
    pending: list = field(default_factory=list)

    def apply(self, event):
        if event["type"] == "MoneyDeposited":
            self.balance += event["amount"]
        elif event["type"] == "MoneyWithdrawn":
            self.balance -= event["amount"]
        self.version += 1

    def withdraw(self, amount):
        if amount > self.balance:
            raise ValueError("insufficient funds")      # rule checked against replayed state
        event = {"type": "MoneyWithdrawn", "amount": amount}
        self.apply(event)
        self.pending.append(event)

def load(store, account_id):
    acct = Account(account_id)
    for e in store.read_stream(f"account-{account_id}"):
        acct.apply(e)
    return acct

acct = load(store, "a-7")
acct.withdraw(500)
store.append(f"account-a-7", acct.pending, expected_version=acct.version - len(acct.pending))

Events are immutable

You never edit or delete an event to fix a mistake. You append a correcting event (DepositReversed), exactly as an accountant posts a reversing entry rather than erasing the ledger.

Quick check: How is the current state of an event-sourced aggregate obtained?

  • By reading a single row that is overwritten on every change
  • By querying the broker for the latest message
  • By replaying its events in order (optionally from a snapshot)
  • By asking every consumer
Answer

By replaying its events in order (optionally from a snapshot) — State is derived by applying the stored events in sequence.