Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

System Design (High-Level Design)  Module 13 – Architecture styles at scale

Event-driven design, event sourcing and CQRS

How event notification, event-carried state transfer, event sourcing and CQRS differ, with an account rebuilt from its events in 14 languages.

  • Advanced
  • 25 minutes
  • Examples run with Python 3.14.8, Pyodide 314.0.7, Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free core, Pro depth: 9 of 25 minutes free
Free: Four patterns differ in what an event carries and what is true · State is a fold over the events, and a snapshot shortens it · Practise: rebuild an account from its events. With a pass: 2 in-depth parts and 3 interview answers, about 16 minutes.

What you will learn

  • Distinguish event notification, event-carried state transfer, event sourcing and CQRS by what an event carries and where the truth lives
  • Rebuild an entity's state from its events with a fold, from the first event or from a snapshot
  • Build a read model that survives duplicate events, and hide its lag from the person who just wrote
  • Prevent two concurrent withdrawals from overdrawing an account, and change an event's shape without rewriting history
  • Decide when event sourcing is worth its costs for one part of a system

Before you start

What you’ll learn · Before you start

What you will learn

  • Distinguish event notification, event-carried state transfer, event sourcing and CQRS by what an event carries and where the truth lives
  • Rebuild an entity's state from its events with a fold, from the first event or from a snapshot
  • Build a read model that survives duplicate events, and hide its lag from the person who just wrote
  • Prevent two concurrent withdrawals from overdrawing an account, and change an event's shape without rewriting history
  • Decide when event sourcing is worth its costs for one part of a system

Before you start

On this page

A bank account that has seen 1,000 deposits and withdrawals can be stored as one row holding today’s balance, or as the 1,000 events themselves, from which any earlier balance can be computed. Keeping the events is one of four patterns behind the word “event”. Event-driven design connects parts of a system by facts that happened, such as “order placed”, instead of by commands.

Event notification sends a small event and lets interested services call back for details. Event-carried state transfer puts the data in the event, so consumers keep their own copy and never call back. Event sourcing stores the events themselves as the source of truth and computes the current state by replaying them. CQRS (command query responsibility segregation) handles writes and reads with separate models and usually feeds the read models from events, a little later. Use notification for loose coupling, state transfer to stop callbacks, event sourcing where history matters, as in a ledger, and CQRS where reads and writes need different shapes.

Four patterns differ in what an event carries and what is true

Martin Fowler’s What do you mean by “Event-Driven”? separates the four because teams that mix them up blame the wrong one when an event-driven design gets hard. Look at what each arrow carries and which box holds the truth:

Four event patterns: a thin notification, an event that carries its data, an event store, and separate read and write models.Event notificationEvent-carried state transferEvent sourcingCQRSOrdersShippingOrdersShipping, withits own copyWithdraw 70Account: state =fold of its eventsEvent store:append onlyWrite model:commandsProjectorRead model:shaped for a screenQueriesOrderPlaced: id 42GET order 42OrderPlaced: id, items,addressMoneyWithdrawnreplayeventsupdates, alittle later

Event notification, event-carried state transfer, event sourcing and CQRS

Text description of the diagram

The figure has four panels, one under the other.

  1. Event notification. The orders service sends shipping an OrderPlaced event that carries only the order's id, 42. Shipping then calls orders back to get order 42.
  2. Event-carried state transfer. Orders sends shipping an OrderPlaced event that carries the id, the items and the address. Shipping keeps its own copy and needs no call back.
  3. Event sourcing. A command to withdraw 70 reaches an account whose state is the fold of its events. The account appends a MoneyWithdrawn event to an append-only event store, and its state can always be rebuilt by replaying the stored events.
  4. CQRS. Commands go to a write model, which emits events. A projector applies the events, a little later, to a read model shaped for one screen, and queries read from that read model.

Event notification keeps coupling low: the sender announces “order 42 was placed” and does not care who reacts. The cost is that a flow spread across notifications is written down nowhere; you often find it only by watching the running system. Consumers also call back, which keeps load on the sender.

Event-carried state transfer removes the callback: the event carries the order’s items and address, and shipping keeps the copy it needs. Shipping keeps working when orders is down, and answers without a network call. It pays with copies of data in several places and a consumer that must manage its own copy.

Event sourcing goes further. Each change is appended as an event to an append-only store, which becomes the system of record, and an entity’s current state is rebuilt by replaying its events, which the Azure event sourcing pattern calls rehydration. Event sourcing gives you the complete history: an audit trail, the state as of any past moment, and new views built later from old events.

CQRS splits the model that handles commands from the models that answer queries. A read model: A copy of data shaped for one screen or query and fed by the write side's events. It answers quickly but lags a little behind the writes. is shaped for one screen or query and is usually fed by the write side’s events, so it lags behind the writes. CQRS needs no event sourcing, and event sourcing needs no CQRS, although they are often combined. Fowler warns that for most systems CQRS adds risky complexity and should be kept to the parts that need it (CQRS). Follow one order through all four:

Interactive · message by message One order, told four ways Step through order 42 four times. Watch what each event carries, who holds the truth and who has to call back.
One order, told four ways, step 8 of 8. Messages so far: Orders to Events: OrderPlaced: id 42; Events to Shipping or a view: OrderPlaced: id 42; Shipping or a view to Orders: GET order 42; Orders to Shipping or a view: OrderPlaced: items, address; Orders to Events: append OrderPlaced; Events to Orders: replay order 42's stream; Events to Shipping or a view: OrderPlaced, a little later.Ordersthe senderEventsthe system of recordShipping or a viewread model: My ordersOrderPlaced: id 42OrderPlaced: id 42GET order 42OrderPlaced: items, addressappend OrderPlacedreplay order 42's streamOrderPlaced, a little later

The drawing shows the whole scene; its 8 steps are listed below.

Every step as text
  1. Notification. Order 42 is placed. With event notification, orders publishes OrderPlaced with the order's id and nothing more, and it does not care who reacts.
  2. The thin event arrives. Shipping receives the event but not the order, so it still lacks the items and the address it needs.
  3. The call back. Shipping calls orders for the details. Every consumer adds such a call for every event, and none of them can work while orders is down.
  4. State transfer. With event-carried state transfer, the event carries the items and the address, so shipping keeps its own copy and never calls back.
  5. Event sourcing. With event sourcing, the events are the truth. Orders appends OrderPlaced to the order's stream in an append-only store, its system of record.
  6. Rebuilt by replay. To handle the next command for order 42, orders reads the stream back and folds its events, in order, into the order's current state.
  7. CQRS. With CQRS, a projector applies the same events to a read model shaped for one screen, My orders, a little after each write. Queries read that model, never the write side.
  8. Four answers. The same order gave four designs. The truth lives in the sender's database for the first two, in the event store for event sourcing, and in the write model for CQRS, whose read models lag behind it.

What each pattern changes
Criterion Event notificationEvent-carried state transferEvent sourcingCQRS
The event carries An id and a factThe changed dataEvery change, kept for goodWhatever the read models need
Source of truth The sender's databaseThe sender's databaseThe event storeThe write model
Readers see Current data, by calling backTheir own copy, slightly behindState rebuilt from eventsRead models, slightly behind
Main cost Flows nobody can read in codeCopies to keep in syncSchema changes, replay timeTwo models and their lag
When to choose Loose coupling for occasional reactionsConsumers that must work without the senderLedgers, audits, history that mattersReads and writes with very different shapes or loads

Five techniques answer the main problems of these patterns:

  • Idempotent projections skip an event they have already applied.
  • A transactional outbox writes each event in the same transaction as its change.
  • Version tokens let a read wait for the write it follows.
  • Expected versions refuse an append decided on an old version of the stream.
  • Upcasters convert old events to the current shape as they are read.

Checkpoint

Check: The four event patterns

  1. Question 1 of 2 Shipping receives OrderPlaced events that carry only the order's id, and calls orders back for every one. Orders goes down for ten minutes. What happens to shipping?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It cannot get the items or the address, so new orders wait until orders is back

    Event notification keeps coupling low, but every consumer calls the sender back, which adds load and stops working when the sender is down. Event-carried state transfer puts the data in the event, so shipping would keep its own copy and keep working.

  2. Question 2 of 2 In which pattern is the event store itself the system of record, the source of truth?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Event sourcing

    Only event sourcing makes the stored events the source of truth. In notification and state transfer the sender's database is the truth, and in CQRS the write model is, whatever it stores.

State is a fold over the events, and a snapshot shortens it

Rebuilding state from events is a Building one result from a sequence by applying each item in turn to the result built so far, starting from an initial value. In event sourcing the current state is a fold over the events.: start from nothing and apply each event in order to the state the previous ones built. A command such as “withdraw 70” is checked against that state, and only a command that succeeds appends an event, so the events record facts and the fold never validates them. A snapshot (event sourcing): The stored state of an entity after a given event, so that a rebuild starts there and replays only the events that came after it. stores the state after some event, so a rebuild starts there instead of at the first event. Step through the four events of the exercise below:

Interactive · stage by stage Four events folded into an account, with a snapshot Step through the fold one event at a time. Each box is a stored event; the line under it is the state after it.
Four events folded into an account, with a snapshot, step 6 of 6. Showing #1 opened (balance 0, version 1), #2 deposited 250 (balance 250, version 2), #3 withdrawn 100 (150 at version 3, snapshot), #4 deposited 40 (balance 190, version 4). In focus: #1 opened, #2 deposited 250.#1 openedbalance 0, version 1#2 deposited 250balance 250, version 2#3 withdrawn 100150 at version 3, snapshot#4 deposited 40balance 190, version 4

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Start from nothing. The fold starts with no state and applies the first event. The account is opened with a balance of 0, and its version, the number of events applied so far, is 1.
  2. Apply the next event. The deposit of 250 is applied to the state the first event built, which gives 250 at version 2. The fold never checks an event, because it records something that already happened.
  3. Store a snapshot. The withdrawal of 100 leaves 150 at version 3. A snapshot stores this state, so that a later rebuild can start here instead of at event 1.
  4. Rebuild from the snapshot. The deposit of 40 gives 190 at version 4. A rebuild that starts from the snapshot folds only this event and reaches the same state as a replay of all four.
  5. A command is refused. A command to withdraw 500 is checked against the balance of 190 and refused, so nothing is appended. Commands decide and events record, and the stream still holds four facts.
  6. A question about the past. The balance as of event 2 is the fold of the first two events, 250. A table that holds only today's balance cannot answer that question.

The same fold at scale, in 14 languages that all printed the lines under the tabs: the program builds a stream of 1,000 events, refusing any withdrawal that would overdraw, rebuilds the account from the first event and from a snapshot stored after event 900, and asks for the balance as of event 500:

One program · 14 languages An account rebuilt from its events, with a snapshot O(n) to fold n events; a snapshot leaves the tail

Python 3.14 · main.py

from dataclasses import dataclass


@dataclass(frozen=True)
class Event:
    kind: str  # "opened", "deposited" or "withdrawn"
    amount: int = 0


@dataclass(frozen=True)
class Account:
    balance: int = 0
    version: int = 0


def apply(state: Account, event: Event) -> Account:
    # An event is a fact that already happened, so apply never refuses one.
    if event.kind == "deposited":
        return Account(state.balance + event.amount, state.version + 1)
    if event.kind == "withdrawn":
        return Account(state.balance - event.amount, state.version + 1)
    return Account(0, state.version + 1)


def fold(events: list[Event], state: Account = Account()) -> Account:
    for event in events:
        state = apply(state, event)
    return state


events = [Event("opened")]
balance = refused = step = 0
while len(events) < 1000:
    step += 1
    amount = 1 + step * 7919 % 500
    if step * 31 % 100 < 55:
        events.append(Event("deposited", amount))
        balance += amount
    elif amount <= balance:
        events.append(Event("withdrawn", amount))
        balance -= amount
    else:
        refused += 1  # the command fails, so no event is stored

n = len(events)
full = fold(events)
snapshot = fold(events[:900])  # stored after event 900
same = "yes" if fold(events[900:], snapshot) == full else "no"
print(f"events stored: {n}; withdrawals refused: {refused}")
print(f"full replay: {n} events, balance {full.balance}, version {full.version}")
print(f"from snapshot 900: {n - 900} events, the same state: {same}")
print(f"as of event 500: balance {fold(events[:500]).balance}")

In Python: Frozen dataclasses compare field by field, so the check is one ==, and apply returns a new Account instead of changing the one it was given.

Recorded with CPython 3.14.8 on macOS 26 arm64. It runs in your browser with Pyodide 314.0.7 (CPython 3.14.2). To run it yourself: python3 main.py

JavaScript Node 24 · main.mjs

const EMPTY = { balance: 0, version: 0 };

function apply(state, event) {
  // An event is a fact that already happened, so apply never refuses one.
  switch (event.kind) {
    case "deposited":
      return { balance: state.balance + event.amount, version: state.version + 1 };
    case "withdrawn":
      return { balance: state.balance - event.amount, version: state.version + 1 };
    default:
      return { balance: 0, version: state.version + 1 };
  }
}

const fold = (events, state = EMPTY) => events.reduce(apply, state);

const events = [{ kind: "opened", amount: 0 }];
let balance = 0;
let refused = 0;
for (let step = 1; events.length < 1000; step++) {
  const amount = 1 + ((step * 7919) % 500);
  if ((step * 31) % 100 < 55) {
    events.push({ kind: "deposited", amount });
    balance += amount;
  } else if (amount <= balance) {
    events.push({ kind: "withdrawn", amount });
    balance -= amount;
  } else {
    refused++; // the command fails, so no event is stored
  }
}

const n = events.length;
const full = fold(events);
const snapshot = fold(events.slice(0, 900)); // stored after event 900
const latest = fold(events.slice(900), snapshot);
const same = latest.balance === full.balance && latest.version === full.version ? "yes" : "no";
console.log(`events stored: ${n}; withdrawals refused: ${refused}`);
console.log(`full replay: ${n} events, balance ${full.balance}, version ${full.version}`);
console.log(`from snapshot 900: ${n - 900} events, the same state: ${same}`);
console.log(`as of event 500: balance ${fold(events.slice(0, 500)).balance}`);

In JavaScript: fold is Array.prototype.reduce with apply as the reducer. The states are plain objects, which have no equality of their own, so the check compares both fields.

Recorded with Node.js 24.21.0 LTS (V8 13.6) on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0). To run it yourself: node main.mjs

Java 25 LTS · Main.java

import java.util.ArrayList;
import java.util.List;

public class Main {
  record Event(String kind, int amount) {}

  record Account(int balance, int version) {}

  static final Account EMPTY = new Account(0, 0);

  static Account apply(Account state, Event event) {
    // An event is a fact that already happened, so apply never refuses one.
    return switch (event.kind()) {
      case "deposited" -> new Account(state.balance() + event.amount(), state.version() + 1);
      case "withdrawn" -> new Account(state.balance() - event.amount(), state.version() + 1);
      default -> new Account(0, state.version() + 1);
    };
  }

  static Account fold(List<Event> events, Account state) {
    for (Event event : events) {
      state = apply(state, event);
    }
    return state;
  }

  public static void main(String[] args) {
    List<Event> events = new ArrayList<>(List.of(new Event("opened", 0)));
    int balance = 0;
    int refused = 0;
    for (int step = 1; events.size() < 1000; step++) {
      int amount = 1 + step * 7919 % 500;
      if (step * 31 % 100 < 55) {
        events.add(new Event("deposited", amount));
        balance += amount;
      } else if (amount <= balance) {
        events.add(new Event("withdrawn", amount));
        balance -= amount;
      } else {
        refused++; // the command fails, so no event is stored
      }
    }

    int n = events.size();
    Account full = fold(events, EMPTY);
    Account snapshot = fold(events.subList(0, 900), EMPTY); // stored after event 900
    String same = fold(events.subList(900, n), snapshot).equals(full) ? "yes" : "no";
    System.out.println("events stored: " + n + "; withdrawals refused: " + refused);
    System.out.println(
        "full replay: " + n + " events, balance " + full.balance() + ", version " + full.version());
    System.out.println("from snapshot 900: " + (n - 900) + " events, the same state: " + same);
    System.out.println("as of event 500: balance " + fold(events.subList(0, 500), EMPTY).balance());
  }
}

In Java: Account is a record, so equals compares balance and version, and subList returns views of the stream, so the snapshot's prefix and its tail are never copied.

Recorded with OpenJDK 25.0.4.1 LTS (Eclipse Temurin) on macOS 26 arm64. It runs in your browser with a TeaVM 0.16.0 build in QuickJS (quickjs-emscripten 0.32.0). To run it yourself: java Main.java

C++23 · main.cpp

#include <print>
#include <span>
#include <string_view>
#include <vector>

struct Event {
  std::string_view kind; // "opened", "deposited" or "withdrawn"
  int amount = 0;
};

struct Account {
  int balance = 0;
  int version = 0;
  bool operator==(const Account &) const = default;
};

Account apply(const Account &state, const Event &event) {
  // An event is a fact that already happened, so apply never refuses one.
  if (event.kind == "deposited")
    return {state.balance + event.amount, state.version + 1};
  if (event.kind == "withdrawn")
    return {state.balance - event.amount, state.version + 1};
  return {0, state.version + 1};
}

Account fold(std::span<const Event> events, Account state = {}) {
  for (const Event &event : events)
    state = apply(state, event);
  return state;
}

int main() {
  std::vector<Event> events = {{"opened"}};
  int balance = 0;
  int refused = 0;
  for (int step = 1; events.size() < 1000; ++step) {
    const int amount = 1 + step * 7919 % 500;
    if (step * 31 % 100 < 55) {
      events.push_back({"deposited", amount});
      balance += amount;
    } else if (amount <= balance) {
      events.push_back({"withdrawn", amount});
      balance -= amount;
    } else {
      ++refused; // the command fails, so no event is stored
    }
  }

  const std::span<const Event> all = events;
  const auto n = all.size();
  const Account full = fold(all);
  const Account snapshot = fold(all.first(900)); // stored after event 900
  const bool same = fold(all.subspan(900), snapshot) == full;
  std::println("events stored: {}; withdrawals refused: {}", n, refused);
  std::println("full replay: {} events, balance {}, version {}", n,
               full.balance, full.version);
  std::println("from snapshot 900: {} events, the same state: {}", n - 900,
               same ? "yes" : "no");
  std::println("as of event 500: balance {}", fold(all.first(500)).balance);
}

In C++: std::span views the stream without copying it, so first(900) is the snapshot's prefix and subspan(900) the tail after it; the defaulted operator== compares both fields.

Recorded with Apple clang 21.0.0 (C++23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang++ -std=c++23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.cpp -o main && ./main

C# 14 · .NET 10 · app.cs

using System.Globalization;

CultureInfo.CurrentCulture = CultureInfo.InvariantCulture;

var events = new List<Event> { new("opened") };
int balance = 0;
int refused = 0;
for (int step = 1; events.Count < 1000; step++)
{
    int amount = 1 + step * 7919 % 500;
    if (step * 31 % 100 < 55)
    {
        events.Add(new Event("deposited", amount));
        balance += amount;
    }
    else if (amount <= balance)
    {
        events.Add(new Event("withdrawn", amount));
        balance -= amount;
    }
    else
    {
        refused++; // the command fails, so no event is stored
    }
}

int n = events.Count;
Account full = Fold(events, new Account());
Account snapshot = Fold(events[..900], new Account()); // stored after event 900
string same = Fold(events[900..], snapshot) == full ? "yes" : "no";
Console.WriteLine($"events stored: {n}; withdrawals refused: {refused}");
Console.WriteLine($"full replay: {n} events, balance {full.Balance}, version {full.Version}");
Console.WriteLine($"from snapshot 900: {n - 900} events, the same state: {same}");
Console.WriteLine($"as of event 500: balance {Fold(events[..500], new Account()).Balance}");

static Account Apply(Account state, Event e) => e.Kind switch
{
    // An event is a fact that already happened, so Apply never refuses one.
    "deposited" => new Account(state.Balance + e.Amount, state.Version + 1),
    "withdrawn" => new Account(state.Balance - e.Amount, state.Version + 1),
    _ => new Account(0, state.Version + 1),
};

static Account Fold(IEnumerable<Event> events, Account state) => events.Aggregate(state, Apply);

record Event(string Kind, int Amount = 0);

record Account(int Balance = 0, int Version = 0);

In C#: Records compare by value, so the check is one ==; events[..900] and events[900..] are ranges, and LINQ's Aggregate is the fold.

Recorded with C# 14 on .NET 10.0.12 (SDK 10.0.401) on macOS 26 arm64. It runs in your browser with the .NET runtime for WebAssembly 10.0.12. To run it yourself: dotnet run app.cs

C23 · main.c

#include <stddef.h>
#include <stdio.h>
#include <string.h>

enum { STREAM = 1000 };

typedef struct {
  const char *kind; // "opened", "deposited" or "withdrawn"
  int amount;
} Event;

typedef struct {
  int balance;
  int version;
} Account;

static Account apply(Account state, Event event) {
  // An event is a fact that already happened, so apply never refuses one.
  if (strcmp(event.kind, "deposited") == 0)
    return (Account){state.balance + event.amount, state.version + 1};
  if (strcmp(event.kind, "withdrawn") == 0)
    return (Account){state.balance - event.amount, state.version + 1};
  return (Account){0, state.version + 1};
}

static Account fold(const Event *events, size_t count, Account state) {
  for (size_t i = 0; i < count; i++)
    state = apply(state, events[i]);
  return state;
}

int main(void) {
  static Event events[STREAM];
  size_t n = 0;
  events[n++] = (Event){"opened", 0};
  int balance = 0;
  int refused = 0;
  for (int step = 1; n < STREAM; step++) {
    const int amount = 1 + step * 7919 % 500;
    if (step * 31 % 100 < 55) {
      events[n++] = (Event){"deposited", amount};
      balance += amount;
    } else if (amount <= balance) {
      events[n++] = (Event){"withdrawn", amount};
      balance -= amount;
    } else {
      refused++; // the command fails, so no event is stored
    }
  }

  const Account empty = {0, 0};
  const Account full = fold(events, n, empty);
  const Account snapshot = fold(events, 900, empty); // stored after event 900
  const Account latest = fold(events + 900, n - 900, snapshot);
  const bool same =
      latest.balance == full.balance && latest.version == full.version;
  printf("events stored: %zu; withdrawals refused: %d\n", n, refused);
  printf("full replay: %zu events, balance %d, version %d\n", n, full.balance,
         full.version);
  printf("from snapshot 900: %zu events, the same state: %s\n", n - 900,
         same ? "yes" : "no");
  printf("as of event 500: balance %d\n", fold(events, 500, empty).balance);
  return 0;
}

In C: The stream is a fixed array of 1,000 events and fold takes a pointer and a count, so events + 900 is the tail after the snapshot; structs have no ==, so the check compares both fields.

Recorded with Apple clang 21.0.0 (C23) and GCC 16.2.0 on macOS 26 arm64. It runs in your browser with Clang in WebAssembly (YoWASP) 22.0.0-git20542-10. To run it yourself: clang -std=c23 -O2 -Wall -Wextra -Werror -pedantic -fsanitize=address,undefined main.c -o main && ./main

PHP 8.5 · main.php

<?php

declare(strict_types=1);

final readonly class Event
{
    public function __construct(public string $kind, public int $amount = 0) {}
}

final readonly class Account
{
    public function __construct(public int $balance = 0, public int $version = 0) {}
}

function apply(Account $state, Event $event): Account
{
    // An event is a fact that already happened, so apply never refuses one.
    return match ($event->kind) {
        'deposited' => new Account($state->balance + $event->amount, $state->version + 1),
        'withdrawn' => new Account($state->balance - $event->amount, $state->version + 1),
        default => new Account(0, $state->version + 1),
    };
}

/** @param list<Event> $events */
function fold(array $events, Account $state = new Account()): Account
{
    return array_reduce($events, apply(...), $state);
}

$events = [new Event('opened')];
$balance = 0;
$refused = 0;
for ($step = 1; count($events) < 1000; $step++) {
    $amount = 1 + $step * 7919 % 500;
    if ($step * 31 % 100 < 55) {
        $events[] = new Event('deposited', $amount);
        $balance += $amount;
    } elseif ($amount <= $balance) {
        $events[] = new Event('withdrawn', $amount);
        $balance -= $amount;
    } else {
        $refused++; // the command fails, so no event is stored
    }
}

$n = count($events);
$full = fold($events);
$snapshot = fold(array_slice($events, 0, 900)); // stored after event 900
$same = fold(array_slice($events, 900), $snapshot) == $full ? 'yes' : 'no';
echo "events stored: {$n}; withdrawals refused: {$refused}\n";
echo "full replay: {$n} events, balance {$full->balance}, version {$full->version}\n";
echo 'from snapshot 900: ' . ($n - 900) . " events, the same state: {$same}\n";
echo 'as of event 500: balance ' . fold(array_slice($events, 0, 500))->balance . "\n";

In PHP: Readonly classes hold the event and the state, == compares two objects of one class property by property, and array_reduce is the fold.

Recorded with PHP 8.5.11 (CLI) on macOS 26 arm64. It runs in your browser with php-wasm 0.2.0 (PHP 8.5.2). To run it yourself: php main.php

Rust 1.99 · main.rs

#[derive(Clone, Copy)]
enum Event {
    Opened,
    Deposited(i64),
    Withdrawn(i64),
}

#[derive(Clone, Copy, Default, PartialEq)]
struct Account {
    balance: i64,
    version: usize,
}

fn apply(state: Account, event: &Event) -> Account {
    // An event is a fact that already happened, so apply never refuses one.
    let balance = match event {
        Event::Opened => 0,
        Event::Deposited(amount) => state.balance + amount,
        Event::Withdrawn(amount) => state.balance - amount,
    };
    Account {
        balance,
        version: state.version + 1,
    }
}

fn fold(events: &[Event], state: Account) -> Account {
    events.iter().fold(state, apply)
}

fn main() {
    let mut events = vec![Event::Opened];
    let (mut balance, mut refused) = (0, 0);
    let mut step = 0;
    while events.len() < 1000 {
        step += 1;
        let amount = 1 + step * 7919 % 500;
        if step * 31 % 100 < 55 {
            events.push(Event::Deposited(amount));
            balance += amount;
        } else if amount <= balance {
            events.push(Event::Withdrawn(amount));
            balance -= amount;
        } else {
            refused += 1; // the command fails, so no event is stored
        }
    }

    let n = events.len();
    let full = fold(&events, Account::default());
    let snapshot = fold(&events[..900], Account::default()); // stored after event 900
    let latest = fold(&events[900..], snapshot);
    let same = if latest == full { "yes" } else { "no" };
    let (balance, version, tail) = (full.balance, full.version, n - 900);
    println!("events stored: {n}; withdrawals refused: {refused}");
    println!("full replay: {n} events, balance {balance}, version {version}");
    println!("from snapshot 900: {tail} events, the same state: {same}");
    let as_of = fold(&events[..500], Account::default());
    println!("as of event 500: balance {}", as_of.balance);
}

In Rust: Event is an enum, so match must cover every kind and the compiler refuses an apply that forgets one; Iterator::fold is the fold.

Recorded with rustc 1.99.0 (edition 2024) on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: rustc --edition 2024 -O main.rs && ./main

TypeScript 6.0 · main.ts

type AccountEvent = {
  readonly kind: "opened" | "deposited" | "withdrawn";
  readonly amount: number;
};
type Account = { readonly balance: number; readonly version: number };

const EMPTY: Account = { balance: 0, version: 0 };

function apply(state: Account, event: AccountEvent): Account {
  // An event is a fact that already happened, so apply never refuses one.
  switch (event.kind) {
    case "deposited":
      return { balance: state.balance + event.amount, version: state.version + 1 };
    case "withdrawn":
      return { balance: state.balance - event.amount, version: state.version + 1 };
    case "opened":
      return { balance: 0, version: state.version + 1 };
  }
}

const fold = (events: readonly AccountEvent[], state: Account = EMPTY): Account =>
  events.reduce(apply, state);

const events: AccountEvent[] = [{ kind: "opened", amount: 0 }];
let balance = 0;
let refused = 0;
for (let step = 1; events.length < 1000; step++) {
  const amount = 1 + ((step * 7919) % 500);
  if ((step * 31) % 100 < 55) {
    events.push({ kind: "deposited", amount });
    balance += amount;
  } else if (amount <= balance) {
    events.push({ kind: "withdrawn", amount });
    balance -= amount;
  } else {
    refused++; // the command fails, so no event is stored
  }
}

const n = events.length;
const full = fold(events);
const snapshot = fold(events.slice(0, 900)); // stored after event 900
const latest = fold(events.slice(900), snapshot);
const same = latest.balance === full.balance && latest.version === full.version ? "yes" : "no";
console.log(`events stored: ${n}; withdrawals refused: ${refused}`);
console.log(`full replay: ${n} events, balance ${full.balance}, version ${full.version}`);
console.log(`from snapshot 900: ${n - 900} events, the same state: ${same}`);
console.log(`as of event 500: balance ${fold(events.slice(0, 500)).balance}`);

In TypeScript: The event's kind is a union of three strings, so the switch covers every case and the compiler checks that apply always returns a state.

Recorded with TypeScript 6.0.3 (strict), run by Node.js 24.21.0 on macOS 26 arm64. It runs in your browser with QuickJS (quickjs-emscripten 0.32.0) after Sucrase 3.35.1. To run it yourself: npx -p typescript@6.0.3 tsc --noEmit --strict main.ts && node main.ts

Go 1.27 · main.go

package main

import "fmt"

type Event struct {
	Kind   string // "opened", "deposited" or "withdrawn"
	Amount int
}

type Account struct {
	Balance, Version int
}

func apply(state Account, event Event) Account {
	// An event is a fact that already happened, so apply never refuses one.
	switch event.Kind {
	case "deposited":
		return Account{state.Balance + event.Amount, state.Version + 1}
	case "withdrawn":
		return Account{state.Balance - event.Amount, state.Version + 1}
	default:
		return Account{0, state.Version + 1}
	}
}

func fold(events []Event, state Account) Account {
	for _, event := range events {
		state = apply(state, event)
	}
	return state
}

func main() {
	events := []Event{{Kind: "opened"}}
	balance, refused := 0, 0
	for step := 1; len(events) < 1000; step++ {
		amount := 1 + step*7919%500
		switch {
		case step*31%100 < 55:
			events = append(events, Event{"deposited", amount})
			balance += amount
		case amount <= balance:
			events = append(events, Event{"withdrawn", amount})
			balance -= amount
		default:
			refused++ // the command fails, so no event is stored
		}
	}

	n := len(events)
	full := fold(events, Account{})
	snapshot := fold(events[:900], Account{}) // stored after event 900
	same := "no"
	if fold(events[900:], snapshot) == full {
		same = "yes"
	}
	fmt.Printf("events stored: %d; withdrawals refused: %d\n", n, refused)
	fmt.Printf("full replay: %d events, balance %d, version %d\n", n, full.Balance, full.Version)
	fmt.Printf("from snapshot 900: %d events, the same state: %s\n", n-900, same)
	fmt.Printf("as of event 500: balance %d\n", fold(events[:500], Account{}).Balance)
}

In Go: Structs of comparable fields compare with ==, and events[900:] shares the stream's array instead of copying it.

Recorded with Go 1.27.1 on macOS 26 arm64. It runs in your browser with the Go compiler in WebAssembly 1.27.1. To run it yourself: go run main.go

Swift 6.4 · main.swift

enum Event {
  case opened
  case deposited(Int)
  case withdrawn(Int)
}

struct Account: Equatable {
  var balance = 0
  var version = 0
}

func apply(_ state: Account, _ event: Event) -> Account {
  // An event is a fact that already happened, so apply never refuses one.
  switch event {
  case .opened: return Account(balance: 0, version: state.version + 1)
  case .deposited(let amount):
    return Account(balance: state.balance + amount, version: state.version + 1)
  case .withdrawn(let amount):
    return Account(balance: state.balance - amount, version: state.version + 1)
  }
}

func fold(_ events: ArraySlice<Event>, from state: Account = Account()) -> Account {
  events.reduce(state, apply)
}

var events: [Event] = [.opened]
var balance = 0
var refused = 0
var step = 0
while events.count < 1000 {
  step += 1
  let amount = 1 + step * 7919 % 500
  if step * 31 % 100 < 55 {
    events.append(.deposited(amount))
    balance += amount
  } else if amount <= balance {
    events.append(.withdrawn(amount))
    balance -= amount
  } else {
    refused += 1  // the command fails, so no event is stored
  }
}

let n = events.count
let full = fold(events[...])
let snapshot = fold(events[..<900])  // stored after event 900
let same = fold(events[900...], from: snapshot) == full ? "yes" : "no"
print("events stored: \(n); withdrawals refused: \(refused)")
print("full replay: \(n) events, balance \(full.balance), version \(full.version)")
print("from snapshot 900: \(n - 900) events, the same state: \(same)")
print("as of event 500: balance \(fold(events[..<500]).balance)")

In Swift: The event is an enum with associated values, and ArraySlice lets the whole stream, its prefix and its tail share one array; reduce is the fold.

Recorded with Apple Swift 6.4 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: swiftc -O main.swift -o main && ./main

Ruby 4.0 · main.rb

# frozen_string_literal: true

Event = Struct.new(:kind, :amount)
Account = Struct.new(:balance, :version)
EMPTY = Account.new(0, 0).freeze

def apply(state, event)
  # An event is a fact that already happened, so apply never refuses one.
  case event.kind
  when 'deposited' then Account.new(state.balance + event.amount, state.version + 1)
  when 'withdrawn' then Account.new(state.balance - event.amount, state.version + 1)
  else Account.new(0, state.version + 1)
  end
end

def fold(events, state = EMPTY)
  events.reduce(state) { |acc, event| apply(acc, event) }
end

events = [Event.new('opened', 0)]
balance = 0
refused = 0
step = 0
while events.size < 1000
  step += 1
  amount = 1 + (step * 7919 % 500)
  if step * 31 % 100 < 55
    events << Event.new('deposited', amount)
    balance += amount
  elsif amount <= balance
    events << Event.new('withdrawn', amount)
    balance -= amount
  else
    refused += 1 # the command fails, so no event is stored
  end
end

n = events.size
full = fold(events)
snapshot = fold(events.first(900)) # stored after event 900
same = fold(events.drop(900), snapshot) == full ? 'yes' : 'no'
puts "events stored: #{n}; withdrawals refused: #{refused}"
puts "full replay: #{n} events, balance #{full.balance}, version #{full.version}"
puts "from snapshot 900: #{n - 900} events, the same state: #{same}"
puts "as of event 500: balance #{fold(events.first(500)).balance}"

In Ruby: Structs compare by value, so the check is one ==, and EMPTY is a frozen Account, so no fold can change the starting state.

Recorded with CRuby 4.0.7 on macOS 26 arm64. It runs in your browser with ruby.wasm 2.10.1 (Ruby 4.0.0). To run it yourself: ruby main.rb

Kotlin 2.4 · Main.kt

data class Event(
    val kind: String,
    val amount: Int = 0,
)

data class Account(
    val balance: Int = 0,
    val version: Int = 0,
)

// An event is a fact that already happened, so apply never refuses one.
fun apply(
    state: Account,
    event: Event,
): Account =
    when (event.kind) {
        "deposited" -> Account(state.balance + event.amount, state.version + 1)
        "withdrawn" -> Account(state.balance - event.amount, state.version + 1)
        else -> Account(0, state.version + 1)
    }

fun fold(
    events: List<Event>,
    state: Account = Account(),
): Account = events.fold(state, ::apply)

fun main() {
    val events = mutableListOf(Event("opened"))
    var balance = 0
    var refused = 0
    var step = 0
    while (events.size < 1000) {
        step++
        val amount = 1 + step * 7919 % 500
        when {
            step * 31 % 100 < 55 -> {
                events.add(Event("deposited", amount))
                balance += amount
            }

            amount <= balance -> {
                events.add(Event("withdrawn", amount))
                balance -= amount
            }

            else -> {
                refused++ // the command fails, so no event is stored
            }
        }
    }
    val n = events.size
    val full = fold(events)
    val snapshot = fold(events.subList(0, 900)) // stored after event 900
    val same = if (fold(events.subList(900, n), snapshot) == full) "yes" else "no"
    println("events stored: $n; withdrawals refused: $refused")
    println("full replay: $n events, balance ${full.balance}, version ${full.version}")
    println("from snapshot 900: ${n - 900} events, the same state: $same")
    println("as of event 500: balance ${fold(events.subList(0, 500)).balance}")
}

In Kotlin: Data classes compare by value, and List.fold with the function reference ::apply is the fold.

Recorded with Kotlin 2.4.20 on Java 25.0.4.1 on macOS 26 arm64. It runs in your browser with a WebAssembly build on the WASI runner (browser_wasi_shim 0.4.2). To run it yourself: kotlinc Main.kt -include-runtime -d main.jar && java -jar main.jar

Dart 3.13 · main.dart

typedef Event = ({String kind, int amount});
typedef Account = ({int balance, int version});

const Account empty = (balance: 0, version: 0);

Account apply(Account state, Event event) {
  // An event is a fact that already happened, so apply never refuses one.
  final version = state.version + 1;
  return switch (event.kind) {
    'deposited' => (balance: state.balance + event.amount, version: version),
    'withdrawn' => (balance: state.balance - event.amount, version: version),
    _ => (balance: 0, version: version),
  };
}

Account fold(List<Event> events, [Account state = empty]) =>
    events.fold(state, apply);

void main() {
  final events = <Event>[(kind: 'opened', amount: 0)];
  var balance = 0;
  var refused = 0;
  for (var step = 1; events.length < 1000; step++) {
    final amount = 1 + step * 7919 % 500;
    if (step * 31 % 100 < 55) {
      events.add((kind: 'deposited', amount: amount));
      balance += amount;
    } else if (amount <= balance) {
      events.add((kind: 'withdrawn', amount: amount));
      balance -= amount;
    } else {
      refused++; // the command fails, so no event is stored
    }
  }
  final n = events.length;
  final full = fold(events);
  final snapshot = fold(events.sublist(0, 900)); // stored after event 900
  final same = fold(events.sublist(900), snapshot) == full ? 'yes' : 'no';
  print('events stored: $n; withdrawals refused: $refused');
  print(
    'full replay: $n events, balance ${full.balance}, version ${full.version}',
  );
  print('from snapshot 900: ${n - 900} events, the same state: $same');
  print('as of event 500: balance ${fold(events.sublist(0, 500)).balance}');
}

In Dart: Records with named fields compare by value, so the check is one ==, and the empty state is a const record.

Recorded with Dart 3.13.5 on macOS 26 arm64. It runs in your browser with a WebAssembly build by dart2wasm 3.13.5. To run it yourself: dart run main.dart

What every language printed

events stored: 1000; withdrawals refused: 1
full replay: 1000 events, balance 26158, version 1000
from snapshot 900: 100 events, the same state: yes
as of event 500: balance 13208

Both rebuilds give the same balance, 26,158, at version 1,000, and the one from the snapshot folded 100 events instead of 1,000. The balance as of event 500, 13,208, came from the same events cut short, a question that a table holding only the current balance cannot answer.

Key idea

The events are facts and the state is computed from them: fold from the first event, from a snapshot or up to any earlier event, and only a command that passes its checks adds one.

Checkpoint

Check: State as a fold, and snapshots

  1. Question 1 of 2 A stream holds 1,000 events, and a snapshot was stored after event 900. To answer "the balance as of event 950", how many events does the fold apply after it loads the snapshot?

    Type a number.

    A hint

    The snapshot already holds the state after event 900.

    Show the answer: Question 1 of 2

    Answer: 50 events

    The snapshot is the state after event 900, so the fold applies events 901 to 950, which is 50 events, instead of 950 from the first event. Cutting the fold short at 950 is what gives the state at that point.

  2. Question 2 of 2 The account in the figure holds 190 at version 4. A command to withdraw 500 arrives. What happens to the stream?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Nothing is appended; the command is refused and the stream stays at version 4

    Commands decide and events record. The withdrawal is checked against the balance of 190 and refused, so no event is appended: the stream still holds four facts, and the fold has nothing new to apply.

Practise: rebuild an account from its events

Write the fold yourself; its sample tests include the four events of the figure:

Exercise · Easy · Python

Rebuild an account from its events

An event-sourced account stores what happened, not its current state. Finish apply and fold in account.py so that the state can be rebuilt from the events at any time, from the first event or from a snapshot.

apply(state, event) returns the state after one more event. It must not change the dictionary it was given, because a snapshot may be reused. The events are dictionaries with a "type":

  • {"type": "AccountOpened", "owner": "asha"} starts the account: {"owner": "asha", "balance": 0, "status": "open", "version": 1} (the state before it is None);
  • {"type": "MoneyDeposited", "amount": 250} adds the amount to the balance;
  • {"type": "MoneyWithdrawn", "amount": 100} subtracts it;
  • {"type": "AccountClosed"} sets the status to "closed".

Every event adds one to "version", so the version is the number of events applied. Events record facts that already happened, so apply does not check them; an event of any other type raises ValueError.

fold(events, state=None) applies the events in order, starting from state, and returns the result. With no events it returns state unchanged.

Starter code · account.py

"""An event-sourced account: the state is rebuilt from the events that happened to it."""


def apply(state, event):
    """Return the state after `event`, without changing `state`."""
    # Replace this line with your code.
    return state


def fold(events, state=None):
    """Apply `events` in order, starting from `state`, and return the result."""
    # Replace this line with your code.
    return state
The sample tests · test_account.py
from account import apply, fold

OPENED = {"type": "AccountOpened", "owner": "asha"}


def test_opening_starts_at_zero():
    """an opened account has a zero balance and version 1"""
    assert fold([OPENED]) == {"owner": "asha", "balance": 0, "status": "open", "version": 1}


def test_deposits_and_withdrawals():
    """deposits add, withdrawals subtract"""
    events = [OPENED, {"type": "MoneyDeposited", "amount": 250}, {"type": "MoneyWithdrawn", "amount": 100},
              {"type": "MoneyDeposited", "amount": 40}]
    assert fold(events)["balance"] == 190


def test_version_counts_events():
    """the version is the number of events applied"""
    events = [OPENED] + [{"type": "MoneyDeposited", "amount": 1}] * 9
    assert fold(events)["version"] == 10


def test_closing():
    """a closed account keeps its balance and changes its status"""
    state = fold([OPENED, {"type": "MoneyDeposited", "amount": 5}, {"type": "AccountClosed"}])
    assert state["status"] == "closed" and state["balance"] == 5 and state["version"] == 3


def test_apply_does_not_change_its_input():
    """a snapshot can be reused because apply returns a new state"""
    snapshot = fold([OPENED, {"type": "MoneyDeposited", "amount": 70}])
    copy = dict(snapshot)
    apply(snapshot, {"type": "MoneyWithdrawn", "amount": 30})
    assert snapshot == copy


def test_snapshot_and_tail_equal_a_full_replay():
    """folding the rest of the events onto a snapshot gives the same state as a full replay"""
    events = [OPENED] + [{"type": "MoneyDeposited", "amount": n} for n in range(1, 30)]
    snapshot = fold(events[:12])
    assert fold(events[12:], snapshot) == fold(events)


def test_unknown_event_type():
    """an unknown event type raises ValueError"""
    try:
        fold([OPENED, {"type": "MoneyMoved", "amount": 3}])
    except ValueError:
        return
    raise AssertionError("expected ValueError")


def test_no_events():
    """folding no events returns the starting state unchanged"""
    state = fold([OPENED])
    assert fold([], state) == state and fold([]) is None
A hint

Handle AccountOpened first: it ignores the old state and builds a new dictionary. For every other type, start from new = dict(state), which copies the state so the caller's dictionary stays as it was, add one to new["version"], then change the one field that the event type changes. fold is a loop that feeds each result into the next apply.

The sample tests run on this device, in your browser (Pyodide): nothing is sent to mysmartcopilot.com. The first run downloads Python (about 13.5 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.

Then test yourself on the patterns and the fold, the one you know least first.

Practice that adapts to you

Practise this lesson

8 questions about 9 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 8 Shipping receives an OrderPlaced event that carries only the order's id, then calls the orders service to read the order. Which pattern is this?

    Choose one answer.

    Show the answer: Question 1 of 8

    Answer: Event notification

    The event only says that something happened, and the consumer calls back for the details. That keeps events small and the orders service the only holder of the data, at the price of a call to orders for every event.

  2. Question 2 of 8 An account's balance is never stored as a number that is updated. It is computed by applying the account's deposits and withdrawals in order. Which pattern is this?

    Choose one answer.

    Show the answer: Question 2 of 8

    Answer: Event sourcing

    The events are the source of truth and the state is a fold over them. CQRS is a separate decision: whether reads come from another model than the one that handles writes.

  3. Question 3 of 8 After placing an order, a user opens "My orders", which is a read model fed by events, and the new order is missing for half a second. What is true?

    Choose one answer.

    Show the answer: Question 3 of 8

    Answer: The read model lags behind the write, and the design must hide that from the person who just wrote

    A read model is fed by events after the write, so it is a little behind, and the person who just wrote is the one most likely to notice. Nothing was lost; the read arrived before the event had been applied.

  4. Question 4 of 8 Pricing needs each product's weight and size, and it must keep answering while the catalogue service is down for an hour. How should the catalogue's ProductChanged events be designed?

    Choose one answer.

    Show the answer: Question 4 of 8

    Answer: Event-carried state transfer, with the weight and size in the event, so pricing keeps its own copy

    A consumer that must work while the sender is down needs the data in the events, so it can keep its own copy. The price is a copy to keep in sync, slightly behind the catalogue.

  5. Question 5 of 8 A team wants a fast read model for "My orders", but its orders table must stay the source of truth. Can it use CQRS?

    Choose one answer.

    Show the answer: Question 5 of 8

    Answer: Yes; CQRS needs no event sourcing, and the read model can be fed by events the write side publishes

    CQRS and event sourcing are separate decisions. Here the table stays the truth, the write side publishes its changes as events, and a projector keeps the "My orders" read model up to date from them.

  6. Question 6 of 8 A fold rebuilds an account from its first event. What does it start from?

    Choose one answer.

    Show the answer: Question 6 of 8

    Answer: An empty state, before any event, to which the first event is applied

    A fold starts from nothing and applies each event in order. A rebuild from a snapshot starts from the stored state instead and applies only the events after it.

  7. Question 7 of 8 Why does the fold's apply function never check that a withdrawal leaves the balance at zero or more?

    Choose one answer.

    Show the answer: Question 7 of 8

    Answer: Events record facts that already happened; the rule was checked when the command was decided

    A command such as "withdraw 70" is checked against the current state, and only a command that succeeds appends an event. The fold then applies facts, so refusing one would make the rebuilt state disagree with what happened.

  8. Question 8 of 8 An auditor asks for an account's balance just after its 500th event. The account is event-sourced. How do you answer?

    Choose one answer.

    Show the answer: Question 8 of 8

    Answer: Fold the stream from the first event up to event 500

    The events are kept, so the state after any event is the fold of the events up to it. The program answered "as of event 500" this way, with 13,208, a question a table holding only today's balance cannot answer.

Read models that lag, and how to hide the lag Pro: opens with a Pro, Premium or Ultimate pass

A read model is only as useful as it is current, and it is always a little behind. This part builds a projection that survives duplicates and restarts, measures the lag a user meets after a write, and shows how to rebuild a read model without stopping the system.

In this part:

  • Building a projection that survives duplicates and restarts
  • The lag, measured, and a version token that hides it
  • Rebuilding a read model from the log, with the arithmetic

2 diagrams · 1 runnable example (JavaScript) · about 10 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Read a complete sample first: Design a unique ID generator

Event sourcing's costs, reproduced in code Pro: opens with a Pro, Premium or Ultimate pass

Event sourcing trades a simple table for a history that is permanent. This part reproduces its three standing costs: concurrent writers, events whose shape changes, and replays that grow with time.

In this part:

  • Two withdrawals race: optimistic concurrency with expected versions
  • Changing an event’s shape without rewriting history
  • Snapshots and replay time, with numbers
  • Side effects during replay, personal data and the decision

2 diagrams · 1 runnable example · about 19 minutes

Opening this part with your pass…Pro part. It opens with the same pass as the part above.

Interview questions

  1. Fresher to mid · Warm-up

    What is the difference between event notification and event-carried state transfer?

    Both announce that something happened; they differ in what the event carries. An event notification carries little more than the fact and an id, such as "order 42 was placed", and a consumer that needs the details calls the sender back. Events stay small and the sender remains the only holder of the data, but every consumer adds load on the sender and stops working when it is down. Event-carried state transfer puts the changed data in the event, such as the order's items and address, so consumers keep their own copy and never call back. They keep working when the sender is down and answer without a network call, at the price of copies of the data in several places, each slightly behind the source.

  2. Senior to staff

    Design the core of a wallet whose balance must never go negative and whose every change must be auditable for years. Would you use event sourcing? How do you handle two withdrawals at the same moment, and an account with 50,000 events?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  3. Mid to senior

    After placing an order, users land on "My orders", where the new order is often missing for a second or two, and support tickets are piling up. The page reads a read model fed by events. Explain why, and give two fixes with their costs.

    Model answer, Pro. First step and follow-ups

  4. Mid to senior

    A team renamed a field of its OrderPlaced event from "amount" to "total" and deployed. A week later, rebuilding a read model from the event store fails on the old events. What went wrong, and how should event schemas evolve?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • Event notification sends a fact and lets consumers call back; event-carried state transfer sends the data so they do not have to.
  • Event sourcing makes the events the truth and the state a fold over them; a snapshot shortens the fold, and a fold cut short gives an earlier state.
  • CQRS separates the write model from read models that are fed by events and lag a little behind.
  • The four are separate decisions: each can be used without the others, and each has its own cost.

Terms in this lesson

CQRS (command query responsibility segregation)
Handling writes and reads with separate models: commands go to a write model, and queries read from read models that events keep up to date a little later.
event notification
An event that announces a fact with little more than an id, such as "order 42 was placed"; a consumer that needs the details calls the sender back.
event sourcing
Keeping every change to an entity as an event in an append-only store, the system of record, and computing the current state by replaying those events in order.
event-carried state transfer
Events that carry the changed data itself, so that consumers keep their own copy and never need to call the sender back.
read model
A copy of data shaped for one screen or query and fed by the write side's events. It answers quickly but lags a little behind the writes.
snapshot (event sourcing)
The stored state of an entity after a given event, so that a rebuild starts there and replays only the events that came after it.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.