CQRS separates reads and writes, but most people miss that your "read models" aren’t just denormalized tables; they’re state machines that only care about events.

Let’s see this in action. Imagine we’re building an order management system. We have a OrderCreated event and an OrderShipped event. Our read model for orders will listen to these.

// Event Stream
[
  { "type": "OrderCreated", "payload": { "orderId": "123", "customerId": "abc", "items": [...], "timestamp": "..." } },
  { "type": "OrderShipped", "payload": { "orderId": "123", "shippingDate": "...", "trackingNumber": "..." } }
]

Our read model handler, let’s call it OrderReadModelHandler, receives these events.

public class OrderReadModelHandler : IEventHandler<OrderCreated>, IEventHandler<OrderShipped>
{
    private readonly IOrderRepository _orderRepository; // For writing to the read model DB

    public OrderReadModelHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public async Task HandleAsync(OrderCreated @event)
    {
        var orderDto = new OrderDto
        {
            OrderId = @event.Payload.OrderId,
            CustomerId = @event.Payload.CustomerId,
            Items = @event.Payload.Items.Select(i => new OrderItemDto { ProductId = i.ProductId, Quantity = i.Quantity }).ToList(),
            Status = "Created", // Initial state
            CreatedAt = @event.Payload.Timestamp
        };
        await _orderRepository.SaveAsync(orderDto);
    }

    public async Task HandleAsync(OrderShipped @event)
    {
        var orderDto = await _orderRepository.GetByIdAsync(@event.Payload.OrderId);
        if (orderDto != null)
        {
            orderDto.Status = "Shipped";
            orderDto.ShippedAt = @event.Payload.ShippingDate;
            orderDto.TrackingNumber = @event.Payload.TrackingNumber;
            await _orderRepository.UpdateAsync(orderDto); // Or SaveAsync if your repo handles upserts
        }
    }
}

Notice how OrderShipped doesn’t need to know about the original OrderCreated payload. It only needs the orderId to find the existing read model entry and update its state. This is the core idea: each event handler for a read model is a state machine that transitions the read model from one state to another based on incoming events.

The problem this solves is the impedance mismatch between your transactional write model (often normalized, complex, and slow for reads) and your reporting/querying needs (which demand fast, denormalized, specific views). CQRS allows you to build exactly the read models you need for your UI or other services, optimized for querying.

Internally, the system works like this:

  1. Command Handling: A command arrives (e.g., CreateOrderCommand). A command handler validates it, performs domain logic, and emits one or more domain events (e.g., OrderCreated).
  2. Event Publishing: These domain events are published to an event bus or message broker.
  3. Event Subscription: Read model handlers (or "projectors") subscribe to these events.
  4. Read Model Projection: When an event arrives, the handler updates the corresponding read model(s) in a separate data store (e.g., a document database, a relational DB optimized for reads, or even a search index).
  5. Querying: Your application queries these read models directly, bypassing the write-side complexity.

The exact levers you control are:

  • Event Design: What information is captured in each event? This directly impacts what your read models can know.
  • Read Model Schema: What shape does your denormalized data take? This is optimized for specific queries.
  • Event Handlers (Projectors): The logic that transforms incoming events into read model updates.
  • Query Handlers: The specific code that retrieves data from your read models, often returning Data Transfer Objects (DTOs).

Your DTOs are just the shape of the data in your read models, designed to be consumed by clients. They are the output of your query handlers and the shape of your read model entities.

The most surprising thing is how resilient this architecture can be to changes in the write model. If you refactor your aggregate root or change how commands are processed, as long as you can still produce the same sequence of events, your read models can remain untouched. You can even add new read models by simply subscribing existing event streams to new handlers, without touching any existing code.

The next concept to grapple with is how to handle eventual consistency, especially when reads need to be eventually consistent with writes.

Want structured learning?

Take the full Cqrs course →