Event Sourcing
Context
Section titled “Context”Your services rely on events — sagas chain on them, CQRS projections are built from them. But there is a subtle reliability gap. A service usually updates its database and then publishes an event as two separate steps. If it commits the database change and crashes before publishing, the rest of the system never hears about it; the state and the event stream silently diverge. You want a way to update state and publish the corresponding event such that one can never happen without the other.
Problem
Section titled “Problem”The dual-write problem: writing to the database and publishing to the message broker are two operations that you cannot wrap in one transaction. Whichever you do first, a crash in between leaves the two stores inconsistent. On top of that, ordinary state storage throws away history — once a row is overwritten you can no longer answer “what was the balance last Tuesday?” or “how did we get to this state?”.
How do you store state so that the event is the source of truth, publishing is guaranteed, and the full history is preserved?
Solution
Section titled “Solution”With Event Sourcing you do not store the current state at all. You store the sequence of domain events that produced it, in an append-only event store. An account is not a row with a balance column; it is the ordered list AccountOpened, Deposited, Withdrawn, Deposited. To get the current state, you load the events for that entity and fold (replay) them into an in-memory state.
Because the event is the unit you persist, there is no dual write: appending the event is the state change. The same event store then doubles as the outbox — other services subscribe to the stream and receive every event in order, with no separate publish step to lose.
flowchart LR Cmd[Command] --> Agg[Aggregate] Agg -->|append events| Store[(Event Store - append only)] Store -->|replay / fold| State[Current State] Store -->|subscribe| C1[CQRS Projection] Store -->|subscribe| C2[Other Service] Store -->|subscribe| C3[Audit / Analytics]
Example
Section titled “Example”The example models a bank account. Commands produce events; the current state is computed by folding the event list. Notice there is no setter for balance — it only ever moves by applying an event.
type Event = | { type: 'AccountOpened'; owner: string } | { type: 'Deposited'; amount: number } | { type: 'Withdrawn'; amount: number };
type Account = { owner: string; balance: number };
// Fold the log into current state.function apply(state: Account, e: Event): Account { switch (e.type) { case 'AccountOpened': return { owner: e.owner, balance: 0 }; case 'Deposited': return { ...state, balance: state.balance + e.amount }; case 'Withdrawn': return { ...state, balance: state.balance - e.amount }; }}
const replay = (events: Event[]): Account => events.reduce(apply, { owner: '', balance: 0 });
// A command validates against current state, then appends a new event.async function withdraw(id: string, amount: number) { const state = replay(await store.load(id)); if (state.balance < amount) throw new Error('insufficient funds'); await store.append(id, { type: 'Withdrawn', amount }); // append = state change + publish}from functools import reduce
def apply(state: dict, e: dict) -> dict: if e["type"] == "AccountOpened": return {"owner": e["owner"], "balance": 0} if e["type"] == "Deposited": return {**state, "balance": state["balance"] + e["amount"]} if e["type"] == "Withdrawn": return {**state, "balance": state["balance"] - e["amount"]} return state
def replay(events: list[dict]) -> dict: return reduce(apply, events, {"owner": "", "balance": 0})
# A command validates against current state, then appends a new event.async def withdraw(account_id: str, amount: int) -> None: state = replay(await store.load(account_id)) if state["balance"] < amount: raise ValueError("insufficient funds") await store.append(account_id, {"type": "Withdrawn", "amount": amount}) # append = state change + publishtype Event struct { Type string // "AccountOpened" | "Deposited" | "Withdrawn" Owner string Amount int}
type Account struct { Owner string Balance int}
// Fold the log into current state.func Apply(s Account, e Event) Account { switch e.Type { case "AccountOpened": return Account{Owner: e.Owner, Balance: 0} case "Deposited": s.Balance += e.Amount case "Withdrawn": s.Balance -= e.Amount } return s}
func Replay(events []Event) Account { var s Account for _, e := range events { s = Apply(s, e) } return s}
// A command validates against current state, then appends a new event.func Withdraw(ctx context.Context, id string, amount int) error { events, err := store.Load(ctx, id) if err != nil { return err } if Replay(events).Balance < amount { return errors.New("insufficient funds") } return store.Append(ctx, id, Event{Type: "Withdrawn", Amount: amount}) // append = state change + publish}enum Event { AccountOpened { owner: String }, Deposited { amount: i64 }, Withdrawn { amount: i64 },}
#[derive(Default)]struct Account { owner: String, balance: i64,}
// Fold the log into current state.fn apply(mut state: Account, e: &Event) -> Account { match e { Event::AccountOpened { owner } => Account { owner: owner.clone(), balance: 0 }, Event::Deposited { amount } => { state.balance += amount; state } Event::Withdrawn { amount } => { state.balance -= amount; state } }}
fn replay(events: &[Event]) -> Account { events.iter().fold(Account::default(), apply)}
// A command validates against current state, then appends a new event.async fn withdraw(id: &str, amount: i64) -> Result<(), Error> { let state = replay(&store::load(id).await?); if state.balance < amount { return Err(Error::InsufficientFunds); } store::append(id, Event::Withdrawn { amount }).await // append = state change + publish}Resulting context
Section titled “Resulting context”What you gain:
- Reliable event publishing. Appending the event is the write, so there is no dual-write gap — subscribers receive exactly the events that changed the state, in order. This makes Event Sourcing a natural backbone for sagas and CQRS.
- A complete audit log. Every change is preserved, immutably, forever. You can answer not just what the state is but how and when it got there.
- Temporal queries and replay. You can reconstruct the state as of any past moment, and rebuild new read models simply by replaying history through a fresh projection.
What it costs you:
- Querying is hard. The event store is great at “give me one entity’s events” and poor at “find all accounts with a negative balance”. Event Sourcing therefore almost always pairs with CQRS, which builds queryable read models from the stream.
- Replay cost. Folding a long event history on every load is expensive, so you periodically save a snapshot and replay only the events after it.
- Schema evolution and eventual consistency. Events are immutable, so changing their shape over time requires versioning and upcasting old events. And anything built from the stream — read models, other services — is eventually consistent.
Related patterns
Section titled “Related patterns”- CQRS — the standard partner that makes an event-sourced system queryable.
- Saga — sagas rely on the reliable events an event store emits.
- Database per Service — each service may choose Event Sourcing as its private storage model.
| Benefit | Trade-off |
|---|---|
| A complete audit log — you know how and when state changed | Querying current state means replaying events — slower than reading a stored value |
| You can rebuild state at any time | Changing an event’s schema is hard — old events must stay compatible |
| Pairs very well with CQRS | A large event store needs snapshotting |
| Time-travel debugging — inspect state at any past moment | High conceptual overhead — the team has to adopt a different mental model |
Common mistakes
Section titled “Common mistakes”Event Sourcing everywhere — used without an audit or time-travel requirement. Symptoms:
- A simple service with no need for history gets an event store anyway.
- Complexity goes up with no matching benefit.
- The team spends more time managing event schemas than building features.
Mutable events — editing an event after the fact. Symptoms:
- An event gets edited to “fix a bug” instead of appending a new compensating event.
- The audit log is no longer trustworthy.
💡 Real-world examples
LMAX Exchange: the reference example Martin Fowler wrote about in “LMAX Architecture” — a low-latency financial trading system whose single-threaded Business Logic Processor runs entirely off an event log (Event Sourcing) plus an in-memory state model, processing millions of orders per second with a complete audit trail (Martin Fowler: LMAX Architecture).
Financial systems in general: regulatory requirements often mandate a complete audit trail of every transaction, which Event Sourcing satisfies naturally, since its change log is append-only and fully inspectable after the fact.