CQRS in Practice: When It's Worth the Complexity and When It Isn't
I once inherited a codebase where the team had implemented "CQRS" for a simple CRUD application that managed employee records. There were separate command and query models, a mediator pipeline, dedicated read and write databases with a synchronization process, and an event bus connecting everything. The application had five entities. Five. The original CRUD version would have been about 20 controller actions. The "CQRS" version had over 80 classes across 12 projects.
CQRS is a powerful pattern. It is also one of the most over-applied patterns in enterprise software. Let's cut through the noise and figure out when it actually earns its complexity.
What CQRS Actually Is
CQRS stands for Command Query Responsibility Segregation. The core idea is simple: use different models for reading and writing data.
That is it. Seriously. At its simplest, CQRS means you have one set of classes optimized for handling writes (commands) and another set optimized for handling reads (queries). They can share the same database. They do not require event sourcing, message queues, or eventual consistency.
Here is the key distinction that trips people up:
- CQRS = separate read and write models
- Event Sourcing = storing state as a sequence of events instead of current state
- CQRS + Event Sourcing = a specific combination that some architectures use
You can use CQRS without event sourcing. Most teams should.
The Traditional Approach vs CQRS
In a typical application, the same model handles both reads and writes:
// Traditional: one model for everything
public class OrderService
{
public Order GetOrder(Guid id) => _db.Orders.Find(id);
public List<OrderSummary> GetOrdersForDashboard() => /* complex query */;
public void PlaceOrder(Order order) => /* validation + save */;
public void CancelOrder(Guid id) => /* business rules + save */;
}
This works fine until your read and write needs diverge significantly. The dashboard query needs denormalized data from six tables with aggregations. The write operation needs to enforce complex business rules and validate invariants. Cramming both into the same model forces compromises in both directions.
Implementing CQRS in .NET
Here is a clean, practical CQRS implementation. No framework required for the basics.
Define Commands and Queries
// Commands represent intent to change state
public record PlaceOrderCommand(
Guid CustomerId,
List<OrderLineItem> Items,
string ShippingAddress
);
public record CancelOrderCommand(Guid OrderId, string Reason);
// Queries represent a request for data
public record GetOrderQuery(Guid OrderId);
public record GetDashboardQuery(Guid CustomerId, DateRange Period);
Command Handlers (Write Side)
public class PlaceOrderHandler
{
private readonly OrderDbContext _db;
private readonly IInventoryService _inventory;
public PlaceOrderHandler(OrderDbContext db, IInventoryService inventory)
{
_db = db;
_inventory = inventory;
}
public async Task<Result<Guid>> Handle(PlaceOrderCommand command)
{
// Complex business logic lives here
foreach (var item in command.Items)
{
var available = await _inventory.CheckStock(item.ProductId, item.Quantity);
if (!available)
return Result.Failure<Guid>($"Insufficient stock for {item.ProductId}");
}
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = command.CustomerId,
Lines = command.Items.Select(i => new OrderLine
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = i.UnitPrice,
}).ToList(),
Status = OrderStatus.Placed,
CreatedAt = DateTime.UtcNow,
};
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return Result.Success(order.Id);
}
}
Query Handlers (Read Side)
public class GetDashboardHandler
{
private readonly IDbConnection _readDb; // Could be a read replica
public GetDashboardHandler(IDbConnection readDb) => _readDb = readDb;
public async Task<DashboardView> Handle(GetDashboardQuery query)
{
// Optimized read query — no business logic, just data shaping
var sql = @"
SELECT
COUNT(*) as TotalOrders,
SUM(total_amount) as Revenue,
AVG(total_amount) as AvgOrderValue,
COUNT(CASE WHEN status = 'Cancelled' THEN 1 END) as CancelledOrders
FROM orders
WHERE customer_id = @CustomerId
AND created_at BETWEEN @Start AND @End";
return await _readDb.QuerySingleAsync<DashboardView>(
sql, new { query.CustomerId, query.Period.Start, query.Period.End }
);
}
}
Notice the read side uses Dapper with raw SQL for maximum query performance. The write side uses EF Core for change tracking and business logic. Different tools for different jobs — that is the whole point of CQRS.
The MediatR Pattern
Most .NET CQRS implementations use MediatR (or a similar mediator library) to decouple the request from the handler:
// With MediatR
public record PlaceOrderCommand(...) : IRequest<Result<Guid>>;
public record GetDashboardQuery(...) : IRequest<DashboardView>;
// In your controller
[ApiController]
[Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> PlaceOrder(PlaceOrderCommand command)
{
var result = await _mediator.Send(command);
return result.IsSuccess
? CreatedAtAction(nameof(GetOrder), new { id = result.Value }, null)
: BadRequest(result.Error);
}
[HttpGet("dashboard")]
public async Task<IActionResult> GetDashboard(
[FromQuery] GetDashboardQuery query)
{
return Ok(await _mediator.Send(query));
}
}
MediatR also gives you a pipeline where you can add cross-cutting concerns — validation, logging, transaction management — without polluting your handlers.
When CQRS Pays Off
CQRS earns its complexity when you have genuine asymmetry between reads and writes:
High read/write ratio disparity. If your system handles 1,000 reads for every write, CQRS lets you optimize the read path aggressively (denormalized views, caching, read replicas) without complicating your write logic.
Complex domain logic on writes. If placing an order involves inventory checks, fraud detection, pricing rules, and loyalty points calculation, that logic deserves its own model — not something tangled with query optimization.
Different scaling needs. You can scale read and write infrastructure independently. Read replicas, CDN caching for read endpoints, and beefy write nodes for consistency.
Multiple read representations. An e-commerce product might need to appear as a search result (minimal data), a catalog card (medium data), a detail page (full data), and an admin view (data plus metadata). Each is a different read model.
Team boundaries. When the team building the reporting dashboard is different from the team building the order processing pipeline, CQRS gives each team a clean boundary to work within.
When CQRS Is Overkill
Simple CRUD applications. If your domain is mostly "save this entity, read it back, list all entities" — adding CQRS creates indirection without benefit. A standard repository pattern does the job.
Small teams. CQRS adds files, abstractions, and conventions. With a team of two or three developers who all understand the whole codebase, this overhead slows you down instead of helping.
Uniform read/write patterns. If your reads and writes use essentially the same data shape, separate models are just duplication.
Early-stage products. When you are still discovering your domain model, premature separation makes it harder to refactor. Start with the simplest thing that works; extract CQRS when the pain points emerge.
A Decision Framework
Ask these questions before reaching for CQRS:
| Question | If Yes | If No |
|---|---|---|
| Do reads and writes use significantly different data shapes? | CQRS candidate | Skip |
| Is write-side business logic complex (not just validation)? | CQRS candidate | Skip |
| Do you need to scale reads and writes independently? | CQRS candidate | Skip |
| Do you have multiple teams working on the same domain? | CQRS candidate | Skip |
| Is this a greenfield CRUD app with a small team? | Skip | Evaluate other factors |
If you answered "yes" to two or more of the first four questions, CQRS will likely pay for itself. Otherwise, start simple.
Optional: Adding Event Sourcing
If your domain benefits from a complete audit trail or you need to reconstruct state at any point in time, you can layer event sourcing on top of CQRS. But this is a separate decision with its own trade-offs:
- Pros: complete history, temporal queries, event replay for debugging
- Cons: eventual consistency between write store and read projections, increased storage, projection rebuild complexity
Most applications that benefit from CQRS do not need event sourcing. A relational database with separate read and write models gives you 80% of the benefit with 20% of the complexity. Only add event sourcing when you have a concrete need for it — not because it sounds architecturally pure.
Takeaway
CQRS is a scalpel, not a butter knife. It solves a specific problem — the tension between read-optimized and write-optimized models — and solves it well. But applying it to every application is like using a distributed database for a to-do list. Start with the simplest architecture that works. When you feel the pain of a single model trying to serve divergent read and write needs, that is when CQRS earns its place. Not before.
Comments
No comments yet. Be the first!