CQRS: Command Query Responsibility Segregation
CQRS separates read and write operations into different models, allowing each to be optimized independently. This pattern is particularly powerful when combined with Event Sourcing.
The Problem CQRS Solves
Traditional CRUD systems use a single model for both reads and writes, leading to:
- Complex models trying to serve both purposes
- Read performance issues with normalized data
- Write contention affecting read operations
- Difficulty scaling reads and writes independently
CQRS Architecture
Implementing CQRS
Command Side
// Commands represent intent to change state
public record CreateOrderCommand : ICommand<Guid>
{
public Guid CustomerId { get; init; }
public List<OrderItemDto> Items { get; init; }
public AddressDto ShippingAddress { get; init; }
}
// Command Handler
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand, Guid>
{
private readonly IOrderRepository _repository;
private readonly IEventPublisher _eventPublisher;
public async Task<Guid> HandleAsync(
CreateOrderCommand command,
CancellationToken ct)
{
// Validate business rules
await ValidateCustomerAsync(command.CustomerId, ct);
await ValidateProductsAsync(command.Items, ct);
// Create aggregate
var order = Order.Create(
command.CustomerId,
command.Items.Select(i => new OrderItem(
i.ProductId,
i.Quantity,
i.UnitPrice)).ToList(),
new Address(command.ShippingAddress));
// Persist
await _repository.SaveAsync(order, ct);
// Publish domain events for read model sync
await _eventPublisher.PublishAsync(order.DomainEvents, ct);
return order.Id;
}
}
// Write Model - Rich domain model
public class Order : AggregateRoot
{
public Guid Id { get; private set; }
public Guid CustomerId { get; private set; }
public OrderStatus Status { get; private set; }
private readonly List<OrderItem> _items = new();
public void AddItem(OrderItem item)
{
if (Status != OrderStatus.Draft)
throw new DomainException("Cannot modify submitted order");
_items.Add(item);
AddDomainEvent(new OrderItemAddedEvent(Id, item));
}
}
Query Side
// Queries are simple data requests
public record GetOrderQuery : IQuery<OrderDto>
{
public Guid OrderId { get; init; }
}
// Query Handler - Direct database access, no domain model
public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
private readonly IDbConnection _db;
public async Task<OrderDto> HandleAsync(
GetOrderQuery query,
CancellationToken ct)
{
// Direct SQL for optimal read performance
var order = await _db.QueryFirstOrDefaultAsync<OrderDto>(
@"SELECT
o.id AS OrderId,
o.customer_id AS CustomerId,
c.name AS CustomerName,
c.email AS CustomerEmail,
o.status AS Status,
o.total_amount AS TotalAmount
FROM orders_read_model o
JOIN customers_read_model c ON o.customer_id = c.id
WHERE o.id = @OrderId",
new { query.OrderId });
return order;
}
}
// Read Model - Optimized for queries, denormalized
public class OrderDto
{
public Guid OrderId { get; set; }
public string CustomerName { get; set; } // Denormalized
public string CustomerEmail { get; set; } // Denormalized
public decimal TotalAmount { get; set; }
public List<OrderItemDto> Items { get; set; }
}
Read Model Synchronization
// Projections update read models from events
public class OrderProjection :
IEventHandler<OrderCreatedEvent>,
IEventHandler<OrderItemAddedEvent>,
IEventHandler<OrderShippedEvent>
{
private readonly IDbConnection _db;
private readonly ICustomerQueryService _customerQuery;
public async Task HandleAsync(OrderCreatedEvent @event, CancellationToken ct)
{
// Fetch denormalized data
var customer = await _customerQuery.GetCustomerAsync(@event.CustomerId, ct);
await _db.ExecuteAsync(
@"INSERT INTO orders_read_model
(id, customer_id, customer_name, customer_email, status, total_amount)
VALUES (@Id, @CustomerId, @CustomerName, @CustomerEmail, @Status, @Total)",
new
{
@event.OrderId,
@event.CustomerId,
CustomerName = customer.Name,
CustomerEmail = customer.Email,
Status = "Draft",
Total = 0m
});
}
public async Task HandleAsync(OrderShippedEvent @event, CancellationToken ct)
{
await _db.ExecuteAsync(
@"UPDATE orders_read_model
SET status = 'Shipped', shipped_at = @ShippedAt
WHERE id = @OrderId",
new { @event.OrderId, @event.ShippedAt });
}
}
Multiple Read Models
// Different read models for different use cases
// Dashboard metrics
public class OrderDashboardProjection : IEventHandler<OrderSubmittedEvent>
{
public async Task HandleAsync(OrderSubmittedEvent @event, CancellationToken ct)
{
await _db.ExecuteAsync(
@"INSERT INTO daily_order_stats (date, order_count, total_revenue)
VALUES (@Date, 1, @Amount)
ON CONFLICT (date) DO UPDATE
SET order_count = daily_order_stats.order_count + 1,
total_revenue = daily_order_stats.total_revenue + @Amount",
new { Date = @event.SubmittedAt.Date, Amount = @event.TotalAmount });
}
}
// Search index
public class SearchIndexProjection : IEventHandler<OrderSubmittedEvent>
{
private readonly ISearchClient _searchClient;
public async Task HandleAsync(OrderSubmittedEvent @event, CancellationToken ct)
{
await _searchClient.IndexAsync(new OrderSearchDocument
{
Id = @event.OrderId.ToString(),
CustomerName = @event.CustomerName,
Status = "Submitted",
TotalAmount = @event.TotalAmount
}, ct);
}
}
Handling Eventual Consistency
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateOrder(CreateOrderRequest request)
{
var orderId = await _mediator.Send(new CreateOrderCommand { ... });
// Return 202 Accepted - order is being processed
return AcceptedAtAction(
nameof(GetOrder),
new { orderId },
new { orderId, status = "Processing" });
}
[HttpGet("{orderId}")]
public async Task<IActionResult> GetOrder(Guid orderId)
{
try
{
var order = await _mediator.Send(new GetOrderQuery { OrderId = orderId });
return Ok(order);
}
catch (NotFoundException)
{
// Might be eventual consistency delay
Response.Headers.Add("Retry-After", "1");
return StatusCode(202, new { message = "Order is being processed" });
}
}
}
Key Takeaways
- Separate Models: Write model for business logic, read models for queries
- Optimize Independently: Scale reads and writes based on actual needs
- Denormalize Reads: Read models can duplicate data for query performance
- Eventual Consistency: Accept and design for it in the UI/API
- Multiple Read Models: Create purpose-specific read models
When to Use CQRS
| Use CQRS When | Avoid CQRS When |
|---|---|
| Read/write workloads differ significantly | Simple CRUD operations |
| Complex domain logic on write side | Small team/simple domain |
| Need different data stores for reads/writes | Consistency is critical |
| High scalability requirements | Quick prototyping |
| Event Sourcing is used | Limited infrastructure |