13 — Event Sourcing
Interview level: Principal / Staff (L6/L7) — almost always paired with CQRS. The L5 answer describes the pattern; the L6/L7 answer covers snapshot optimisation, event schema versioning, upcasting, and when the audit trail alone doesn’t justify the complexity.
Context
A system needs to answer not just “what is the current state?” but “what was the state at time T?” or “what sequence of decisions produced this state?” Traditional CRUD systems overwrite state on every update; the history is lost. Event sourcing keeps the full history as the source of truth.
Problem
| Force | Description |
|---|---|
| Audit requirement | Regulators or business need a complete, tamper-evident history of every state change |
| Temporal queries | ”What did the account balance look like at 14:32:07 on March 3rd?” |
| Debugging | Production bug: “Why did this order end up in state X?” — with CRUD, the previous state is gone |
| Projection flexibility | Different consumers need different views of the same state (billing view, shipping view) |
| Event-driven integration | Downstream services need to react to state changes; the event log is the integration point |
Solution
flowchart LR
subgraph Write["Write Path"]
CMD["Command\nPlaceOrder"]
AGG["Aggregate\n(loads events,\nvalidates, decides)"]
ES["Event Store\n(append-only log)\nOrderPlaced\nPaymentProcessed\nOrderShipped"]
CMD --> AGG --> ES
end
subgraph Read["Read Path — Projections"]
P1["Order Status View\n(current state)"]
P2["Audit Log View\n(all events, all time)"]
P3["Billing View\n(payment events only)"]
ES -->|"event stream"| P1 & P2 & P3
end
subgraph Replay["State Reconstruction"]
R["Replay all events\nfor aggregate ID\n→ current state"]
ES --> R
end
Every state change is captured as an immutable event (OrderPlaced, ItemAdded,
PaymentProcessed, OrderCancelled). The event store is append-only — events are never updated or
deleted. To reconstruct current state, replay all events for a given aggregate from the beginning of
its log.
Event structure
{
"event_id": "a3f8...",
"aggregate_id": "order-42",
"aggregate_type":"Order",
"event_type": "OrderPlaced",
"sequence": 1,
"occurred_at": "2026-06-30T10:00:00Z",
"payload": {
"customer_id": "cust-7",
"items": [{"sku": "widget", "qty": 2}],
"total": 49.99
},
"metadata": {
"correlation_id": "req-abc",
"causation_id": "cmd-xyz",
"actor": "user:amit@example.com"
}
}
metadata.correlation_id and causation_id are load-bearing for debugging: they link the event to
the originating request and the command that caused it.
Snapshot Optimisation
Replaying 10 years of events for an account with 100K events on every read is prohibitive. Snapshots checkpoint the aggregate state periodically:
flowchart LR
E1["Event 1"] --> E2["Event 2"] --> E3["..."] --> E1000["Event 1000"]
E1000 -->|"snapshot"| S1["Snapshot @ seq=1000\n(materialised state)"]
S1 --> E1001["Event 1001"] --> E1002["Event 1002"]
LOAD["Load aggregate"] -->|"1. load latest snapshot"| S1
LOAD -->|"2. replay events\nafter seq=1000"| E1001
Snapshot strategy:
- Snapshot every N events (e.g., every 100 events) — simple; may snapshot unnecessarily
- Snapshot when aggregate state size exceeds a threshold
- Snapshot on explicit command (“prepare for archival”)
Snapshots are an optimisation, not part of the core model. The system must work correctly without them; snapshots only affect performance.
Event Schema Versioning
Events are immutable and live forever. Inevitably the schema changes. This is the most underestimated problem in event sourcing.
The problem:
// v1 event (stored 3 years ago)
{ "event_type": "OrderPlaced", "customer_name": "Amit Singh" }
// v2 event (current)
{ "event_type": "OrderPlaced", "customer": { "id": "cust-7", "name": "Amit Singh" } }
Old events still exist in the store and must be replayed. Options:
Upcasting (preferred): Apply a transformation at read time; old events are converted to the current schema on the fly. No data migration required.
def upcast(event: dict) -> dict:
if event["event_type"] == "OrderPlaced" and "customer_name" in event["payload"]:
# v1 → v2 upcast
name = event["payload"].pop("customer_name")
event["payload"]["customer"] = {"name": name, "id": None}
return event
In-place migration: Update existing event payloads in the store. Breaks immutability. Only do this for security (PII removal) with an audit trail of the migration.
Versioned event types: OrderPlacedV1, OrderPlacedV2 — the handler dispatches on type. Works
but creates an explosion of types over time.
Rule: never change the meaning of an existing field; only add optional fields or use upcasting.
Consequences
Gains
- Complete, tamper-evident audit log by construction — no extra audit table needed
- Temporal queries trivially answered by replaying events up to time T
- Projection flexibility: the same event stream feeds multiple read models; add a new projection without touching the write side
- Event log is the natural integration point for downstream services (via Kafka or direct subscription)
- Bug diagnosis: replay events to reproduce exact state at time of bug
Trade-offs
- Eventual consistency: projections lag behind the event log (see CQRS trade-offs)
- Snapshot complexity: without snapshots, aggregate load time grows linearly with event count; with snapshots, you have another artifact to manage
- Event schema versioning: the hardest long-term cost; upcaster chain grows with every schema change
- Not suitable for simple CRUD: if there’s no audit requirement, no temporal query need, and no downstream projection requirement, event sourcing is overengineering with significant operational cost
- Storage cost: storing all events forever is expensive at scale; archival strategy required
Observability
event_store_append_duration_seconds{aggregate_type} # write latency
event_store_events_appended_total{event_type} # throughput by event type
event_store_replay_duration_seconds{aggregate_type} # how long replays take
event_store_replay_events_count{aggregate_type} # events replayed per load
snapshot_age_events{aggregate_type} # events since last snapshot
upcaster_applied_total{from_version, to_version} # upcast frequency
projection_lag_seconds{projection} # how far behind projections are
Alert: event_store_replay_events_count{p99} > 1000 — aggregates are growing too large; check
snapshot frequency. Alert: projection_lag_seconds > SLO — projection is falling behind event
stream.
MAANG Interview Anchors
-
“Event sourcing gives you audit trail, temporal queries, and projection flexibility for free — but it costs you schema versioning complexity and eventual consistency. I’d only adopt it when at least two of those three gains are explicit requirements. The audit trail alone rarely justifies the complexity; append-only tables or database triggers can give you that more cheaply.”
-
“Event schema versioning is the tax that compounds over time. Three years in, you have 47 event types with 3–5 versions each. The upcaster chain becomes load-bearing infrastructure. I’d enforce an ‘additive changes only’ rule from day one and require a postmortem process for any breaking schema change.”
-
“Snapshots are the performance escape hatch, not a core feature. Design the system to work correctly without them; add snapshots when aggregate replay time exceeds the SLO. The mistake is building snapshots first and then treating them as required — they add operational complexity that isn’t needed at low event counts.”
-
“The event log is also your integration bus. Downstream services subscribe to the event stream instead of polling the write model. This eliminates the dual-write problem: the event is the source of truth; projections and downstream consumers derive from it. I’d surface this in the architecture diagram as the single place where state changes cross service boundaries.”
Known Uses
| System | Event Sourcing application |
|---|---|
| EventStoreDB | Purpose-built event store with built-in projection engine |
| Axon Framework | CQRS + ES in Java; snapshot support built in |
| Kafka (log compaction off) | Used as an event store for high-throughput streams |
| Martin Fowler’s reference app | Canonical ES example: AccountingEntry ledger |
| Banking core systems | Ledger as append-only event log; balance = sum of credits − debits |
| Git | Every commit is an immutable event; HEAD = replay of all commits |
Local graph
Linked from 7 notes
12 — CQRS — Command Query Responsibility Segregation
Separate the write model (commands) from the read model (queries). Unlocks independent scaling, optimised projections, and eventual-consistency trade-offs at scale.
Low-Level Design for MAANG Interviews
A book-shaped table of contents for LLD interview prep: OOP fundamentals through SOLID, design principles, UML, design patterns, dependency management, reliability, concurrency, domain modeling, API design, testing, refactoring, classic interview problems, advanced architecture, and performance — cross-linking Object-Oriented Programming and Patterns instead of duplicating them.
2 — The Signals
Metrics, logs, traces, profiles, and events — what each is built to capture, what it costs, and which question it actually answers vs. which one people mistakenly ask it.
16 — Data Patterns (Microservice)
Database-per-Service, Shared Database, and Materialized View — the remaining data-ownership patterns beyond CQRS, Event Sourcing, Outbox, and Saga, which already have their own chapters in this book.
02 — Event Patterns
Event Notification vs. Event-Carried State Transfer, and Choreography vs. Orchestration — how services stay decoupled through events, building on the Event Sourcing and Saga patterns already covered in this book.
Patterns
A book-shaped table of contents for reusable engineering patterns spanning object-oriented design, enterprise architecture, distributed systems, messaging, APIs, cloud infrastructure, observability, security, concurrency, AI/agentic systems, and organizational design — grounded in production experience at scale.
System Design
Principal/Staff-level system design reference collection for MAANG interview preparation — observability pipelines, distributed systems, reliability engineering, and beyond.
Related notes
12 — CQRS — Command Query Responsibility Segregation
Separate the write model (commands) from the read model (queries). Unlocks independent scaling, optimised projections, and eventual-consistency trade-offs at scale.
14 — Transactional Outbox
Atomically write to the database and publish a message by using a single local transaction. The outbox table is polled or tailed by CDC to publish reliably. Solves the dual-write problem.
Q1 Answer — Search Fan-Out Design
Worked answer to Fan-Out/Fan-In Practice Q1: partitioning, deadline propagation, partial-result policy, and instrumentation priority for a 200-shard search API at 150ms P99.
Q2 Answer — Hedging Trade-off
Worked answer to Fan-Out/Fan-In Practice Q2: the load-vs-latency math of hedged requests, when to enable them, and what to instrument first to justify the decision.