CQRS query handlers are where you transform raw domain data into precisely what your UI needs, but most people build them like they’re still in a monolith.
Let’s look at a simple Order aggregate and how we might query for a list of orders.
// Domain
public class Order
{
public Guid Id { get; private set; }
public CustomerId CustomerId { get; private set; }
public DateTime OrderDate { get; private set; }
public decimal TotalAmount { get; private set; }
public OrderStatus Status { get; private set; }
// ... other state and methods
}
// Read Model
public class OrderSummary
{
public Guid Id { get; set; }
public string CustomerName { get; set; } // We'll need to join this
public DateTime OrderDate { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; }
}
// Query Handler
public class GetOrderSummariesQueryHandler : IQueryHandler<GetOrderSummariesQuery, List<OrderSummary>>
{
private readonly IDomainRepository _domainRepository;
private readonly ICustomerReadRepository _customerReadRepository; // For customer names
public GetOrderSummariesQueryHandler(IDomainRepository domainRepository, ICustomerReadRepository customerReadRepository)
{
_domainRepository = domainRepository;
_customerReadRepository = customerReadRepository;
}
public async Task<List<OrderSummary>> Handle(GetOrderSummariesQuery query)
{
// Imagine this fetches *all* orders and their relevant state
var orders = await _domainRepository.GetOrdersByCustomerId(query.CustomerId);
var summaries = new List<OrderSummary>();
foreach (var order in orders)
{
var customerName = await _customerReadRepository.GetCustomerName(order.CustomerId);
summaries.Add(new OrderSummary
{
Id = order.Id,
CustomerName = customerName,
OrderDate = order.OrderDate,
TotalAmount = order.TotalAmount,
Status = order.Status.ToString()
});
}
return summaries;
}
}
This is a common starting point. You’re fetching aggregate roots from your domain store and then enriching them with data from another read repository. The problem is, the _domainRepository.GetOrdersByCustomerId might be fetching far more data than OrderSummary actually needs. It might be loading child entities, audit trails, or other state that’s irrelevant for this specific view.
The core idea behind optimized read models is to treat your read side as a distinct data store optimized for querying, not just a cache of your domain. Instead of fetching Order aggregates and then transforming them, you fetch directly into your OrderSummary shape.
This means your read model handlers should ideally interact with a data store that already has the data shaped correctly.
// Optimized Read Model Handler
public class GetOrderSummariesOptimizedHandler : IQueryHandler<GetOrderSummariesQuery, List<OrderSummary>>
{
private readonly IOrderReadRepository _orderReadRepository; // This repository queries a dedicated read store
public GetOrderSummariesOptimizedHandler(IOrderReadRepository orderReadRepository)
{
_orderReadRepository = orderReadRepository;
}
public async Task<List<OrderSummary>> Handle(GetOrderSummariesQuery query)
{
// This repository directly queries a table/collection optimized for OrderSummary
// It might perform joins or lookups internally, but the handler doesn't need to know.
return await _orderReadRepository.GetOrderSummaries(query.CustomerId);
}
}
// Example of what IOrderReadRepository might do internally (e.g., using Dapper or an ORM)
public class OrderReadRepository : IOrderReadRepository
{
private readonly IDbConnection _dbConnection; // Assuming SQL
public OrderReadRepository(IDbConnection dbConnection)
{
_dbConnection = dbConnection;
}
public async Task<List<OrderSummary>> GetOrderSummaries(Guid customerId)
{
// This query fetches only the necessary columns and performs the join
// on the database server, returning exactly what OrderSummary needs.
const string sql = @"
SELECT
o.Id,
c.Name AS CustomerName,
o.OrderDate,
o.TotalAmount,
o.Status
FROM Orders o
JOIN Customers c ON o.CustomerId = c.Id
WHERE o.CustomerId = @CustomerId";
return (await _dbConnection.QueryAsync<OrderSummary>(sql, new { CustomerId = customerId })).ToList();
}
}
Notice how the GetOrderSummariesOptimizedHandler doesn’t know about IDomainRepository or ICustomerReadRepository. It delegates the entire responsibility of fetching and shaping the data to IOrderReadRepository. This repository then talks to a data store (like a SQL database, a document database, or even an in-memory cache) that has a table or collection specifically designed for OrderSummary objects. This often involves pre-joined data or denormalized fields.
The key is that the data for OrderSummary is materialized somewhere else, typically by a separate process (an event handler updating a read model database) when domain events occur. The query handler’s job is simply to retrieve that pre-shaped data as efficiently as possible.
The most surprising true thing about optimized read models is that they often involve more data duplication and more complex data pipelines than a traditional monolith, but the query performance gains are massive because each read model is a purpose-built data structure.
The next logical step is to consider how to handle complex filtering and sorting directly within these optimized read repositories, potentially leveraging database-specific features.