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.
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:
Event notification, event-carried state transfer, event sourcing and CQRS
Text description of the diagram
The figure has four panels, one under the other.
- 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.
- 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.
- 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.
- 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:
The drawing shows the whole scene; its 8 steps are listed below.
Every step as text
- 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.
- The thin event arrives. Shipping receives the event but not the order, so it still lacks the items and the address it needs.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
| Criterion | Event notification | Event-carried state transfer | Event sourcing | CQRS |
|---|---|---|---|---|
| The event carries | An id and a fact | The changed data | Every change, kept for good | Whatever the read models need |
| Source of truth | The sender's database | The sender's database | The event store | The write model |
| Readers see | Current data, by calling back | Their own copy, slightly behind | State rebuilt from events | Read models, slightly behind |
| Main cost | Flows nobody can read in code | Copies to keep in sync | Schema changes, replay time | Two models and their lag |
| When to choose | Loose coupling for occasional reactions | Consumers that must work without the sender | Ledgers, audits, history that matters | Reads 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
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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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.
- 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:
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
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs.
Runs on this device, in your browser. The first run downloads Python (about 13.5 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. Runs on this device: a JavaScript build of this program (about 12 KB), made when the page was built. The first run also downloads the JavaScript runner (about 0.6 MB), which is kept for the next runs. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 151 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device: a .NET build of this program (about 4 KB), made when the page was built. The first run also downloads .NET (about 3.6 MB), which is kept for the next runs. Edit runs your changes in the browser: the first edited run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads .NET (about 8.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 8 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads the C and C++ compiler (about 29.1 MB), which is kept for the next runs. It is best on a computer. Runs on this device, in your browser. The first run downloads PHP (about 4.3 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 20 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads the JavaScript runner (about 0.9 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 716 KB), made when the page was built. Edit runs your changes in the browser: the first edited run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device, in your browser. The first run downloads the Go compiler (about 18.7 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 1.7 MB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device, in your browser. The first run downloads Ruby (about 10.2 MB), which is kept for the next runs. Runs on this device: a WebAssembly build of this program (about 181 KB), made when the page was built. To change the code, copy it and run it with the command below. Runs on this device: a WebAssembly build of this program (about 14 KB), made when the page was built. To change the code, copy it and run it with the command below.Your run, in this browser
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
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 isNone);{"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.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
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.
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.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
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).
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
-
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.
-
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?
-
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.
-
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?
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
- What do you mean by "Event-Driven"? (martinfowler.com)
- Event Sourcing (martinfowler.com)
- CQRS (martinfowler.com)
- Event Sourcing pattern (Azure Architecture Center) (Microsoft)
- CQRS pattern (Azure Architecture Center) (Microsoft)
- Materialized View pattern (Azure Architecture Center) (Microsoft)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress