CQRS Deep Dive: Separating Reads and Writes
CQRS (Command Query Responsibility Segregation) separates read and write operations into different models. It's powerful but adds complexity - knowing when to use it is key.
TL;DR
| Concept | Definition |
|---|---|
| Command | Changes state, returns nothing (or just ID) |
| Query | Returns data, changes nothing |
| CQRS | Different models for commands and queries |
| When | Read/write patterns differ significantly |
The Spectrum
Simple CRUD ←────────────────────────────→ Full CQRS + Event Sourcing
┌─────────┐ ┌─────────────┐ ┌────────────┐ ┌──────────────────┐
│ Single │ │ Separate │ │ Different │ │ Event Sourcing │
│ Model │ │ Read/Write │ │ Databases │ │ + Projections │
│ │ │ Services │ │ │ │ │
└─────────┘ └─────────────┘ └────────────┘ └──────────────────┘
│ │ │ │
│ │ │ │
Simple Light Full Maximum
CRUD CQRS CQRS Complexity
Level 1: Separate Services (Light CQRS)
Same database, different code paths:
// Commands go through domain model
public class CreateOrderHandler : ICommandHandler<CreateOrderCommand, OrderId>
{
private readonly IOrderRepository _repository;
public async Task<OrderId> Handle(CreateOrderCommand command)
{
var order = Order.Create(command.CustomerId, command.Items);
order.Validate();
await _repository.Save(order);
return order.Id;
}
}
// Queries bypass domain model
public class GetOrderHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
private readonly IDbConnection _connection;
public async Task<OrderDto> Handle(GetOrderQuery query)
{
// Direct database query - no domain model
return await _connection.QuerySingleOrDefaultAsync<OrderDto>(@"
SELECT o.Id, o.Status, o.Total, c.Name as CustomerName
FROM Orders o
JOIN Customers c ON c.Id = o.CustomerId
WHERE o.Id = @OrderId",
new { query.OrderId });
}
}
Level 2: Different Models (Full CQRS)
Optimized read models separate from write models:
Write Model
// Rich domain model for writes
public class Order : AggregateRoot<OrderId>
{
public CustomerId CustomerId { get; }
public OrderStatus Status { get; private set; }
private readonly List<OrderLine> _lines = new();
public void AddLine(ProductId productId, int quantity, Money price)
{
if (Status != OrderStatus.Draft)
throw new InvalidOrderStateException();
_lines.Add(new OrderLine(productId, quantity, price));
RecalculateTotal();
}
public void Submit()
{
if (!_lines.Any())
throw new EmptyOrderException();
Status = OrderStatus.Submitted;
AddDomainEvent(new OrderSubmitted(Id));
}
}
Read Model
// Denormalized read model
public class OrderReadModel
{
public Guid Id { get; set; }
public string CustomerName { get; set; }
public string CustomerEmail { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
public int ItemCount { get; set; }
public List<OrderLineReadModel> Lines { get; set; }
}
// Optimized for specific queries
public class OrderListItem
{
public Guid Id { get; set; }
public string CustomerName { get; set; }
public string Status { get; set; }
public decimal Total { get; set; }
public DateTime CreatedAt { get; set; }
}
Synchronization
// Event handler updates read model
public class OrderSubmittedHandler : IEventHandler<OrderSubmitted>
{
private readonly IReadModelStore _readStore;
private readonly IOrderRepository _orderRepo;
private readonly ICustomerRepository _customerRepo;
public async Task Handle(OrderSubmitted @event)
{
var order = await _orderRepo.GetById(@event.OrderId);
var customer = await _customerRepo.GetById(order.CustomerId);
var readModel = new OrderReadModel
{
Id = order.Id.Value,
CustomerName = customer.Name,
CustomerEmail = customer.Email,
Status = order.Status.ToString(),
Total = order.Total.Amount,
ItemCount = order.Lines.Count,
Lines = order.Lines.Select(l => new OrderLineReadModel
{
ProductId = l.ProductId.Value,
ProductName = l.ProductName,
Quantity = l.Quantity,
Price = l.Price.Amount
}).ToList()
};
await _readStore.Upsert(readModel);
}
}
Level 3: Separate Databases
Different Databases for Different Needs
| Write DB | Read DB | Use Case |
|---|---|---|
| PostgreSQL | Elasticsearch | Full-text search |
| PostgreSQL | Redis | Fast lookups |
| PostgreSQL | MongoDB | Flexible queries |
| Event Store | PostgreSQL | Event sourcing |
When to Use CQRS
Good Fit
✅ Read and write patterns differ significantly
✅ Complex domain with simple read requirements
✅ High read-to-write ratio (10:1 or more)
✅ Different scaling needs for reads vs writes
✅ Event sourcing (natural fit)
✅ Multiple read representations needed
Poor Fit
❌ Simple CRUD operations
❌ Read and write patterns are similar
❌ Small scale, no performance issues
❌ Team unfamiliar with pattern
❌ Adding complexity without clear benefit
Trade-offs
| Benefit | Cost |
|---|---|
| Optimized read models | Two models to maintain |
| Independent scaling | Synchronization complexity |
| Simpler queries | Eventual consistency |
| Better performance | More infrastructure |
Common Mistakes
Mistake 1: CQRS for Simple CRUD
// ❌ Over-engineering simple operations
public class UpdateUserNameCommand { }
public class UpdateUserNameHandler { }
public class UserNameUpdatedEvent { }
public class UserReadModelProjector { }
// ✅ Just update the record
public async Task UpdateUserName(Guid userId, string name)
{
var user = await _context.Users.FindAsync(userId);
user.Name = name;
await _context.SaveChangesAsync();
}
Mistake 2: Ignoring Eventual Consistency
// ❌ Expecting immediate consistency
public async Task<ActionResult> CreateOrder(CreateOrderCommand cmd)
{
await _mediator.Send(cmd);
// Read model might not be updated yet!
var order = await _readService.GetOrder(cmd.OrderId);
return Ok(order);
}
// ✅ Handle eventual consistency
public async Task<ActionResult> CreateOrder(CreateOrderCommand cmd)
{
var orderId = await _mediator.Send(cmd);
// Return the ID, let client query when ready
return Accepted(new { orderId });
}
Quick Reference
┌─────────────────────────────────────────────────────────┐
│ CQRS QUICK REFERENCE │
├─────────────────────────────────────────────────────────┤
│ │
│ LEVELS │
│ 1. Same DB, different services (Light) │
│ 2. Different models, same DB (Medium) │
│ 3. Different databases (Full) │
│ │
│ WHEN TO USE │
│ • Read/write patterns differ │
│ • High read-to-write ratio │
│ • Need different scaling │
│ • Using event sourcing │
│ │
│ TRADE-OFFS │
│ + Optimized reads │
│ + Independent scaling │
│ - Two models to maintain │
│ - Eventual consistency │
│ │
└─────────────────────────────────────────────────────────┘
Next Steps
- Event Sourcing + CQRS - Natural combination
- Saga Pattern - Distributed transactions