CQRS isn’t just about separating reads from writes; it’s about acknowledging that different operations have fundamentally different needs in terms of performance, consistency, and complexity.
Let’s see this in action. Imagine a simple e-commerce system.
// Command: Request to create a new order
public record CreateOrderCommand(Guid UserId, List<OrderItemDto> Items) : IRequest<Guid>;
// Query: Request to get an order by its ID
public record GetOrderByIdQuery(Guid OrderId) : IRequest<OrderDetailsDto?>;
// Handler for the command
public class CreateOrderCommandHandler : IRequestHandler<CreateOrderCommand, Guid>
{
// Imagine this talks to a write-optimized database or service
private readonly IOrderWriteService _orderWriteService;
public CreateOrderCommandHandler(IOrderWriteService orderWriteService)
{
_orderWriteService = orderWriteService;
}
public async Task<Guid> Handle(CreateOrderCommand request, CancellationToken cancellationToken)
{
var order = new Order(request.UserId, request.Items.Select(i => new OrderItem(i.ProductId, i.Quantity)).ToList());
await _orderWriteService.SaveAsync(order);
return order.Id;
}
}
// Handler for the query
public class GetOrderByIdQueryHandler : IRequestHandler<GetOrderByIdQuery, OrderDetailsDto?>
{
// Imagine this talks to a read-optimized data store (e.g., denormalized view)
private readonly IOrderReadRepository _orderReadRepository;
public GetOrderByIdQueryHandler(IOrderReadRepository orderReadRepository)
{
_orderReadRepository = orderReadRepository;
}
public async Task<OrderDetailsDto?> Handle(GetOrderByIdQuery request, CancellationToken cancellationToken)
{
var order = await _orderReadRepository.GetByIdAsync(request.OrderId);
if (order == null) return null;
// Map to a DTO optimized for reading
return new OrderDetailsDto
{
Id = order.Id,
UserId = order.UserId,
TotalAmount = CalculateTotal(order.Items),
Status = order.Status.ToString()
// Potentially include related data here that isn't needed for writes
};
}
private decimal CalculateTotal(List<OrderItem> items) => items.Sum(i => i.Quantity * i.UnitPrice); // Simplified
}
// DTOs
public record OrderItemDto(Guid ProductId, int Quantity);
public record OrderDetailsDto
{
public Guid Id { get; set; }
public Guid UserId { get; set; }
public decimal TotalAmount { get; set; }
public string Status { get; set; }
}
This setup, using MediatR, allows us to send CreateOrderCommand objects to their respective handlers and GetOrderByIdQuery objects to theirs. The key insight is that the CreateOrderCommand handler might interact with a transactional database optimized for writes, while the GetOrderByIdQuery handler could fetch data from a denormalized read model, perhaps even a different database altogether, optimized for fast retrieval. This separation allows us to scale read and write operations independently. A busy API endpoint that’s constantly fetching order details can be scaled up without impacting the performance of order creation, and vice-versa.
The mental model for CQRS is a fork in the road: one path for commands (actions that change state) and another for queries (requests for information). Commands are typically imperative: "Create this order." Queries are declarative: "Give me the details of order X." MediatR acts as the dispatcher, ensuring the correct handler is invoked based on the message type. This allows for distinct models and strategies for each path. The write model can be highly normalized and transactional, focusing on integrity. The read model can be denormalized, optimized for specific query patterns, and potentially eventually consistent with the write model. This is where you can introduce eventual consistency patterns, like using a message queue to update read models after a write operation completes.
Most people think of CQRS as just two separate methods, but the real power comes from the fact that the read model can be designed specifically for the queries it serves. This means you can pre-join data, pre-calculate aggregates, and structure your read data in a way that makes common queries lightning fast, even if it means that data isn’t in a strictly normalized form. For instance, a product listing page might require the product name, current price, and a thumbnail image URL. The read model for this query could be a single table or document containing exactly these fields, denormalized from separate product, pricing, and image tables. The command side, however, would still deal with those separate entities to maintain data integrity during updates.
The next step after implementing basic CQRS is often to explore event sourcing, where state changes are stored as a sequence of immutable events rather than just the current state.