Read models and projections
Denormalized read models are built by projecting write-side state changes. The projection creates a sync problem: the read model can be stale. Bounding staleness is a design choice — synchronous projection is consistent but slower; asynchronous is fast but eventually consistent.
The billing platform team had split their write and read models. The write side — the normalized Order aggregate — was clean. The read side was a separate OrderDashboardRow table: one denormalized row per order, pre-joined with account manager name, last payment event, and overdue status. Dashboard queries became fast. But the read model had to come from somewhere. The team’s first attempt was simple: after each command handler committed a write, it also updated the OrderDashboardRow table directly, in the same database transaction. This worked. Consistency was perfect. Then the team noticed that every Order write — even writes that had nothing to do with the dashboard — took longer because of the additional OrderDashboardRow update. Worse, when the product team added a second read model for account manager reporting, the command handlers grew again. And when they added a third read model, a senior engineer pointed out that the command handlers now had three responsibilities: enforcing the order invariants, updating the dashboard view, and updating the manager report view. The command handlers had become entangled with read model construction. The team stepped back and asked: where should the read models come from? The answer is projection: the read model is built by observing state changes on the write side and transforming them into the read model’s shape. The projection mechanism — not the command handler — is responsible for keeping the read model current. But projection introduces a question the team did not expect: the read model might not be current yet when a query arrives. How stale is acceptable, and how do you keep that window bounded?
What a read model is
A read model is a pre-built, denormalized data structure shaped for a specific query. It is not the write model viewed through a different angle — it is a different artifact, built from the write side’s state changes, and structured for a specific read concern.
For the order dashboard, the read model might be an OrderDashboardRow table with columns: order_id, customer_name, account_manager_name, submitted_at, total_amount, status, last_payment_event, is_overdue. This row exists purely to serve the dashboard query. No invariant is enforced on it. No business rule lives in it. It is the materialized answer to “what does the dashboard need to display?”
Different read models serve different queries. A read model for the account manager’s pipeline view has different columns than the dashboard. A read model for the finance team’s overdue-orders report has different grouping and filtering. Each read model is shaped for its specific consumer. This is the structural freedom CQRS provides: reads are no longer forced to use the write model’s shape.
Where read models come from: projection
A projection is the process that translates write-side state changes into a read model. When an Order is submitted, the write side commits the change to the normalized orders table. The projection observes that change and updates the OrderDashboardRow: it fetches the account manager name, computes the total, sets the status. The read model is now current.
The projection is the mechanism that owns the transformation: write-side event or state change → read model update. Command handlers do not update read models. That responsibility belongs entirely to the projection.
The projection can observe write-side changes through different mechanisms:
- Domain events: the command handler emits a
OrderSubmittedevent; the projection subscribes and handles it. - Database triggers or change-data capture: the projection watches the
orderstable directly at the database level. - Synchronous in-process call: the projection is called directly after the write commits, still within the same thread (but in a separate concern from the command handler).
Which mechanism you choose determines the staleness characteristics of the read model.
The sync problem: staleness
When the projection runs asynchronously — via a message bus or a background process observing change-data capture — the read model is eventually consistent with the write side. There is a window between when the write commits and when the projection runs and updates the read model. During that window, queries see the old data.
This is not an accident or a failure. It is a deliberate trade-off. Asynchronous projection decouples the write path from the read model construction: writes commit quickly; the projection runs in the background without blocking the command handler. The cost is staleness.
The staleness window is typically milliseconds to low seconds in a healthy system. But the team must decide: is that acceptable for each specific read model?
For the order dashboard, a few seconds of staleness is usually fine. The sales rep who submitted an order does not need to see the updated dashboard status in the same request. Eventual consistency is acceptable.
For a CurrentCreditAvailability read model used to make the credit-limit decision during order submission, staleness is dangerous. If the read model lags, a second order might pass the credit check against a stale available-credit figure. This read model probably should not be a denormalized projection at all — it should be a synchronous query against the write side.
▸Why this works
The right question about staleness is not “how do we eliminate it?” but “what is the acceptable staleness for each specific read model?” Some read models tolerate seconds; some tolerate minutes (a weekly report); some cannot tolerate any lag at all and should not be asynchronous projections. Designing read models means explicitly deciding the staleness budget for each one, not defaulting to eventual consistency everywhere.
Synchronous projection: consistency at the cost of coupling
A synchronous projection runs within the same transaction as the write. When SubmitOrder commits, the projection updates OrderDashboardRow in the same database transaction. The read model is always consistent with the write side.
The cost: the write transaction is now longer (it must also complete the read model update), and the command handler’s transaction boundary now encompasses the read model concern. If the projection fails, the entire command fails. Adding another read model makes the transaction longer again.
Synchronous projection is appropriate when:
- The read model is simple and small (one row, one fast update).
- The staleness of the read model is unacceptable even for milliseconds.
- The performance overhead of the extra write is acceptable.
It becomes problematic when there are many read models, when read model construction is expensive, or when you want the write and read concerns to evolve independently.
Asynchronous projection: decoupled at the cost of eventual consistency
An asynchronous projection runs after the write commits — via a domain event on a message bus, or via change-data capture. The command handler commits only the write-side change. The projection runs in a separate process or thread.
The benefits: the command handler’s transaction is minimal and fast; the projection concern is decoupled from the write concern; multiple read models can be updated by independent projection workers without affecting the write path; the write and read models can be deployed and scaled independently.
Rebuilding by replay
One of the most powerful properties of a projection-based read model: it can be rebuilt from scratch by replaying write-side history. If the read model schema changes — you add an is_overdue column that did not exist before — you can drop the read model, replay all historical write-side events (or re-project from the source-of-truth write table), and reconstruct the read model with the new column. No data migration on the write side. No schema migration on the write table.
This works because the read model is a derived artifact. It holds no data that does not also exist on the write side. The write side is the source of truth; the read model is a materialized view of it. Dropping and rebuilding the read model loses nothing that cannot be recovered.
This property becomes even more powerful when the write side uses an event log as its source of truth (event sourcing, covered in unit 08). With an event log, you can replay the exact sequence of events that produced every order in history and build a new read model from scratch in a background job without any downtime.
The team has an async projection updating the order dashboard read model. A product manager complains: 'After I approve an order, my dashboard still shows it as pending for a few seconds.' The engineering lead responds: 'That is expected — the read model is eventually consistent.' Is this acceptable, and how should the decision have been made?
The team needs to add a new column `days_since_last_contact` to the order dashboard read model. With a traditional normalized schema, this would require a database migration. In a projection-based CQRS system, how is this handled differently?
A senior engineer proposes: 'We should keep a single read model that all our queries use. Having multiple read models for different views is wasteful duplication.' What is the counter-argument?
- 01What is a projection, and why does it exist as a separate concern from the command handler?
- 02What is the staleness window in async projection and how should it be managed?
- 03Why can a read model be rebuilt by replaying write-side history, and when is this valuable?
The opening story’s entangled command handlers — updating three read models inline — were not a bad implementation of CQRS. They were the right instinct (keep read models separate from the write model) executed with the wrong mechanism (building read models in command handlers). The fix is projection: a separate concern that observes write-side state changes and transforms them into read model updates. The command handler’s transaction covers only the write side.
Projection introduces staleness. Async projection is fast and decoupled — the write path is lean — but the read model lags behind the write side. That lag is almost always acceptable for views like dashboards. It is not acceptable for read models involved in write-side decision-making. Every read model needs an explicit staleness budget.
Read models are derived artifacts. They can be dropped and rebuilt from write-side history without losing anything. New columns, bug fixes, new view requirements — all can be handled by updating the projection logic and replaying history. The write side does not need to change.
The next lesson examines when CQRS is overkill — the complexity it introduces, which cases justify it, and the common mistake of conflating CQRS with event sourcing.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.