Event-Driven Architecture · lesson 5 of 10
Beginner
3Intermediate
6- 4 Event Sourcing: Snapshots So Replay Doesn't Get Slower Forever
- 5 CQRS: Separate Write and Read Models, and the Lag Between Them
- 6 The Dual-Write Problem: A DB Commit and a Publish Can Fail Independently
- 7 Transactional Outbox: Committing the Event With the Data
- 8 Idempotent Consumers: Surviving a Duplicate Delivery
- 9 Dead-Letter Queues: What Happens When Retries Keep Failing
Also part of Event-Driven Architecture
6- + Saga Pattern: Orchestrating a Multi-Step Transaction from Microservices
- + Saga Pattern: Real Compensation When a Step Fails from Microservices
- + At-Most-Once, At-Least-Once, and Exactly-Once from Kafka
- + Serialization and the Schema Registry from Kafka
- + Pub/Sub: Channels, Patterns, and the Message That Never Arrives from Redis
- + Incremental Loads and Change Data Capture from Data Engineering
CQRS: Separate Write and Read Models, and the Lag Between Them
A denormalized read model only reflects the write log once a projector actually processes new events — real output showing that lag, and closing it.
By SkillByExample · Published
The read model in CQRS isn’t a live view of the write log — it’s a separately-updated projection that can fall behind, and knowing exactly how far behind is part of the design.
A read model that hasn’t caught up yet
write_log = []
def place_order(order_id, items):
total = sum(price for _, price in items)
write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
print(f"Order {order_id} written (total ${total})")
read_model = {"order_count": 0, "revenue": 0}
projected_up_to = 0
def catch_up_projection():
global projected_up_to
for event in write_log[projected_up_to:]:
read_model["order_count"] += 1
read_model["revenue"] += event["total"]
projected_up_to = len(write_log)
place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])
catch_up_projection()
print(f"Read model after projecting orders 1-2: {read_model}")
place_order(3, [("gizmo", 40)])
print(f"Write log has {len(write_log)} orders, but read model still says: {read_model} (stale)")
catch_up_projection()
print(f"Read model after the projector catches up: {read_model}")
$ python cqrs_write_read_models.py
Order 1 written (total $35)
Order 2 written (total $10)
Read model after projecting orders 1-2: {'order_count': 2, 'revenue': 45}
Order 3 written (total $40)
Write log has 3 orders, but read model still says: {'order_count': 2, 'revenue': 45} (stale)
Read model after the projector catches up: {'order_count': 3, 'revenue': 85}
place_order only ever appends to write_log — it has no idea read_model exists.
catch_up_projection is the only thing that ever touches read_model, and it only runs when
called, which is exactly why order 3’s write is real and durable the moment it’s appended, but
invisible to any query against read_model until the projector actually runs.
Practice
1. Build a second read model — item popularity — from the same write log.
write_log = []
def place_order(order_id, items):
total = sum(price for _, price in items)
write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])
place_order(3, [("widget", 10), ("gizmo", 40)])
revenue_model = {"order_count": 0, "revenue": 0}
item_counts = {}
def project_all():
revenue_model["order_count"] = 0
revenue_model["revenue"] = 0
item_counts.clear()
for event in write_log:
revenue_model["order_count"] += 1
revenue_model["revenue"] += event["total"]
for name, _ in event["items"]:
item_counts[name] = item_counts.get(name, 0) + 1
project_all()
print(f"Revenue read model: {revenue_model}")
print(f"Item-popularity read model: {item_counts}")
$ python second_read_model.py
Revenue read model: {'order_count': 3, 'revenue': 95}
Item-popularity read model: {'widget': 3, 'gadget': 1, 'gizmo': 1}
Both read models are built from the exact same three events — nothing about the write side changed to support a second, unrelated query shape.
2. Answer a query the read model was never designed for by going straight to the write log.
write_log = []
def place_order(order_id, items):
total = sum(price for _, price in items)
write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
place_order(1, [("widget", 10), ("gadget", 25)])
place_order(2, [("widget", 10)])
read_model = {"order_count": 0, "revenue": 0}
for event in write_log:
read_model["order_count"] += 1
read_model["revenue"] += event["total"]
def items_in_order(order_id):
for event in write_log:
if event["order_id"] == order_id:
return event["items"]
return None
print(f"Read model can answer 'how many orders': {read_model['order_count']}")
print(f"Read model has no concept of 'items in order 2' at all")
print(f"Going straight to the write log instead: {items_in_order(2)}")
$ python query_the_write_log.py
Read model can answer 'how many orders': 2
Read model has no concept of 'items in order 2' at all
Going straight to the write log instead: [('widget', 10)]
CQRS separates the models a system usually queries through, not the only place data lives — the write log stays queryable directly for anything a read model wasn’t built to answer, just less efficiently.
3. Measure staleness directly as a count of unprojected events, across several writes in a row.
write_log = []
def place_order(order_id, items):
total = sum(price for _, price in items)
write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
read_model = {"order_count": 0, "revenue": 0}
projected_up_to = 0
def catch_up_projection():
global projected_up_to
for event in write_log[projected_up_to:]:
read_model["order_count"] += 1
read_model["revenue"] += event["total"]
projected_up_to = len(write_log)
def staleness():
return len(write_log) - projected_up_to
for order_id in range(1, 6):
place_order(order_id, [("widget", 10)])
print(f"After writing order {order_id}: staleness = {staleness()} event(s) behind")
catch_up_projection()
print(f"After running the projector once: staleness = {staleness()} event(s) behind")
print(f"Read model: {read_model}")
$ python measure_staleness.py
After writing order 1: staleness = 1 event(s) behind
After writing order 2: staleness = 2 event(s) behind
After writing order 3: staleness = 3 event(s) behind
After writing order 4: staleness = 4 event(s) behind
After writing order 5: staleness = 5 event(s) behind
After running the projector once: staleness = 0 event(s) behind
Read model: {'order_count': 5, 'revenue': 50}
Staleness only ever grows between projector runs and only ever resets to zero when the projector actually runs — a real system would run this on a timer or in response to each new event, but the underlying number being tracked is the same either way.
4. Simulate a crash by starting from a wrong read model, and rebuild it correctly from the write log.
write_log = []
def place_order(order_id, items):
total = sum(price for _, price in items)
write_log.append({"type": "OrderPlaced", "order_id": order_id, "items": items, "total": total})
place_order(1, [("widget", 10)])
place_order(2, [("gadget", 25)])
place_order(3, [("gizmo", 40)])
read_model = {"order_count": 7, "revenue": 999}
print(f"Read model before 'crash' (already wrong): {read_model}")
def rebuild_read_model_from_scratch(write_log):
rebuilt = {"order_count": 0, "revenue": 0}
for event in write_log:
rebuilt["order_count"] += 1
rebuilt["revenue"] += event["total"]
return rebuilt
read_model = rebuild_read_model_from_scratch(write_log)
print(f"Read model rebuilt from the write log: {read_model}")
$ python rebuild_after_crash.py
Read model before 'crash' (already wrong): {'order_count': 7, 'revenue': 999}
Read model rebuilt from the write log: {'order_count': 3, 'revenue': 75}
Whatever garbage read_model held before doesn’t matter — rebuild_read_model_from_scratch
ignores it entirely and derives a fresh value purely from write_log, which is the actual
source of truth the whole time.