Skip to main content

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

ConceptDefinition
CommandChanges state, returns nothing (or just ID)
QueryReturns data, changes nothing
CQRSDifferent models for commands and queries
WhenRead/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 DBRead DBUse Case
PostgreSQLElasticsearchFull-text search
PostgreSQLRedisFast lookups
PostgreSQLMongoDBFlexible queries
Event StorePostgreSQLEvent 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

BenefitCost
Optimized read modelsTwo models to maintain
Independent scalingSynchronization complexity
Simpler queriesEventual consistency
Better performanceMore 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