How do you implement the CQRS (Command Query Responsibility Segregation) pattern?

9 minadvancedCQRSarchitectureDDDMediatR

Quick Answer

CQRS (Command Query Responsibility Segregation) separates writes (commands that change state) from reads (queries that return data), using distinct models for each so they can be optimized independently. In .NET it's often implemented with a mediator (MediatR) dispatching command/query handlers, and can be taken further with separate read/write data stores. It shines in complex domains and high-read/write asymmetry, but adds complexity, so it's not warranted for simple CRUD.

Detailed Answer

CQRS (Command Query Responsibility Segregation) is a pattern that separates read and write operations by using different models for commands (writes) and queries (reads). This allows each side to be optimized independently.

Core Concepts:

  1. Commands: Operations that change state (writes)
  2. Queries: Operations that read data (reads)
  3. Command Handlers: Process commands and update the domain
  4. Query Handlers: Process queries and return data
  5. Separate Models: Different models for commands and queries

Basic CQRS Implementation:

// Command and Query base classes
public interface ICommand
{
}

public interface ICommandHandler<in TCommand> where TCommand : ICommand
{
    Task Handle(TCommand command, CancellationToken cancellationToken = default);
}

public interface IQuery<TResult>
{
}

public interface IQueryHandler<in TQuery, TResult> where TQuery : IQuery<TResult>
{
    Task<TResult> Handle(TQuery query, CancellationToken cancellationToken = default);
}

// Mediator interface
public interface IMediator
{
    Task<TResult> Send<TResult>(IQuery<TResult> query, CancellationToken cancellationToken = default);
    Task Send(ICommand command, CancellationToken cancellationToken = default);
}

Command Implementation:

// Commands
public record CreateOrderCommand(CustomerId CustomerId, List<OrderItemDto> Items) : ICommand;

public record ConfirmOrderCommand(OrderId OrderId) : ICommand;

public record CancelOrderCommand(OrderId OrderId, string Reason) : ICommand;

public record AddOrderItemCommand(OrderId OrderId, ProductId ProductId, Quantity Quantity, Money UnitPrice) : ICommand;

// Command Handlers
public class CreateOrderCommandHandler : ICommandHandler<CreateOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IDomainEventDispatcher _eventDispatcher;
    
    public async Task Handle(CreateOrderCommand command, CancellationToken cancellationToken = default)
    {
        var order = new Order(new OrderId(Guid.NewGuid()), command.CustomerId);
        
        foreach (var item in command.Items)
        {
            order.AddItem(
                new ProductId(item.ProductId),
                new Quantity(item.Quantity),
                new Money(item.UnitPrice, "USD")
            );
        }
        
        await _orderRepository.SaveAsync(order);
        await _unitOfWork.CommitAsync();
        
        // Dispatch domain events
        await _eventDispatcher.DispatchAsync(order.DomainEvents);
        order.ClearDomainEvents();
    }
}

public class ConfirmOrderCommandHandler : ICommandHandler<ConfirmOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IDomainEventDispatcher _eventDispatcher;
    
    public async Task Handle(ConfirmOrderCommand command, CancellationToken cancellationToken = default)
    {
        var order = await _orderRepository.GetByIdAsync(command.OrderId);
        if (order == null)
            throw new OrderNotFoundException(command.OrderId);
            
        order.Confirm();
        
        await _orderRepository.SaveAsync(order);
        await _unitOfWork.CommitAsync();
        
        // Dispatch domain events
        await _eventDispatcher.DispatchAsync(order.DomainEvents);
        order.ClearDomainEvents();
    }
}

public class CancelOrderCommandHandler : ICommandHandler<CancelOrderCommand>
{
    private readonly IOrderRepository _orderRepository;
    private readonly IUnitOfWork _unitOfWork;
    private readonly IDomainEventDispatcher _eventDispatcher;
    
    public async Task Handle(CancelOrderCommand command, CancellationToken cancellationToken = default)
    {
        var order = await _orderRepository.GetByIdAsync(command.OrderId);
        if (order == null)
            throw new OrderNotFoundException(command.OrderId);
            
        order.Cancel(command.Reason);
        
        await _orderRepository.SaveAsync(order);
        await _unitOfWork.CommitAsync();
        
        // Dispatch domain events
        await _eventDispatcher.DispatchAsync(order.DomainEvents);
        order.ClearDomainEvents();
    }
}

Query Implementation:

// Queries
public record GetOrderQuery(OrderId OrderId) : IQuery<OrderDto>;

public record GetOrdersByCustomerQuery(CustomerId CustomerId) : IQuery<List<OrderSummaryDto>>;

public record GetOrderHistoryQuery(OrderId OrderId) : IQuery<List<OrderHistoryDto>>;

public record SearchOrdersQuery(string SearchTerm, int Page, int PageSize) : IQuery<SearchOrdersResult>;

// Query DTOs
public class OrderDto
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string Status { get; set; }
    public decimal Total { get; set; }
    public string Currency { get; set; }
    public DateTime CreatedAt { get; set; }
    public List<OrderItemDto> Items { get; set; }
}

public class OrderSummaryDto
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string Status { get; set; }
    public decimal Total { get; set; }
    public DateTime CreatedAt { get; set; }
}

public class OrderHistoryDto
{
    public DateTime Timestamp { get; set; }
    public string Action { get; set; }
    public string Description { get; set; }
}

public class SearchOrdersResult
{
    public List<OrderSummaryDto> Orders { get; set; }
    public int TotalCount { get; set; }
    public int Page { get; set; }
    public int PageSize { get; set; }
}

// Query Handlers
public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
    private readonly IOrderRepository _orderRepository;
    
    public async Task<OrderDto> Handle(GetOrderQuery query, CancellationToken cancellationToken = default)
    {
        var order = await _orderRepository.GetByIdAsync(query.OrderId);
        if (order == null)
            throw new OrderNotFoundException(query.OrderId);
            
        return new OrderDto
        {
            Id = order.Id.Value,
            CustomerId = order.CustomerId.Value,
            Status = order.Status.ToString(),
            Total = order.Total.Amount,
            Currency = order.Total.Currency,
            CreatedAt = order.CreatedAt,
            Items = order.Items.Select(item => new OrderItemDto
            {
                ProductId = item.ProductId.Value,
                Quantity = item.Quantity.Value,
                UnitPrice = item.UnitPrice.Amount
            }).ToList()
        };
    }
}

public class GetOrdersByCustomerQueryHandler : IQueryHandler<GetOrdersByCustomerQuery, List<OrderSummaryDto>>
{
    private readonly IOrderReadRepository _orderReadRepository;
    
    public async Task<List<OrderSummaryDto>> Handle(GetOrdersByCustomerQuery query, CancellationToken cancellationToken = default)
    {
        var orders = await _orderReadRepository.GetByCustomerIdAsync(query.CustomerId);
        
        return orders.Select(order => new OrderSummaryDto
        {
            Id = order.Id,
            CustomerId = order.CustomerId,
            Status = order.Status,
            Total = order.Total,
            CreatedAt = order.CreatedAt
        }).ToList();
    }
}

public class SearchOrdersQueryHandler : IQueryHandler<SearchOrdersQuery, SearchOrdersResult>
{
    private readonly IOrderReadRepository _orderReadRepository;
    
    public async Task<SearchOrdersResult> Handle(SearchOrdersQuery query, CancellationToken cancellationToken = default)
    {
        var result = await _orderReadRepository.SearchAsync(query.SearchTerm, query.Page, query.PageSize);
        
        return new SearchOrdersResult
        {
            Orders = result.Orders.Select(order => new OrderSummaryDto
            {
                Id = order.Id,
                CustomerId = order.CustomerId,
                Status = order.Status,
                Total = order.Total,
                CreatedAt = order.CreatedAt
            }).ToList(),
            TotalCount = result.TotalCount,
            Page = query.Page,
            PageSize = query.PageSize
        };
    }
}

Separate Read and Write Models:

// Write Model (Domain)
public class Order : AggregateRoot<OrderId>
{
    public OrderId Id { get; private set; }
    public CustomerId CustomerId { get; private set; }
    public OrderStatus Status { get; private set; }
    public Money Total { get; private set; }
    public DateTime CreatedAt { get; private set; }
    
    private readonly List<OrderItem> _items = new();
    public IReadOnlyList<OrderItem> Items => _items.AsReadOnly();
    
    // Business logic and invariants
    public void AddItem(ProductId productId, Quantity quantity, Money unitPrice)
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Cannot add items to a confirmed order");
            
        // Business logic...
    }
    
    public void Confirm()
    {
        if (Status != OrderStatus.Draft)
            throw new InvalidOperationException("Order is not in draft status");
            
        if (_items.Count == 0)
            throw new InvalidOperationException("Cannot confirm an empty order");
            
        Status = OrderStatus.Confirmed;
        AddDomainEvent(new OrderConfirmedEvent(Id, CustomerId, Total));
    }
}

// Read Model (Optimized for queries)
public class OrderReadModel
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public string CustomerName { get; set; }
    public string Status { get; set; }
    public decimal Total { get; set; }
    public string Currency { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? ConfirmedAt { get; set; }
    public DateTime? ShippedAt { get; set; }
    public string ShippingAddress { get; set; }
    public List<OrderItemReadModel> Items { get; set; }
}

public class OrderItemReadModel
{
    public int ProductId { get; set; }
    public string ProductName { get; set; }
    public int Quantity { get; set; }
    public decimal UnitPrice { get; set; }
    public decimal Total { get; set; }
}

// Read Repository
public interface IOrderReadRepository
{
    Task<OrderReadModel> GetByIdAsync(int orderId);
    Task<List<OrderReadModel>> GetByCustomerIdAsync(int customerId);
    Task<SearchResult<OrderReadModel>> SearchAsync(string searchTerm, int page, int pageSize);
}

public class OrderReadRepository : IOrderReadRepository
{
    private readonly ReadDbContext _context;
    
    public async Task<OrderReadModel> GetByIdAsync(int orderId)
    {
        return await _context.Orders
            .Include(o => o.Items)
            .Where(o => o.Id == orderId)
            .Select(o => new OrderReadModel
            {
                Id = o.Id,
                CustomerId = o.CustomerId,
                CustomerName = o.CustomerName,
                Status = o.Status,
                Total = o.Total,
                Currency = o.Currency,
                CreatedAt = o.CreatedAt,
                ConfirmedAt = o.ConfirmedAt,
                ShippedAt = o.ShippedAt,
                ShippingAddress = o.ShippingAddress,
                Items = o.Items.Select(item => new OrderItemReadModel
                {
                    ProductId = item.ProductId,
                    ProductName = item.ProductName,
                    Quantity = item.Quantity,
                    UnitPrice = item.UnitPrice,
                    Total = item.Total
                }).ToList()
            })
            .FirstOrDefaultAsync();
    }
    
    public async Task<List<OrderReadModel>> GetByCustomerIdAsync(int customerId)
    {
        return await _context.Orders
            .Where(o => o.CustomerId == customerId)
            .OrderByDescending(o => o.CreatedAt)
            .Select(o => new OrderReadModel
            {
                Id = o.Id,
                CustomerId = o.CustomerId,
                CustomerName = o.CustomerName,
                Status = o.Status,
                Total = o.Total,
                Currency = o.Currency,
                CreatedAt = o.CreatedAt,
                ConfirmedAt = o.ConfirmedAt,
                ShippedAt = o.ShippedAt
            })
            .ToListAsync();
    }
}

Event-Driven Updates to Read Model:

// Event handler to update read model
public class OrderConfirmedEventHandler : IDomainEventHandler<OrderConfirmedEvent>
{
    private readonly IOrderReadRepository _orderReadRepository;
    
    public async Task Handle(OrderConfirmedEvent domainEvent, CancellationToken cancellationToken = default)
    {
        // Update read model when domain event occurs
        await _orderReadRepository.UpdateOrderStatusAsync(
            domainEvent.OrderId.Value, 
            "Confirmed", 
            DateTime.UtcNow);
    }
}

public class OrderCancelledEventHandler : IDomainEventHandler<OrderCancelledEvent>
{
    private readonly IOrderReadRepository _orderReadRepository;
    
    public async Task Handle(OrderCancelledEvent domainEvent, CancellationToken cancellationToken = default)
    {
        await _orderReadRepository.UpdateOrderStatusAsync(
            domainEvent.OrderId.Value, 
            "Cancelled", 
            DateTime.UtcNow);
    }
}

API Controller Usage:

[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly IMediator _mediator;
    
    [HttpPost]
    public async Task<ActionResult<OrderId>> CreateOrder([FromBody] CreateOrderCommand command)
    {
        await _mediator.Send(command);
        return Ok();
    }
    
    [HttpPost("{id}/confirm")]
    public async Task<ActionResult> ConfirmOrder(int id)
    {
        var command = new ConfirmOrderCommand(new OrderId(id));
        await _mediator.Send(command);
        return Ok();
    }
    
    [HttpPost("{id}/cancel")]
    public async Task<ActionResult> CancelOrder(int id, [FromBody] CancelOrderRequest request)
    {
        var command = new CancelOrderCommand(new OrderId(id), request.Reason);
        await _mediator.Send(command);
        return Ok();
    }
    
    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(int id)
    {
        var query = new GetOrderQuery(new OrderId(id));
        var order = await _mediator.Send(query);
        return Ok(order);
    }
    
    [HttpGet("customer/{customerId}")]
    public async Task<ActionResult<List<OrderSummaryDto>>> GetCustomerOrders(int customerId)
    {
        var query = new GetOrdersByCustomerQuery(new CustomerId(customerId));
        var orders = await _mediator.Send(query);
        return Ok(orders);
    }
    
    [HttpGet("search")]
    public async Task<ActionResult<SearchOrdersResult>> SearchOrders(
        [FromQuery] string searchTerm, 
        [FromQuery] int page = 1, 
        [FromQuery] int pageSize = 10)
    {
        var query = new SearchOrdersQuery(searchTerm, page, pageSize);
        var result = await _mediator.Send(query);
        return Ok(result);
    }
}

Benefits of CQRS:

  1. Separation of Concerns: Commands and queries are handled separately
  2. Optimization: Each side can be optimized independently
  3. Scalability: Read and write operations can be scaled separately
  4. Flexibility: Different models for different use cases
  5. Performance: Read models can be denormalized for better query performance
  6. Maintainability: Clear separation makes the code easier to understand and maintain

When to Use CQRS:

  • Complex domains with different read and write requirements
  • High-performance applications with many reads
  • Systems where read and write models differ significantly
  • Applications that need to scale reads and writes independently
  • Systems with complex reporting requirements

Best Practices:

  1. Start Simple: Begin with a single model and separate when needed
  2. Event-Driven Updates: Use domain events to keep read models in sync
  3. Eventual Consistency: Accept that read models might be slightly behind
  4. Proper Error Handling: Handle failures in command and query processing
  5. Testing: Test commands and queries separately
  6. Documentation: Document the separation and synchronization strategy

CQRS is a powerful pattern that can significantly improve the performance and maintainability of complex applications when applied appropriately.

Related Resources