CQRS gets conflated with event sourcing so often that a lot of teams think adopting one requires the other. It doesn't. CQRS just means separate models for writes (commands) and reads (queries) — and the lightweight version of that is a much smaller decision than most people assume.

Key takeaways
  • CQRS means separate models for reads and writes. It does not require separate databases, event sourcing, or eventual consistency — those are optional extensions people conflate with the core pattern.
  • Most line-of-business applications don't need it. A single well-designed model handles both reads and writes fine; reach for CQRS when read and write workloads have genuinely different shapes, scale, or performance needs.
  • MediatR gives you command/query separation at the code level, against the same database, as a low-risk starting point — you can adopt this without any infrastructure changes.
  • Full CQRS with separate read and write stores adds real operational complexity (keeping them in sync, eventual consistency between them). Don't take that step until you've measured an actual problem the lightweight version can't solve.

What CQRS actually is

Command Query Responsibility Segregation: commands change state and return nothing meaningful (success/failure, maybe an ID); queries read state and change nothing. Splitting these into separate models means your write model can be strict and validation-heavy while your read model can be shaped exactly for the screen or API response that needs it — no forced compromise between the two.

Client / API Command Handler validates, mutates Query Handler projects, reads Write Store Read Store Command Query same DB (lightweight) or sync (heavyweight)
Commands and queries split at the handler level — the store below can stay shared or split later

The lightweight version: separate models, same database

This is where nearly every team should start. Commands and queries are separate classes with separate handlers, but they still hit the same DbContext.

csharp
public record SubmitOrderCommand(Guid OrderId) : IRequest;

public class SubmitOrderHandler : IRequestHandler<SubmitOrderCommand>
{
    private readonly AppDbContext _db;
    public SubmitOrderHandler(AppDbContext db) => _db = db;

    public async Task Handle(SubmitOrderCommand cmd, CancellationToken ct)
    {
        var order = await _db.Orders.FindAsync(new object[] { cmd.OrderId }, ct)
            ?? throw new NotFoundException(nameof(Order), cmd.OrderId);
        order.Submit();
        await _db.SaveChangesAsync(ct);
    }
}

public record OrderSummaryQuery(Guid CustomerId) : IRequest<List<OrderSummaryDto>>;

public class OrderSummaryHandler : IRequestHandler<OrderSummaryQuery, List<OrderSummaryDto>>
{
    private readonly AppDbContext _db;
    public OrderSummaryHandler(AppDbContext db) => _db = db;

    public Task<List<OrderSummaryDto>> Handle(OrderSummaryQuery q, CancellationToken ct) =>
        _db.Orders
           .Where(o => o.CustomerId == q.CustomerId)
           .Select(o => new OrderSummaryDto(o.Id, o.Total, o.Status.ToString()))
           .ToListAsync(ct);
}

Notice the query handler projects straight into a DTO shaped for its one caller, instead of returning full Order entities and letting the API layer figure out what to expose. That's most of CQRS's real-world benefit, and it costs you nothing beyond organizing code into commands and queries.

The heavyweight version: separate read and write stores

This is justified when your read and write workloads have genuinely different scale or shape — a write-heavy transactional core in SQL Server, with a denormalized, search-optimized read store in Elasticsearch or Cosmos DB that's updated asynchronously from domain events.

csharp
// Write side: normal transactional save
public async Task Handle(SubmitOrderCommand cmd, CancellationToken ct)
{
    var order = await _orders.GetAsync(cmd.OrderId, ct);
    order.Submit();
    await _unitOfWork.SaveChangesAsync(ct);
    await _bus.Publish(new OrderSubmitted(order.Id, order.CustomerId, order.Total), ct);
}

// Read side: a separate handler keeps the search-optimized store in sync,
// asynchronously and eventually consistent with the write side
public async Task Handle(OrderSubmitted evt)
{
    await _searchIndex.UpsertAsync(new OrderSearchDocument
    {
        OrderId = evt.OrderId,
        CustomerId = evt.CustomerId,
        Total = evt.Total,
        SubmittedAt = DateTime.UtcNow
    });
}

The cost here is real: the read store now lags behind the write store by however long the event takes to process, and your UI needs to tolerate a customer submitting an order and not immediately seeing it in a search result that reads from the projection. That's a legitimate trade-off for the right workload — a reporting dashboard, a search experience, a read-heavy public API — and a genuine liability for a screen where the user expects to see their own write immediately.

The honest defaultIf you can't articulate a specific reason the read and write sides need to scale or query independently, you don't need separate stores yet. The lightweight version (separate handlers, same database) gets you almost all of the maintainability benefit with none of the eventual-consistency cost.

A concrete signal it's time to split

We reach for the heavyweight version when a single query pattern is dominating database load in a way that indexing can't fix — a reporting query joining across a dozen tables, running constantly, competing with transactional writes for the same connection pool and lock contention. Projecting that query's data into a purpose-built read store, updated asynchronously, is a cleaner fix than throwing more compute at a query that fundamentally shouldn't be running against the transactional database at all.

Need help with this in production?

astrivo helps teams design, build, and modernize systems like this. Let's talk about your context.