Skip to main content

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

  1. Separate Models: Write model for business logic, read models for queries
  2. Optimize Independently: Scale reads and writes based on actual needs
  3. Denormalize Reads: Read models can duplicate data for query performance
  4. Eventual Consistency: Accept and design for it in the UI/API
  5. Multiple Read Models: Create purpose-specific read models

When to Use CQRS

Use CQRS WhenAvoid CQRS When
Read/write workloads differ significantlySimple CRUD operations
Complex domain logic on write sideSmall team/simple domain
Need different data stores for reads/writesConsistency is critical
High scalability requirementsQuick prototyping
Event Sourcing is usedLimited infrastructure