The most surprising thing about CQRS read-side performance is that the database is often the least of your worries; it’s the projection logic and how you serve those projections that truly bottleneck.

Let’s see this in action. Imagine a typical e-commerce scenario. We have an Order aggregate that handles writes. On the read side, we want to show a list of "Recent Orders" for a customer, including product names, quantities, and total price.

Here’s a simplified command handler for placing an order:

// Command
public class PlaceOrderCommand : ICommand
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
    public decimal TotalAmount { get; set; }
}

// Event
public class OrderPlacedEvent : IEvent
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public List<OrderItemDto> Items { get; set; }
    public decimal TotalAmount { get; set; }
    public DateTime OrderDate { get; set; }
}

// Command Handler
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
    private readonly IEventStore _eventStore;

    public PlaceOrderCommandHandler(IEventStore eventStore)
    {
        _eventStore = eventStore;
    }

    public async Task Handle(PlaceOrderCommand command)
    {
        var order = new Order(command.OrderId, command.CustomerId, command.Items, command.TotalAmount);
        await _eventStore.AppendAsync(order);
    }
}

Now, the read side. We need a projection that listens to OrderPlacedEvent and updates a denormalized "CustomerOrderSummary" view.

// Projection Data Model
public class CustomerOrderSummary
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalAmount { get; set; }
    public string ProductNames { get; set; } // Comma-separated for simplicity
}

// Projection Handler
public class CustomerOrderSummaryProjection : IEventHandler<OrderPlacedEvent>
{
    private readonly IDocumentStore _documentStore; // e.g., RavenDB, MongoDB, or even SQL

    public CustomerOrderSummaryProjection(IDocumentStore documentStore)
    {
        _documentStore = documentStore;
    }

    public async Task Handle(OrderPlacedEvent @event)
    {
        var productNames = string.Join(", ", @event.Items.Select(i => i.ProductName));
        var summary = new CustomerOrderSummary
        {
            OrderId = @event.OrderId,
            CustomerId = @event.CustomerId,
            OrderDate = @event.OrderDate,
            TotalAmount = @event.TotalAmount,
            ProductNames = productNames
        };

        // Assuming _documentStore.SaveAsync persists to a document DB
        await _documentStore.SaveAsync(summary);
    }
}

When a customer requests their recent orders, we query this CustomerOrderSummary document.

// Query Handler
public class GetRecentOrdersQueryHandler : IQueryHandler<GetRecentOrdersQuery, List<CustomerOrderSummary>>
{
    private readonly IDocumentStore _documentStore;

    public GetRecentOrdersQueryHandler(IDocumentStore documentStore)
    {
        _documentStore = documentStore;
    }

    public async Task<List<CustomerOrderSummary>> Handle(GetRecentOrdersQuery query)
    {
        // Querying for a specific customer, ordered by date
        return await _documentStore.QueryAsync<CustomerOrderSummary>(
            q => q.Where(o => o.CustomerId == query.CustomerId)
                  .OrderByDescending(o => o.OrderDate)
                  .Take(10) // Get top 10
        );
    }
}

This setup works. But what happens when OrderPlacedEvent has hundreds of items, or the ProductNames string gets massive? The projection handler is doing string concatenation and then saving a potentially large document. The query handler then needs to fetch and deserialize this potentially large document. This is where performance issues creep in.

The core problem CQRS read-sides solve is data shape mismatch. The write model is optimized for consistency and domain logic (e.g., ensuring inventory is decremented correctly). The read model is optimized for specific query needs, often denormalized and flattened. Our CustomerOrderSummary is a denormalized projection. We’ve moved the work of joining Order and Product information from query time to event handling time.

The levers you control are:

  1. Projection Granularity: Should one event update one document, or potentially multiple? For OrderPlacedEvent, we update one CustomerOrderSummary. If we also needed a "ProductSalesSummary" view, that would be a separate projection handler listening to the same event.
  2. Data Structure in Read Model: How do you store the data? For ProductNames, storing them as a comma-separated string is simple but awful for querying or displaying individual names. A better read model might store a List<OrderItemSummaryDto> where OrderItemSummaryDto contains ProductId, ProductName, Quantity, Price.
  3. Eventual Consistency: The read model is eventually consistent. There’s a delay between an order being placed and the read model reflecting it. This delay is acceptable for many use cases but critical to understand.

The key to optimizing is often not optimizing the database query itself, but the projection logic and the data structure of the read model. If your projection handler is doing heavy computation (like complex string manipulation, or fetching external data to enrich the read model), that’s a bottleneck. If your read model documents are massive, that’s a bottleneck.

Consider the CustomerOrderSummary again. If we have a List<OrderItemSummaryDto> instead of string ProductNames, the projection handler becomes:

// Projection Data Model (Revised)
public class CustomerOrderSummary
{
    public Guid OrderId { get; set; }
    public Guid CustomerId { get; set; }
    public DateTime OrderDate { get; set; }
    public decimal TotalAmount { get; set; }
    public List<OrderItemSummaryDto> Items { get; set; } // Revised
}

public class OrderItemSummaryDto
{
    public Guid ProductId { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
}

// Projection Handler (Revised)
public async Task Handle(OrderPlacedEvent @event)
{
    var items = @event.Items.Select(i => new OrderItemSummaryDto
    {
        ProductId = i.ProductId,
        ProductName = i.ProductName,
        Quantity = i.Quantity,
        UnitPrice = i.UnitPrice
    }).ToList();

    var summary = new CustomerOrderSummary
    {
        OrderId = @event.OrderId,
        CustomerId = @event.CustomerId,
        OrderDate = @event.OrderDate,
        TotalAmount = @event.TotalAmount,
        Items = items // Use the list
    };
    await _documentStore.SaveAsync(summary);
}

This revised model is more flexible. The projection handler is still doing work, but the output is a more structured document. The query handler now returns a richer object.

Caching is paramount here. For frequently accessed read models, especially those that don’t change often or where a slight staleness is acceptable, caching can dramatically reduce load on your read database and improve response times. A Redis or Memcached layer can store entire query results or even individual read model documents. For example, caching the result of GetRecentOrdersQuery for a specific CustomerId for 5 minutes means that for 5 minutes, no database read occurs.

When you find yourself needing to aggregate data from multiple read models for a single query, you’re often better off creating a new, dedicated projection that pre-aggregates this information. For instance, if you need a "CustomerDashboard" view that shows recent orders and their latest product review, don’t query CustomerOrderSummary and CustomerReviewSummary and join them in your API. Instead, create a CustomerDashboardProjection that listens to both OrderPlacedEvent and ProductReviewAddedEvent and builds a single denormalized document for the dashboard.

The next step is handling complex queries and ensuring transactional integrity across multiple read models.

Want structured learning?

Take the full Cqrs course →