CQRS with a separate read store: useful projections, new guarantees
Published At: · 5 min readengineering architecture #architecture #cqrs #projections #eventual-consistency #dotnet #csharp
The previous post kept commands and queries in one application and one database. That is enough for many systems.1
A separate read store is a further step. It is useful when a screen, report, or read workload has needs that the write model should not own. It also changes a promise that a single store made implicitly: a query immediately after a successful command may no longer see the change.
A read model can be shaped for one question
Suppose an order service stores customers, orders, lines, shipments, and payments in a normalized write database. A support screen needs a list like this:
public sealed record OrderTrackingRow(
string OrderNumber,
string CustomerName,
string Status,
DateTimeOffset LastUpdated,
decimal Total);
The command side needs normalized records and transaction rules. The tracking screen needs one row per order, sorted and filtered by values it displays. Those are different jobs.
A projection can keep a table or document that already has the fields the screen needs:
OrderPlaced or OrderStatusChanged
│
▼
Update OrderTrackingRow
│
▼
Order tracking read store
This is not a second domain model. It is a disposable representation of facts the command side accepted. Microsoft describes CQRS as separate read and write data models that can be optimized independently.[1]
The boundary earns its cost when it solves a present problem
A separate store can help when the read side needs one of these things:
- A denormalized shape that avoids repeated joins for a high-traffic screen.
- A reporting or search model that does not belong in transactional tables.
- Independent read capacity without moving command processing.
It is not automatically better architecture. Martin Fowler warns that CQRS adds risky complexity for most systems.[2] If one database and direct projections already meet the need, keep them. The extra store exists to solve a measured query, reporting, or scaling problem.
A successful command can still show stale data
With one database, ShipOrder can commit and the next GetOrderSummary can read the new status in the same transaction boundary.
With a separate read store, the sequence is different:
ShipOrdervalidates the order and commitsStatus = Shippedin the write store.- The change is published for the tracking projection.
- The projection updates its read store.
GetOrderTrackingseesShipped.
Between steps 1 and 3, the tracking page can still say Paid. That is eventual consistency, not an exception path. Separate read and write stores need a way to synchronize, and the read store can lag behind the write store.[1]
Do not hide that fact behind a redirect and hope timing works. After a successful command, choose an explicit experience:
- Return the command result and show a “shipping update accepted” confirmation.
- Read the authoritative write-side record for the immediate confirmation screen.
- Show when the read model was last updated when stale information has operational impact.
The right choice depends on the user action. A warehouse worker may need confirmation that a shipment was recorded even when the dashboard has not caught up. A public activity feed can usually tolerate a short delay.
Projection code must be restartable
The projection is another system that can stop, receive the same message twice, or fail after updating one record. It needs a small, explicit recovery story.
For an OrderStatusChanged event, store a position or event identifier with the projection update. On restart, continue from the last processed position. If a delivery is repeated, make applying it safe to repeat instead of incrementing counters or creating duplicate rows.
The details vary by storage and transport. The invariant does not: the projection must be rebuildable from the write-side history or another authoritative change log. If it cannot be rebuilt, it has become data that the command side no longer owns or can repair.
This is the operational cost that a second store introduces: monitoring lag, handling failures, and testing replay behavior. Do not pay it for a query that a normal index or a direct projection can answer.
Separate only when the read need is real
Separate read models are valuable because they let a query answer its own question efficiently. Separate read stores are valuable only when that need exceeds what the shared database can provide.
Keep the write side authoritative. Treat projections as replaceable. State where eventual consistency is visible, then build the smallest synchronization path that makes that promise true.