Skip to content

CQRS Across Service Boundaries

What was run

A real write_service.py (port 8101) backed by a real SQLite write_model.db, a real read_model_projector.py process consuming its outbox table and maintaining a separate, denormalized read_model.db, and a real read_service.py (port 8102) serving only from that read model. 500 orders were created through the real API for customer 1, then 300,000 more rows were inserted directly into the orders table (bypassing the API and its outbox) to simulate scale — which, honestly, produced a real bug worth seeing, not just a timing number.

The query, two ways, timed

# write_service.py — correct, but must aggregate over every matching row, every time
@app.get("/customers/{cid}/total-spent-computed-live")
def total_spent_live(cid: int):
    row = conn.execute("SELECT SUM(amount_cents), COUNT(*) FROM orders WHERE customer_id = ?", (cid,)).fetchone()
    return {"customer_id": cid, "total_cents": row[0] or 0, "order_count": row[1]}

# read_service.py — pre-computed by a separate projector process, always a single-row lookup
@app.get("/customers/{cid}/total-spent-from-read-model")
def total_spent_read_model(cid: int):
    row = conn.execute("SELECT total_cents, order_count FROM customer_totals WHERE customer_id = ?", (cid,)).fetchone()
    return {"customer_id": cid, "total_cents": row[0], "order_count": row[1]}
# warmed-up timings, orders table at 300,500 rows:
curl -s localhost:8102/customers/1/total-spent-from-read-model -o /dev/null -w "time:%{time_total}\n"
# time:0.000868   (and 0.001005, 0.002058 across repeats)

curl -s localhost:8101/customers/1/total-spent-computed-live -o /dev/null -w "time:%{time_total}\n"
# time:0.006638   (and 0.007020, 0.007635 across repeats)

The read model answered in roughly a quarter to an eighth of the time of the live aggregate, consistently, because it's a primary-key lookup against a tiny pre-aggregated table instead of a scan-and-sum over every order row.

The real bug this setup exposed, honestly

curl -s localhost:8102/customers/1/total-spent-from-read-model
# {"customer_id":1,"total_cents":500000,"order_count":500}

curl -s localhost:8101/customers/1/total-spent-computed-live
# {"customer_id":1,"total_cents":650000,"order_count":650}

These two numbers disagree, and not because of ordinary eventual-consistency lag — the read model is correctly showing the 500 orders that went through create_order() and its outbox row. The 300,000 bulk rows were inserted directly into the orders table with a raw INSERT that never touched the outbox table, so the projector never saw them and never updated customer_totals for the ~150 of those rows that happened to belong to customer 1. The read model isn't being slow to catch up — it's permanently missing data that bypassed its only input pipeline. This is a completely real, common CQRS failure mode, not a contrived one: any write path that doesn't go through the same event/outbox mechanism the projector depends on will silently drift the read model and never self-correct.

How It Actually Works

CQRS (Command Query Responsibility Segregation) separates the model used to write data (the command side — write_service.py's normalized orders table, optimized for correctly recording individual transactions) from the model used to read data (the query side — read_model.db's denormalized customer_totals table, optimized for answering one specific question fast). The two models don't have to use the same schema, the same database technology, or even the same service — the read side can be a completely different service, scaled and optimized independently of the write side, exactly as this demo splits them.

The connective tissue between the two sides — here, the outbox + projector from Lesson 5 — is doing real, critical work: it's the only path by which the write side's changes reach the read side. This lesson's bug demonstrates the sharp edge of that design directly: a read model is only as complete as its event pipeline's coverage of every possible write path. A system that has even one write path skipping the outbox (a backfill script, an admin tool, a different service writing directly to the same table) will have a read model that's wrong in a way that never fixes itself, no matter how long you wait — unlike ordinary replication lag, which resolves on its own given time.

This is the deeper reason CQRS is usually paired with event sourcing or at least a single, disciplined write path: if the write model's only way of mutating state already goes through an event that the read side also consumes, there's no way to "bypass" the pipeline, because there's no other way to write at all.

Common mistakes

  • Treating a CQRS read-model discrepancy as "it'll catch up eventually" without first checking whether every write path actually produces an event — as shown above, some discrepancies are permanent, not transient lag.
  • Letting a second write path (an admin panel, a migration script, direct database access for "just this once") write to the command side's tables without going through the same code path that produces outbox events.
  • Building a read model as a premature optimization before measuring whether the live-aggregate query is actually too slow for its real use case — Lesson 2's data-ownership lesson applies here too: build the access pattern the consumer actually needs, when it's actually needed.

Exercise

Write a small reconciliation script that compares, for every customer with at least one order, the write side's live-computed total against the read model's stored total, and prints every customer where they disagree. Run it against this demo's data and confirm it surfaces the discrepancy found above. Then fix the root cause: modify the bulk-insert step to also write corresponding outbox rows, re-run the projector, and confirm the reconciliation script reports zero discrepancies afterward.