CQRS without the ceremony

ยท 2 min read

CQRS often arrives with separate databases, event sourcing and a mediator library. The core idea is much simpler and useful on its own: write through your domain model, read with plain queries.

Mention CQRS (Command Query Responsibility Segregation) and people picture a complex system: a write database and a read database, events synchronizing them, event sourcing, and handler classes for everything. That's one way to do it, and for most applications it's far more than they need.

The idea at the core is simple: reading data and changing data have different needs, so don't force them through the same model.

The problem with one model for both

In a typical layered app, a screen that lists orders loads Order entities through a repository, with their line items and customer, then maps them to a DTO. The domain model was designed to protect business rules when things change. For reading, those rules don't matter, and loading full entities just to build a list is wasted work and wasted joins.

Commands: through the domain model

Changes go through entities that enforce the rules:

public async Task<Result> CancelOrderAsync(Guid orderId, string reason, CancellationToken ct)
{
    var order = await db.Orders.Include(o => o.Lines).SingleOrDefaultAsync(o => o.Id == orderId, ct);
    if (order is null) return Result.NotFound();

    order.Cancel(reason, time.GetUtcNow());   // invariants live here
    await db.SaveChangesAsync(ct);
    return Result.Success();
}

Queries: straight to the shape you need

Reads skip the domain model entirely and project directly into what the screen or API returns:

public Task<List<OrderSummary>> GetRecentOrdersAsync(string customerId, CancellationToken ct) =>
    db.Orders
        .AsNoTracking()
        .Where(o => o.CustomerId == customerId)
        .OrderByDescending(o => o.CreatedAt)
        .Take(20)
        .Select(o => new OrderSummary(o.Id, o.CreatedAt, o.Status.ToString(), o.Lines.Sum(l => l.Price * l.Quantity)))
        .ToListAsync(ct);

EF Core translates the projection into a single query that selects only the columns needed. No tracking, no entities, no mapping layer. For complex reports, a raw SQL query or Dapper is equally fine on the read side.

That's CQRS: same database, same DbContext, different paths for reads and writes.

What you don't need (yet)

  • A separate read database. Add a read replica or a denormalized read store only when read load or query complexity actually demands it.
  • Event sourcing. It's a separate pattern with its own costs. It pairs well with CQRS, but neither requires the other.
  • A mediator library. Commands and queries can be plain methods or handler classes called directly.

Where it pays off

  • Read performance: queries fetch exactly what they need.
  • Simpler domain model: entities only need to support changes, not every screen's display needs.
  • Clear intent: a method either changes state or returns data, never both, which makes code easier to reason about and test.

Takeaway

Start CQRS with a simple split in code: commands load entities and change them through the domain model; queries project straight to DTOs with no tracking. Add separate stores, events or libraries only when you have a concrete reason.