CQRS Read Models for Performance
Optimize reads independently from writes: denormalized read stores, projection patterns, and keeping read models eventually consistent.
The Performance Problem with Shared Models
In a traditional architecture, the same normalized database schema handles both reads and writes. Normalized schemas are optimized for writes — they eliminate redundancy and enforce integrity through foreign keys and joins. But reads typically need to join many tables, sort large result sets, and aggregate data. These competing requirements create a constant tension: every index you add for reads slows down writes, and every normalization decision that helps writes hurts read performance.
CQRS (Command Query Responsibility Segregation) resolves this tension by completely separating the write model (Commands) from the read model (Queries). The write side uses a normalized, integrity-enforcing schema optimized for mutations. The read side uses denormalized projections — separate data stores shaped exactly for the queries the UI needs to answer.
Read Model Architecture
The read model is a projection — a representation of data derived from the write side. When a command succeeds and state changes, the write side publishes a domain event. A projection handler (also called a projector) subscribes to these events and updates the read model accordingly. The read model can be a separate database, a Redis hash, an Elasticsearch index, or even a materialized view in the same database.
Denormalization: Shaping Data for Reads
The power of CQRS read models comes from denormalization. Instead of making the UI join `orders`, `order_items`, `products`, and `customers` at query time, the projector assembles this data once — at write time — and stores the result as a single document per order. The query then becomes a single key lookup.
// Write side: normalized (source of truth)
// orders: { id, customer_id, status, created_at }
// order_items: { order_id, product_id, quantity, unit_price }
// customers: { id, name, email }
// products: { id, name, sku }
// Read model: denormalized document per order
interface OrderReadModel {
orderId: string;
status: string;
createdAt: string;
customer: {
id: string;
name: string;
email: string;
};
items: Array<{
productId: string;
productName: string;
sku: string;
quantity: number;
unitPrice: number;
lineTotal: number;
}>;
orderTotal: number;
}
// Projector: listens to domain events and builds read model
async function handleOrderPlaced(event: OrderPlacedEvent): Promise<void> {
const customer = await customerRepo.findById(event.customerId);
const items = await Promise.all(
event.items.map(async (item) => {
const product = await productRepo.findById(item.productId);
return {
productId: item.productId,
productName: product.name,
sku: product.sku,
quantity: item.quantity,
unitPrice: item.unitPrice,
lineTotal: item.quantity * item.unitPrice,
};
})
);
const orderDoc: OrderReadModel = {
orderId: event.orderId,
status: "placed",
createdAt: event.occurredAt,
customer: { id: customer.id, name: customer.name, email: customer.email },
items,
orderTotal: items.reduce((sum, i) => sum + i.lineTotal, 0),
};
await readStore.upsert("orders", event.orderId, orderDoc);
}Multiple Read Models from One Write Side
A single write store can project into multiple read models, each optimized for a different query pattern. An e-commerce system might have: an order detail model (by order ID), an orders-by-customer model (sorted by date), a product sales model (aggregated by product), and an Elasticsearch index (for full-text search). Each is kept up to date by subscribing to the same domain events.
Eventual Consistency and Lag
The fundamental trade-off of CQRS read models is eventual consistency. After a command succeeds, there is a propagation delay — typically milliseconds to seconds — before the read model reflects the change. This lag comes from event propagation, projection processing, and write-to-read-store latency.
Handling Read-After-Write
If a user places an order and immediately tries to view it, they might see stale data. Common mitigations: (1) Show optimistic UI state immediately based on the command payload. (2) Pass the command's event sequence number to the read API; the read side waits until it has processed up to that sequence number before returning. (3) For critical operations, read directly from the write model for a short window post-command.
Rebuilding Read Models
One of CQRS's superpowers is that read models are derivable — they can always be rebuilt by replaying events from the beginning. This means you can add a brand-new read model at any time (backfill by replaying history), fix a projection bug by deleting and rebuilding, and evolve the read schema independently of the write schema.
Interview Tip
When discussing CQRS read models in an interview, emphasize three things: (1) reads and writes scale independently — you can add read replicas without touching the write path; (2) read models are not a cache — they are the source of truth for reads, rebuilt from events, not from the write DB; (3) eventual consistency is the trade-off. Anticipate the follow-up: 'How do you handle read-after-write consistency?' and have a concrete answer ready.
| Aspect | Traditional (Shared Model) | CQRS (Separate Read Model) |
|---|---|---|
| Schema optimization | Compromise between reads and writes | Each side optimized independently |
| Query complexity | Joins at query time | Pre-joined documents, single lookup |
| Consistency | Strong | Eventual (seconds of lag) |
| Schema evolution | Write + read must migrate together | Read model rebuilt independently |
| Scalability | Scale the whole database | Read and write scale separately |