CQRS: Separating Read and Write Models
Every developer has felt the pain: you've got a domain model that started clean and simple, but now it's bloated with computed properties for display, lazy-loaded collections for reports, and...
Key Insights
- CQRS separates your application into distinct write (command) and read (query) models, allowing each to be optimized independently for its specific access patterns.
- The pattern shines in systems with high read/write ratio disparity or complex domains, but adds significant complexity that isn’t justified for simple CRUD applications.
- You don’t need Event Sourcing to use CQRS—they’re complementary patterns that are often combined but can be adopted independently.
The Problem with Single Models
Every developer has felt the pain: you’ve got a domain model that started clean and simple, but now it’s bloated with computed properties for display, lazy-loaded collections for reports, and query-specific annotations that have nothing to do with business logic. Your Order entity has become a Frankenstein’s monster serving two masters—transactional integrity and query performance—and failing at both.
Traditional CRUD architectures force a single model to handle fundamentally different concerns. Writes need strong consistency, validation, and business rule enforcement. Reads need speed, denormalization, and flexibility for diverse query patterns. When you optimize for one, you compromise the other.
At scale, this tension becomes a bottleneck. Your carefully normalized schema ensures data integrity but requires expensive joins for every dashboard query. Your ORM’s change tracking overhead slows down read-heavy endpoints. Your domain model accumulates display logic that clutters business rules.
CQRS offers a way out by acknowledging a simple truth: the shape of data you write is rarely the shape of data you read.
CQRS Fundamentals
Command Query Responsibility Segregation (CQRS) splits your application into two distinct paths. Commands mutate state—they represent intentions to change the system. Queries retrieve state—they never modify anything.
This isn’t just about method naming conventions. In a CQRS architecture, commands and queries flow through entirely separate models, potentially with different data stores, different optimization strategies, and different scaling characteristics.
// Commands represent intent to change state
public interface ICommand { }
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderLineItem> Items,
ShippingAddress Address
) : ICommand;
// Queries represent requests for data
public interface IQuery<TResult> { }
public record GetOrderSummaryQuery(Guid OrderId) : IQuery<OrderSummaryDto>;
// Command handlers process mutations
public interface ICommandHandler<TCommand> where TCommand : ICommand
{
Task HandleAsync(TCommand command, CancellationToken ct);
}
// Query handlers retrieve data
public interface IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
Task<TResult> HandleAsync(TQuery query, CancellationToken ct);
}
Data flows through this system in a clear pattern: commands enter through the write side, get validated, execute business logic, and persist changes. Those changes then propagate (synchronously or asynchronously) to the read side, which maintains denormalized views optimized for specific query patterns.
Implementing the Write Side
The write side owns your domain logic. It’s where business rules live, invariants are enforced, and transactions maintain consistency. Your write model should be rich with behavior, not just a data container.
Command handlers orchestrate this process: validate input, load aggregates, execute domain logic, and persist results.
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand>
{
private readonly IOrderRepository _orderRepository;
private readonly IInventoryService _inventoryService;
private readonly IEventPublisher _eventPublisher;
public PlaceOrderCommandHandler(
IOrderRepository orderRepository,
IInventoryService inventoryService,
IEventPublisher eventPublisher)
{
_orderRepository = orderRepository;
_inventoryService = inventoryService;
_eventPublisher = eventPublisher;
}
public async Task HandleAsync(PlaceOrderCommand command, CancellationToken ct)
{
// Validate business rules
var availabilityResult = await _inventoryService
.CheckAvailabilityAsync(command.Items, ct);
if (!availabilityResult.AllItemsAvailable)
throw new InsufficientInventoryException(availabilityResult.UnavailableItems);
// Create domain aggregate with business logic
var order = Order.Create(
command.CustomerId,
command.Items,
command.Address);
// Domain model enforces invariants
order.CalculateTotals();
order.ApplyBusinessRules();
// Persist to write store
await _orderRepository.SaveAsync(order, ct);
// Publish events for read side synchronization
await _eventPublisher.PublishAsync(new OrderPlacedEvent(
order.Id,
order.CustomerId,
order.Total,
order.Items.Select(i => new OrderItemDto(i.ProductId, i.Quantity, i.Price)),
DateTime.UtcNow
), ct);
}
}
Notice that the write model doesn’t care about how data will be displayed. It focuses entirely on correctness and business rules. The Order aggregate enforces invariants like “order total must match line item sum” without worrying about dashboard requirements.
Implementing the Read Side
The read side is unapologetically optimized for queries. Forget normalization—denormalize aggressively. Forget generic models—build projections tailored to specific screens and reports.
Read models are disposable. They can be rebuilt from the write side’s data at any time. This freedom lets you optimize without fear.
// Denormalized read model for dashboard display
public class OrderDashboardProjection
{
public Guid OrderId { get; set; }
public string CustomerName { get; set; } // Denormalized from Customer
public string CustomerEmail { get; set; } // Denormalized from Customer
public decimal Total { get; set; }
public int ItemCount { get; set; }
public string Status { get; set; }
public DateTime PlacedAt { get; set; }
public string ShippingCity { get; set; } // Flattened from Address
}
public class OrderDashboardQueryHandler
: IQueryHandler<GetOrderDashboardQuery, PagedResult<OrderDashboardProjection>>
{
private readonly IReadDbContext _readDb;
public OrderDashboardQueryHandler(IReadDbContext readDb)
{
_readDb = readDb;
}
public async Task<PagedResult<OrderDashboardProjection>> HandleAsync(
GetOrderDashboardQuery query,
CancellationToken ct)
{
// Direct query against denormalized read store
// No joins, no complex mapping, no ORM overhead
var results = await _readDb.OrderDashboardViews
.Where(o => o.PlacedAt >= query.FromDate)
.Where(o => query.Status == null || o.Status == query.Status)
.OrderByDescending(o => o.PlacedAt)
.Skip(query.Page * query.PageSize)
.Take(query.PageSize)
.ToListAsync(ct);
return new PagedResult<OrderDashboardProjection>(results, totalCount);
}
}
Synchronization between write and read sides can be synchronous (update read models in the same transaction) or asynchronous (via events or change data capture). Asynchronous synchronization offers better write performance and scalability but introduces eventual consistency.
CQRS with Event Sourcing
CQRS and Event Sourcing are frequently paired, but they’re independent patterns. Event Sourcing stores state as a sequence of events rather than current state. CQRS separates read and write models. They complement each other beautifully—events provide a natural synchronization mechanism—but you can adopt either without the other.
When combined, events become the source of truth for building read projections:
public class OrderDashboardProjector : IEventHandler<OrderPlacedEvent>
{
private readonly IReadDbContext _readDb;
private readonly ICustomerReadService _customerService;
public OrderDashboardProjector(
IReadDbContext readDb,
ICustomerReadService customerService)
{
_readDb = readDb;
_customerService = customerService;
}
public async Task HandleAsync(OrderPlacedEvent @event, CancellationToken ct)
{
// Fetch denormalized data from other read models
var customer = await _customerService.GetByIdAsync(@event.CustomerId, ct);
var projection = new OrderDashboardProjection
{
OrderId = @event.OrderId,
CustomerName = customer.FullName,
CustomerEmail = customer.Email,
Total = @event.Total,
ItemCount = @event.Items.Count(),
Status = "Placed",
PlacedAt = @event.OccurredAt,
ShippingCity = @event.ShippingAddress.City
};
await _readDb.OrderDashboardViews.InsertAsync(projection, ct);
}
}
The power here is that you can rebuild any read model by replaying events from the beginning. Added a new dashboard? Replay events to populate it. Found a bug in a projection? Fix it and rebuild.
When to Use CQRS (and When Not To)
CQRS adds complexity. Before adopting it, honestly assess whether you need it.
Good fits:
- Systems with dramatically different read and write loads (100:1 read/write ratios)
- Complex domains where write models need rich behavior
- Applications requiring multiple specialized views of the same data
- Systems where read and write sides need independent scaling
Poor fits:
- Simple CRUD applications with straightforward queries
- Small teams who can’t afford the operational overhead
- Domains where read and write shapes are naturally similar
- Early-stage products where requirements are still fluid
You don’t have to go all-in. Start by applying CQRS to a single bounded context where the pain is acute. Use a shared database with separate read and write models before introducing separate data stores.
Practical Considerations
Eventual consistency is the primary challenge in CQRS systems. Users expect to see their changes immediately, but asynchronous read model updates introduce latency.
The “read your own writes” pattern addresses this by checking the write side when the read model might be stale:
public class OrderQueryService
{
private readonly IQueryHandler<GetOrderSummaryQuery, OrderSummaryDto> _queryHandler;
private readonly IOrderRepository _writeRepository;
private readonly IRecentWriteTracker _writeTracker;
public async Task<OrderSummaryDto> GetOrderAsync(
Guid orderId,
Guid userId,
CancellationToken ct)
{
// Check if this user recently modified this order
if (_writeTracker.HasRecentWrite(userId, orderId))
{
// Fall back to write side for consistency
var order = await _writeRepository.GetByIdAsync(orderId, ct);
return MapToSummary(order);
}
// Safe to use eventually consistent read model
return await _queryHandler.HandleAsync(
new GetOrderSummaryQuery(orderId), ct);
}
}
Testing strategies differ between sides. Write side tests focus on behavior: given these inputs, do the right events/state changes occur? Read side tests verify projections: given these events, does the read model contain expected data?
Operationally, CQRS systems require monitoring for synchronization lag, tools to rebuild projections, and clear strategies for handling projection failures. This isn’t insurmountable, but it’s real work that simple CRUD systems don’t require.
CQRS is a powerful pattern for the right problems. Apply it surgically where the read/write impedance mismatch causes genuine pain, and you’ll find it dramatically simplifies both sides of your application. Apply it universally because it sounds sophisticated, and you’ll drown in accidental complexity.