Event Sourcing & CQRS¶
Prerequisites: Consistency Models, Sagas
Why This Exists¶
Problem 1: Write and read shapes don't match.
Your write side: user.update({email, phone, preferences}) → one Postgres table.
Your read side: Homepage needs (user ID, name, profile pic, follower count, recent posts, badge). That's a join across 4 tables, and the follower count is expensive to compute. Add real-time search, and you need Elasticsearch denormalization.
One table that optimizes writes breaks reads. A schema that optimizes reads locks you into that read shape — add a new dashboard that needs different fields, and you rewrite migrations.
Problem 2: State transitions are implicit.
You see order.status = "shipped". You don't see why — did the warehouse process it? Did the customer force a status change via a bug? Did it roll back from shipped to pending? At 3 a.m., you need the history. Postgres has that in WAL and CDC, but it's not in your application's mental model.
Problem 3: Current state is a lossy projection.
user.updated_at = 2025-08-14 tells you when, not what changed or why. You can't recompute old reports. You can't debug "the system was consistent at T1 but not at T2" without full audit logs.
Mental Model: Events Are the Source of Truth¶
Think of it like a bank ledger vs. a balance sheet.
Ledger (Events): Every transaction is recorded. You can: - Audit "what happened" - Replay from any date - Answer "how did we get here?"
Balance Sheet (Current State): Just the net number. You can: - See the current balance fast - Never answer "how did we get here?" without the ledger
Event sourcing says: the ledger is the source of truth; the balance sheet is just a view.
Visual: Traditional vs. Event-Sourced¶
flowchart TB
subgraph Traditional["Traditional (State-Centric)"]
direction LR
DB["Database: Current State"]
DB -->|Read| Read["Client: Get user"]
Write["Client: Update email"] -->|Lose history| DB
style DB fill:#e65100,color:#fff
end
subgraph EventSourced["Event-Sourced (History is First-Class)"]
direction TB
EventLog["Event Store (Append-Only Log)"]
E1["UserRegistered(42, x@y)"]
E2["EmailUpdated(42, a@b)"]
E3["PicUpdated(42, url)"]
EventLog -->|1| E1
EventLog -->|2| E2
EventLog -->|3| E3
EventLog -->|Replay to Now| WriteState["Current State Cache<br/>(for speed)"]
EventLog -->|Project| ReadModel1["Read Model: User Card"]
EventLog -->|Project| ReadModel2["Read Model: Search Index"]
EventLog -->|Project| ReadModel3["Read Model: Analytics"]
style EventLog fill:#1565c0,color:#fff
style WriteState fill:#388e3c,color:#fff
end What changed? - Write: Client emits UserEmailUpdated event; event store appends (fast, immutable) - Read: Multiple read models project the event stream for different queries
Key insight: Events are immutable, durable, and time-ordered. Current state is a projection of events up to now. A different projection (read model) is just a different view of the same event stream.
The model that makes it click
Event sourcing = immutable log. CQRS = separate read/write models. They're orthogonal: - You can event-source without CQRS (reads rebuild from the log each time — slow) - You can CQRS without event-sourcing (writes update a table, reads use a denormalized cache — loses history) - Combined: writes append events; reads serve from pre-built projections. Best of both.
When CQRS Earns Its Complexity¶
CQRS costs: two data models, eventual consistency windows, projection lag, schema evolution complexity.
The Intuition¶
Imagine a pizza restaurant:
Without CQRS:
Order placed (write) → Update kitchen list AND update customer menu AND update analytics
(one table = one schema shape; optimize for order-taking, but menus and analytics suffer)
With CQRS:
Order placed (event) → Kitchen sees "order 42: 2 pizzas"; Customer sees "order 42: baking";
Analytics sees "1 order, $25 revenue"
(three different shapes for three different read needs, all fed by one event stream)
CQRS pays off when read/write shapes are fundamentally different:
| Scenario | Write shape | Read shape | CQRS worth it? |
|---|---|---|---|
| Blog | POST title, body | GET homepage (title + preview + author + comments count + date) | Yes — write is simple, read is expensive join |
| User profile | PUT | GET user card (name + pic + follower count + badge) | Yes — read aggregates across tables/services |
| Payment | POST | GET statement (paginated, filtered by date range, totals) | Yes — write is once, read is many, expensive |
| Simple CRUD | PUT | GET | No — same shape both ways, added latency for no gain |
| High-volume counter (like/follow) | Increment counter | Read counter | Maybe — if you need latency-free reads, not immediacy |
Red flags (CQRS will hurt): - Read and write shapes are the same (you're just adding staleness) - Reads need real-time consistency (CQRS adds latency by definition) - Projection lag breaks your SLO (e.g., payment confirmation must be immediate) - Your team has never done async projection before (scope creep is real)
Event Sourcing: The Mechanism¶
Structure¶
An event is an immutable fact about what happened:
# What changed
class UserRegistered:
user_id: str
email: str
name: str
created_at: float
# Events are immutable; include the "why" as context
class UserEmailUpdated:
user_id: str
old_email: str
new_email: str
reason: str # "user request" vs "admin correction" vs "data recovery"
triggered_by: str # user ID or system name
timestamp: float
The event store is the source of truth:
# Events stored in order, append-only, every write is a new event
event_store = [
UserRegistered(42, "alice@old.com", "Alice", 1000.0),
UserEmailUpdated(42, "alice@old.com", "alice@new.com", "user request", "42", 1001.0),
UserProfilePicUpdated(42, "https://pic.jpg", "user request", "42", 1002.0),
]
# Rebuild the current state by replaying events in order
def rebuild_user(user_id):
user = User()
for event in event_store:
if event.user_id == user_id:
user.apply(event)
return user
# Result: same model, but now you have every state at every point in time
Rebuilding State vs. Caching It¶
Rebuilding on-demand (simple, slow):
def get_user(user_id):
events = event_store.query(user_id=user_id)
state = User()
for event in events:
state.apply(event)
return state # ✗ O(N) where N = events for this user
Caching current state (complex, fast):
# Write side: append event, update cache
def update_user_email(user_id, new_email):
event = UserEmailUpdated(user_id, ..., new_email, ...)
event_store.append(event)
user_cache[user_id].email = new_email # Write-through cache
return event
# Read side: serve from cache
def get_user(user_id):
return user_cache[user_id] # O(1)
The trade-off: rebuild is consistent but slow; cache is fast but can diverge if not kept in sync.
Solution: event-sourced snapshots (checkpoint every N events):
# Store a snapshot every 100 events
snapshots = {
42: UserSnapshot(email="alice@new.com", pic="...", timestamp=1000), # at event 1000
}
def get_user(user_id):
# Start from the most recent snapshot
snapshot = snapshots.get(user_id)
state = snapshot.to_user() if snapshot else User()
# Replay only events after the snapshot
events = event_store.query(user_id=user_id, after=snapshot.timestamp)
for event in events:
state.apply(event)
return state
CQRS: Separate Models¶
Write Model (Command Side)¶
Optimized for correctness:
class UserWriteModel:
def __init__(self, event_store):
self.events = event_store
def register_user(self, email, name):
"""Validation happens here (single source)"""
if self.events.user_with_email_exists(email):
raise UserAlreadyExists()
event = UserRegistered(
user_id=str(uuid.uuid4()),
email=email,
name=name,
created_at=time.time(),
)
self.events.append(event)
return event.user_id
def update_email(self, user_id, new_email):
"""Validate before persisting"""
if not is_valid_email(new_email):
raise InvalidEmail()
if self.events.user_with_email_exists(new_email):
raise EmailTaken()
# Write only what changed
event = UserEmailUpdated(user_id=user_id, new_email=new_email)
self.events.append(event)
Read Model (Query Side)¶
Optimized for the shape you actually query:
# Read model 1: User card (for profile page)
class UserCardReadModel:
# Denormalized: all data needed in one query
db = {
42: {
"user_id": 42,
"name": "Alice",
"pic_url": "...",
"follower_count": 1250,
"badge": "verified",
"updated_at": 1002.0,
}
}
def get_card(self, user_id):
return self.db[user_id] # O(1), no joins
# Read model 2: User search (for Elasticsearch)
class UserSearchReadModel:
es = {
"mappings": {
"properties": {
"user_id": {"type": "keyword"},
"name": {"type": "text"},
"bio": {"type": "text"},
"follower_count": {"type": "integer"},
}
}
}
def search(self, query):
return es.search(query) # Full-text search, no joins
Projection: Keeping Read Models in Sync¶
A projector is like a mail sorter: it takes events and routes them to the right read models.
flowchart LR
E["Event: UserRegistered"]
P["Projector<br/>(reads events)"]
E --> P
P -->|If UserRegistered| Card["Update User Card<br/>(name, pic, followers)"]
P -->|If UserRegistered| Search["Update Search Index<br/>(full-text name)"]
P -->|If UserRegistered| Analytics["Update Analytics<br/>(new user count)"]
Card -->|Result| CR["Read Model 1<br/>(fast O1 lookup)"]
Search -->|Result| SR["Read Model 2<br/>(search index)"]
Analytics -->|Result| AR["Read Model 3<br/>(dashboards)"]
style P fill:#ff9800,color:#fff
style CR fill:#4caf50,color:#fff
style SR fill:#4caf50,color:#fff
style AR fill:#4caf50,color:#fff Important intuition: A projector doesn't update every read model for every event. UserEmailUpdated updates the write model but maybe not the "User Card" that's shown on the profile page (because your profile page doesn't show email). But it would update the search index if you search by email.
A projector subscribes to events and updates read models:
class UserProjector:
def __init__(self, event_store, write_model, card_model, search_model):
self.event_store = event_store
self.write_model = write_model
self.card_model = card_model
self.search_model = search_model
def handle_user_registered(self, event):
"""Update all read models when a new user is registered"""
# Card read model
self.card_model.db[event.user_id] = {
"user_id": event.user_id,
"name": event.name,
"pic_url": None,
"follower_count": 0,
"badge": None,
"updated_at": event.created_at,
}
# Search read model
self.search_model.es.index(
index="users",
id=event.user_id,
body={
"user_id": event.user_id,
"name": event.name,
"bio": "",
"follower_count": 0,
}
)
def handle_user_email_updated(self, event):
"""Email changed; only write model needs this (search doesn't index email)"""
# Nothing to update in card or search for this event
pass
def handle_user_pic_updated(self, event):
"""Update both read models"""
self.card_model.db[event.user_id]["pic_url"] = event.pic_url
self.search_model.es.update_by_query(
index="users",
query={"match": {"user_id": event.user_id}},
body={"doc": {"pic": event.pic_url}}
)
def handle_user_followed_user(self, event):
"""Follower count changed"""
count = self.event_store.count_followers(event.user_id)
self.card_model.db[event.user_id]["follower_count"] = count
self.search_model.es.update_by_query(
index="users",
query={"match": {"user_id": event.user_id}},
body={"doc": {"follower_count": count}}
)
def project_all(self):
"""Run once to bootstrap; then run continuously on new events"""
for event in self.event_store.all_events():
handler_name = f"handle_{event.__class__.__name__}"
if hasattr(self, handler_name):
getattr(self, handler_name)(event)
The projection is asynchronous by default (lag is expected):
# Write succeeds immediately
event = write_model.update_email(42, "new@email.com") # ✓ Returns immediately
# But read models update a moment later
# (milliseconds to seconds, depending on your infrastructure)
time.sleep(0.5) # Wait for projection to catch up
user_card = card_model.get_card(42) # Sees the new email
Trade-offs: What You Gain and Lose¶
The Graph: What You're Trading¶
graph TB
A["Event Sourcing + CQRS"]
A -->|Gain| G1["✓ Full audit trail"]
A -->|Gain| G2["✓ Scale reads independently"]
A -->|Gain| G3["✓ Easy new read models"]
A -->|Lose| L1["✗ Projection lag"]
A -->|Lose| L2["✗ Schema evolution complex"]
A -->|Lose| L3["✗ More operational parts"]
G1 --> E["Eventually consistent reads<br/>(stale by design)"]
L1 --> E
L2 -->|Needs| Upcasting["Upcast old events<br/>or provide defaults"]
L3 -->|Needs| Monitoring["Monitor projectors<br/>for lag and failures"]
style A fill:#1565c0,color:#fff
style G1 fill:#388e3c,color:#fff
style G2 fill:#388e3c,color:#fff
style G3 fill:#388e3c,color:#fff
style L1 fill:#d32f2f,color:#fff
style L2 fill:#d32f2f,color:#fff
style L3 fill:#d32f2f,color:#fff The Table: What Each Costs¶
| Aspect | Gain | Cost |
|---|---|---|
| Auditability | Full history, can answer "what changed and why" | Schema evolution (old events may have missing fields) |
| Consistency | Write side is always consistent | Read models are stale by a projection lag window |
| Scalability | Read and write can scale independently | Projection complexity; one slow read model blocks all |
| Debuggability | Replay events from any point; time-travel | More moving parts (event store, projectors, read models) |
| Schema flexibility | Add new read models without changing writes | Multiple read models = multiple sources of truth to reconcile |
The essential trade-off:
You get: Auditability + independent scaling
You lose: Immediacy (reads lag writes)
You gain: The ability to answer "why is the system like this?"
The Schema Evolution Trap¶
Old events have old shapes:
# v1: UserRegistered
{"user_id": 42, "email": "alice@old.com", "name": "Alice"}
# v2: Add phone_number
# Problem: old events don't have phone_number
{"user_id": 42, "email": "alice@new.com", "name": "Alice"}
# ^ Missing phone_number; projection breaks
# Solution 1: Upcasting (convert old events on read)
def upcaste_user_registered(event):
return UserRegistered(
user_id=event.user_id,
email=event.email,
name=event.name,
phone_number=event.get("phone_number", ""), # Provide default
)
# Solution 2: Versioning in the event type
UserRegisteredV1 → UserRegisteredV2 (both handlers in your projector)
# Solution 3: Re-emit as new event (heavy but clean)
# When rebuilding, if you see UserRegisteredV1, emit UserRegisteredV2 + send to event store
Failure Modes: When It Goes Wrong¶
1. Projection Lag Breaks SLOs¶
The Timeline:
sequenceDiagram
User->>WriteAPI: POST /checkout (pay for order)
WriteAPI->>EventStore: Append PaymentProcessed event
EventStore-->>WriteAPI: ✓ Done
WriteAPI-->>User: ✓ Payment successful!
User->>ReadAPI: GET /order/42 (show status)
ReadAPI->>ReadModel: Query order status
ReadModel-->>ReadAPI: "pending" (projector hasn't caught up)
ReadAPI-->>User: Status: pending ✗ (user expected "paid")
Note over Projector: Meanwhile, projector catches up...
Projector->>ReadModel: Update order status → "paid"
User->>ReadAPI: GET /order/42 (refresh)
ReadAPI->>ReadModel: Query order status
ReadModel-->>ReadAPI: "paid" (now it's right)
ReadAPI-->>User: Status: paid ✓ Intuition: Write side answers immediately (event appended). Read side answers from the past (projection not caught up). The lag is a feature of CQRS, not a bug — but it must be within your SLO.
Mitigation: - For critical reads, query the write model directly (sacrifice consistency for immediacy) - Use strong read-after-write consistency: after a write, client goes to the specific write leader
def update_email(user_id, new_email):
event = write_model.update_email(user_id, new_email)
# Return the updated state directly from write model
# Don't make the client poll the read model
updated_user = write_model.get_current_state(user_id)
return updated_user
2. Event Store Bloats¶
The Problem:
Year 1: 10M events, rebuild in 10 seconds ✓
Year 2: 50M events, rebuild in 50 seconds ✓
Year 3: 100M events, rebuild in 100 seconds ✗
Year 5: 500M events, rebuild in 500 seconds (8 min) ✗✗
Every read requires replaying ALL events = slow gets slower
Why it matters: You need to rebuild state when: - A projector crashes and needs to catch up - A new read model goes online and needs to bootstrap - You need to reset a read model due to corruption
Mitigation: - Snapshots — checkpoint state every N events, skip events before the snapshot - Event compaction — for events that are superseded (e.g., UserEmailUpdated v1 and v2), keep only the latest - Archival — move old events to cold storage (S3), keep hot events in the database
How Snapshots Help:
graph LR
subgraph NoSnapshot["Without Snapshots"]
E["100M events"]
Rebuild["Rebuild state:<br/>Replay all 100M events"]
Slow["⏱ Takes 500s"]
E --> Rebuild --> Slow
style Slow fill:#d32f2f,color:#fff
end
subgraph WithSnapshot["With Snapshots"]
E2["100M events"]
S["Snapshot at 10M events"]
Events["Remaining 90M events"]
Rebuild2["Rebuild state:<br/>Load snapshot + replay 90M events"]
Fast["⏱ Takes 450s"]
E2 --> S --> Rebuild2 --> Fast
E2 --> Events --> Rebuild2
style Fast fill:#388e3c,color:#fff
style S fill:#ff9800,color:#fff
end Implementation: Take a checkpoint every N events or every T minutes:
def compact_events():
"""Keep only the last version of each event type per user"""
for user_id in all_users:
latest = {}
for event in events_for_user(user_id):
event_type = type(event).__name__
latest[event_type] = event # Overwrite older version
# Delete old versions, keep latest
event_store.delete(user_id, keep_only=latest.values())
3. Eventual Consistency Windows Hide Bugs¶
Two requests arrive simultaneously:
Request 1: Update email to "alice@new.com"
Request 2: Read user email (projection not caught up)
Request 2 gets: old email
User is confused: "I just changed it!"
Mitigation: - Explicit staleness contract: "Card view may be up to 1s behind" - Retry logic: if you just wrote something, keep retrying reads until you see your write - Write-through: return the fresh state from the write, not from the read model
4. Projection Failure Cascades¶
A single event breaks the projector:
def handle_user_registered(event):
# Bug: assumes pic_url is always present (it's not in old events)
picture = Picture(url=event.pic_url) # KeyError on old events
Projector crashes, all read models go stale, no way to recover except replay (which hits the same bug).
Mitigation: - Idempotent projections — handle "if already exists" gracefully - Dead-letter queue — skip bad events, log them, alert ops - Projector versioning — run multiple projector versions (v1 and v2) in parallel, switch when v2 is ahead
def handle_user_registered(event):
pic_url = event.get("pic_url", "") # Default if missing
if pic_url:
picture = Picture(url=pic_url)
else:
picture = None # Handle gracefully
5. Domain Events Leak Implementation Details¶
Wrong:
# This is a database transaction detail, not a domain event
class UserTableRowInserted:
table: str = "users"
columns: dict = {...}
Right:
# This is what happened in the business domain
class UserRegistered:
user_id: str
email: str
name: str
The difference: when you refactor the database schema, you don't emit new events. The domain didn't change; your storage did.
Event Replay vs. Backfilling¶
Both operations reprocess history through your consumers, but they answer different questions — conflating them is how a "populate the new feature" job turns into a "re-charge every customer" incident.
| Aspect | Replay | Backfill |
|---|---|---|
| Meaning | Re-process events the system already saw | Populate a new field/model using events that predate its existence |
| Purpose | Recover from a bug or rebuild a projection | Support a newly introduced feature or historical calculation |
| Triggered by | A processing bug (correction) | A new requirement (net-new capability) |
| Result | Corrected/rebuilt state in an existing shape | A new shape, populated for the first time |
Example: you ship a CustomerLifetimeValue field today, but the events needed to compute it (OrderPlaced, PaymentProcessed) go back two years. Walking the historical event log to populate CustomerLifetimeValue for existing customers is a backfill — you're not correcting anything, you're using history to fill something that never existed. Re-running the pricing calculator over the last month of orders because it shipped with a bug is a replay — you're correcting a wrong result, not creating a new one.
In both cases, the mechanism is identical (read old events, run them through logic, write a result) — which is exactly why it's worth naming the distinction: it tells you what you're allowed to touch. A replay's job is to converge on the state you should have already had; a backfill's job is to create state that never existed. Confusing the two is how you end up mutating an existing read model with logic built for a fresh one, or vice versa.
Don't Replay Into the Production Path Blindly¶
The instinct is to point the fixed logic at the same consumer group that serves production. Don't — for two reasons: it disturbs live traffic (the consumer stops making forward progress on new events while it walks history), and any code path with an external side effect (charging a card, sending an email, calling a webhook) re-fires for every historical event, because the event log doesn't know which of those calls already happened in the real world.
flowchart TB
Topic["Kafka Topic: OrderPlaced, PaymentProcessed, ..."]
subgraph PG["Production Consumer Group"]
PC["Consumer<br/>tracks live offset"]
end
subgraph RG["Replay Consumer Group (separate group id)"]
RC["Consumer<br/>reads from offset 0 or a chosen point"]
end
Topic --> PC --> PDB[("Production DB /<br/>live read model")]
Topic --> RC --> RDB[("New projection /<br/>replay target")]
style PG fill:#1565c0,color:#fff
style RG fill:#ff9800,color:#fff
style PDB fill:#388e3c,color:#fff
style RDB fill:#d32f2f,color:#fff Two consumer groups reading the same topic independently is normal Kafka — each group tracks its own offsets, so the replay consumer can start from offset 0 (or any earlier point) without touching where the production consumer currently is. Point the replay consumer at a new projection or a controlled processing path, validate it, then cut reads over — don't mutate the live read model in place while it's still serving traffic. See Kafka's mental model for how group-level offset tracking works.
Replay Risk Checklist¶
Before running any replay or backfill job, check whether the consumer's side effects are safe to re-fire:
- Duplicate side effects — emails, SMS, push notifications sent again
- External API calls — a payment charge or refund API called a second time
- Notifications — Slack/webhook triggers firing for events that already resolved
- Processing load — replaying millions of events can saturate downstream systems sized for steady-state traffic, not a burst
- Out-of-order assumptions — code that assumes "this is the first time I've seen this event" breaks on replay
- Schema drift — old events may not match the current event schema (see Schema Evolution Trap above)
The fix for the first three is the same one covered in delivery semantics: a dedup table keyed on event ID, checked before any external call, so replaying an event the consumer already applied is a no-op rather than a repeat. A replay pipeline that can't tell "rebuildable projection state" apart from "irreversible side effect" is the actual failure mode here — not the replay itself.
Real Production Case: E-Commerce Order System¶
Requirements: - Order status changes (placed → processing → shipped → delivered) - Search orders by customer/date/status (millions of orders) - Timeline view: "What happened to my order?" (full event log) - Warehouse system needs to know "orders placed in last hour" (different shape than customer view)
The System Architecture:
graph TB
subgraph EventLog["Event Store (Source of Truth)"]
E1["OrderPlaced(42, cust123, pizza)"]
E2["PaymentProcessed(42)"]
E3["InventoryReserved(42)"]
E4["OrderShipped(42, tracking)"]
E5["OrderDelivered(42)"]
E1 --> E2 --> E3 --> E4 --> E5
end
subgraph Projectors["Projectors"]
P1["Timeline Projector"]
P2["Search Projector"]
P3["Warehouse Projector"]
end
subgraph ReadModels["Read Models"]
R1["Order Timeline<br/>(All events, immutable)"]
R2["Search Index<br/>(status, customer_id, date, total)"]
R3["Warehouse Queue<br/>(Orders from last hour)"]
end
subgraph Queries["Queries"]
Q1["Get order history"]
Q2["Find orders by status"]
Q3["Pull next order to ship"]
end
EventLog --> P1 --> R1 --> Q1
EventLog --> P2 --> R2 --> Q2
EventLog --> P3 --> R3 --> Q3
style EventLog fill:#1565c0,color:#fff
style P1 fill:#ff9800,color:#fff
style P2 fill:#ff9800,color:#fff
style P3 fill:#ff9800,color:#fff
style R1 fill:#388e3c,color:#fff
style R2 fill:#388e3c,color:#fff
style R3 fill:#388e3c,color:#fff Notice: One event stream feeds three completely different read models. Each one is optimized for a different query pattern, but they all stay in sync because they all consume the same events.
Why CQRS + Event Sourcing:
- Write model: Minimal —
OrderPlaced(id, customer_id, items, total, ts)plus state machine (validate transitions) - Read model 1 (customer timeline): All events for one order → events sorted by timestamp
- Read model 2 (search): Denormalized table (customer_id, status, date, total) for quick filtering
- Read model 3 (warehouse): Orders placed in last 1h → query by timestamp range
Events:
class OrderPlaced:
order_id: str
customer_id: str
items: list
total: float
ts: float
class PaymentProcessed:
order_id: str
amount: float
ts: float
class InventoryReserved:
order_id: str
items: list
ts: float
class OrderShipped:
order_id: str
tracking_number: str
ts: float
class OrderDelivered:
order_id: str
delivered_at: float
ts: float
Projectors:
class OrderTimelineProjector:
"""Write model: audit log of order state"""
def project(self, event):
# Append to timeline; never delete
timeline[event.order_id].append(event)
class OrderSearchProjector:
"""Read model: fast search by status/date"""
def project(self, event):
if isinstance(event, OrderPlaced):
search_index[event.order_id] = {
"order_id": event.order_id,
"customer_id": event.customer_id,
"status": "placed",
"date": event.ts,
"total": event.total,
}
elif isinstance(event, OrderShipped):
search_index[event.order_id]["status"] = "shipped"
class WarehouseProjector:
"""Read model: recent orders for fulfillment"""
def project(self, event):
if isinstance(event, OrderPlaced):
if event.ts > now() - 3600: # Last hour
warehouse_queue.append(event.order_id)
Queries:
# Customer timeline: full history
timeline = order_timeline[order_id] # Fast O(1)
# Search orders: no joins, O(1) lookup
orders = search_index.query(customer_id=42, status="shipped")
# Warehouse: only recent orders
recent = warehouse_queue.get_recent()
Failure: Warehouse projector crashes on a malformed event.
# Recovery: restart projector from the last checkpoint, skip the bad event
warehouse_projector.recover_from_checkpoint(checkpoint_ts=1628000000)
warehouse_projector.skip_event(bad_event_id)
warehouse_projector.resume()
Interview Questions¶
Q: What's the difference between event sourcing and CQRS?
"Event sourcing stores state as immutable events instead of snapshots — you rebuild state by replaying events. CQRS separates the write model (optimized for correctness) from read models (optimized for query shape). They're orthogonal: you can do one without the other, but together they solve the 'read/write shapes mismatch' problem at the cost of eventual consistency."
Q: You introduce event sourcing and suddenly the customer timeline view is 5 seconds behind. How do you fix it?
"It's a projection lag problem. I'd first check if the projector is slow (query the projector logs). If it's just inherent lag, I have three moves: one, for the current-user timeline, query the write model directly instead of the read model after a write (stronger consistency for the user). Two, add a cache layer that the user invalidates after writing. Three, accept the lag but display it: 'Timeline updates every 1-5 seconds.' If that doesn't work, event sourcing might be the wrong tool for this use case."
Q: Your event store has 500M events. Rebuilding state takes 15 minutes. Can you scale this?
"First, I'd look at whether we're actually replaying 500M events or if snapshots are working — describe the snapshot strategy. If snapshots are fine, the 15 minutes is acceptable for cold starts (rebuild happens rarely). If we're rebuilding on every request, that's a design bug — we should cache the current state and only rebuild on failure. If snapshots aren't helping, I'd implement event compaction: instead of storing every 'UserEmailUpdated' event, keep only the latest one per user. For truly massive scale, I'd split the event store by tenant or date partition — 500M events in one table is a maintenance nightmare. I'd also check if we can move old events to cold storage (S3) and keep only recent events hot."
When Not to Use Event Sourcing + CQRS¶
- Simple CRUD with same read/write shapes → Just use Postgres
- Real-time consistency is non-negotiable → The lag will break your SLO
- Your team has never done this before → The complexity will drown out the benefits
- The read/write split doesn't actually exist → You're adding complexity for no reason
The discipline: identify the pressure, then earn the pattern.
Key Takeaways¶
Remember
- Event sourcing = immutable log of what happened; current state is a projection
- CQRS = separate write model (correct) and read models (fast); they stay in sync via projections
- Projection lag is a feature, not a bug — but it must be within your SLO
- Schema evolution is the hard part (old events have old shapes; handle gracefully)
- Snapshots make replay fast (replay only events after the snapshot)
- Projector failures cascade (handle them gracefully; use dead-letter queues)
- For high-consistency reads, query the write model directly; for eventual-consistency reads, query the read model
- Event sourcing only pays off when you actually need the audit trail; without it, you're trading latency for auditability
Previous: Architecture Patterns | Next: Sagas