CQRS projections can actually be more reliable than traditional denormalized views because their eventual consistency is a feature, not a bug.

Let’s see this in action. Imagine we have an e-commerce system. Our events might look like this:

{ "eventType": "OrderPlaced", "orderId": "abc", "customerId": "123", "items": [...], "timestamp": "2023-10-27T10:00:00Z" }
{ "eventType": "OrderItemAdded", "orderId": "abc", "productId": "xyz", "quantity": 2, "price": 10.00, "timestamp": "2023-10-27T10:01:00Z" }
{ "eventType": "OrderShipped", "orderId": "abc", "trackingNumber": "TRK123", "timestamp": "2023-10-27T11:00:00Z" }

We want a projection for displaying order summaries to customers. This projection will live in a read-optimized database, say PostgreSQL.

CREATE TABLE order_summaries (
    order_id VARCHAR PRIMARY KEY,
    customer_id VARCHAR NOT NULL,
    status VARCHAR NOT NULL,
    total_items INT NOT NULL DEFAULT 0,
    total_amount DECIMAL(10, 2) NOT NULL DEFAULT 0.00,
    shipped_at TIMESTAMP NULL
);

A projection service, running independently, listens to our event stream (e.g., Kafka, Azure Event Hubs). When it receives an OrderPlaced event, it inserts a new row:

-- On OrderPlaced event for order 'abc', customer '123'
INSERT INTO order_summaries (order_id, customer_id, status)
VALUES ('abc', '123', 'PLACED');

When an OrderItemAdded event comes in for order abc, it updates the total_items and total_amount:

-- On OrderItemAdded event for order 'abc', product 'xyz', quantity 2, price 10.00
UPDATE order_summaries
SET total_items = total_items + 2,
    total_amount = total_amount + (2 * 10.00)
WHERE order_id = 'abc';

And for OrderShipped:

-- On OrderShipped event for order 'abc'
UPDATE order_summaries
SET status = 'SHIPPED',
    shipped_at = '2023-10-27T11:00:00Z'
WHERE order_id = 'abc';

This projection service is a separate process. It doesn’t know or care if the order_summaries table is temporarily unavailable; it just keeps processing events. When the database is back online, it will pick up where it left off, ensuring the projection eventually reflects all events. This isolation is key.

The problem this solves is the impedance mismatch between complex, state-changing write operations and fast, simple read operations. By separating them, the write side can be optimized for domain logic and event sourcing, while the read side can be a highly tuned SQL table, a search index, or even a graph database, tailored precisely to query needs. Your projection logic is derived directly from the immutable source of truth: the event stream.

Internally, the projection service typically uses a persistent cursor or offset to track its progress through the event stream. When it processes an event, it records the stream position after the update. If the service restarts, it reads the last recorded position and resumes from there. This is how it guarantees it won’t miss events. The processing of events is usually idempotent – applying the same event multiple times has no further effect, which is crucial if an event is delivered more than once.

The one thing most people don’t realize is that the projection logic itself is also an event handler. It’s just a very specific type of handler whose sole purpose is to transform the event stream into a queryable data model. Think of it as a materialized view built by code, where the "materialization" process is triggered by every single event in your system. This means you can have multiple, independent projection services reading the same event stream to build entirely different read models for different use cases, without impacting each other or the write side.

The next concept you’ll grapple with is handling schema evolution in your events and how that impacts your existing projections.

Want structured learning?

Take the full Cqrs course →