Architecture patterns
Event sourcing
Store the events as the source of truth and derive the current state by replaying them. Learn the gains in audit and replay, and the costs of snapshots, schema changes and querying.
In a conventional system, the database stores the current state. Account 42 has a balance of $140, and the history is gone unless you built audit tables. Event sourcing reverses that. The source of truth is an append-only sequence of events, and the current state is derived by replaying them.
Concept
Replaying means folding the events in order, starting from an empty state, to produce the current state. The state is never stored as the truth. It is a cache of the events.
Walking through an account
Here is the event stream for account 42:
#1 AccountOpened { owner: "ana" }
#2 MoneyDeposited { amount: 200 }
#3 MoneyWithdrawn { amount: 50 }
#4 MoneyDeposited { amount: 40 }
#5 MoneyWithdrawn { amount: 50 }
Current balance = 0 + 200 − 50 + 40 − 50 = 140State is a left fold over the events. A command checks the current state, then appends new events:
type AccountEvent =
| { type: "AccountOpened"; owner: string }
| { type: "MoneyDeposited"; amount: number }
| { type: "MoneyWithdrawn"; amount: number };
type Account = { owner: string; balance: number; version: number };
function apply(state: Account, e: AccountEvent): Account {
switch (e.type) {
case "AccountOpened": return { owner: e.owner, balance: 0, version: state.version + 1 };
case "MoneyDeposited": return { ...state, balance: state.balance + e.amount, version: state.version + 1 };
case "MoneyWithdrawn": return { ...state, balance: state.balance - e.amount, version: state.version + 1 };
}
}
// Appending with expectedVersion is optimistic concurrency. If another writer
// appended event #6 first, this append fails and the command is retried.
async function withdraw(store: EventStore, id: string, amount: number) {
const events = await store.read(id);
const acct = events.reduce(apply, { owner: "", balance: 0, version: 0 });
if (acct.balance < amount) throw new Error("insufficient funds");
await store.append(id, [{ type: "MoneyWithdrawn", amount }], { expectedVersion: acct.version });
}The expectedVersion check is what keeps two concurrent withdrawals from both succeeding on the same balance.
What you gain
- A complete audit log for free. Ledgers, banking and accounting have always worked this way.
- Time travel. Rebuild the state at any past moment by replaying up to that point. This helps debugging and regulators alike.
- New read models from old data. Build a projection nobody planned for last year by replaying the full history.
What it costs
- Snapshots. Replaying 50,000 events to load one account is slow. Save a snapshot every few hundred events, and replay only what follows. Replaying 500 events in memory is cheap, at about 1 ms per 1,000 events. Reading 50,000 events from storage on every command is not.
- Schema evolution. Events are immutable and kept forever, so an old
MoneyDepositedwithout acurrencyfield must still be readable. Options: add only optional fields, version the event types (such asMoneyDepositedV2), or use upcasting, which converts old shapes into the current one as they are read. You can't casually rewrite history. - Querying. "All accounts with a balance over $1,000" can't be answered from a stream. You need read models, which leads to CQRS.
- Complexity. Every developer meets it in daily work. Use it where history is the business, such as ledgers, inventory movements and collaborative editing. Don't use it as a default for CRUD apps.
Common mistakes
- Using event sourcing for simple CRUD. The cost lands on every feature, and the gain is small.
- Editing old events. Fix the history by appending a correcting event, not by rewriting.
- Skipping snapshots on long streams. Load time grows with the stream, and every command pays for it.
Check yourself
1. Why does an event-sourced ledger make an audit cheap? The history is the storage. You can replay to any past point and see exactly how the balance got there.
2. An old MoneyDeposited event has no currency field. What do you do?
Upcast it on read, converting the old shape to the current one. Don't rewrite history.
Key takeaways
- Events are the source of truth. Current state is derived by replaying them.
- You gain a full audit log, time travel and new read models from old data.
- The costs are snapshots, schema evolution and querying, which needs read models.
- Use it where history is the business, not as a default.
Further learning
Next, see how read models answer the queries that event streams can't, in CQRS and read models.
Related
CQRS and read models
Command Query Responsibility Segregation uses separate models for writing and reading. Learn how read models are built from events, the eventual consistency they bring, and how to hide that lag from users.
Event streaming and Kafka
How a durable log lets many consumers read the same events at their own pace, and how Kafka builds that log from partitions, replicas and consumer groups.
Event-driven architecture, CQRS and event sourcing
Building systems around events, from change data capture to event sourcing and read models, and knowing when it simplifies a system versus creating a distributed mess.