CQRS

Intro

CQRS (Command Query Responsibility Segregation) separates the part of the system that changes state from the part that serves read data. It matters because read and write workloads usually have different shapes: writes care about consistency and invariants, while reads care about latency and query flexibility. With CQRS, you can scale and optimize these paths independently instead of forcing one model to serve both. Reach for it when your domain rules are non-trivial, your query surface is broad, or your read:write ratio is high enough that a denormalized read model pays off.

Mechanism

At the core, CQRS uses two different models with different responsibilities:

The write side is typically normalized and transactional (for example, EF Core with aggregate boundaries and concurrency control). The read side is often denormalized (materialized views, projection tables, or document-style records) to avoid expensive joins at query time.

Bridge options between write and read models:

In interviews, emphasize that CQRS is about separating responsibilities and optimization goals, not automatically about adding message brokers or multiple databases.

Deeper Explanation

graph LR
    subgraph CLIENT[Client]
        U[User Action]
    end

    subgraph WRITE[Write Side - optimized for consistency]
        CMD[PlaceOrderCommand]
        CH[Command Handler]
        VAL{Validate business rules}
        WM[(Normalized Write DB)]
    end

    subgraph READ[Read Side - optimized for queries]
        QRY[GetOrderSummaryQuery]
        QH[Query Handler]
        RM[(Denormalized Read DB)]
    end

    U -->|Mutate state| CMD
    CMD --> CH
    CH --> VAL
    VAL -->|Valid| WM
    VAL -->|Invalid| ERR([Reject with error])

    U -->|Fetch data| QRY
    QRY --> QH
    QH --> RM
    RM --> VIEW([Fast flat response])

    WM -.->|Async projection or event| RM

The key insight: the write model is normalized and enforces business rules, while the read model is denormalized and shaped for fast queries. They can use different databases, different schemas, or even different technologies. The trade-off is eventual consistency between the two sides.

This sample uses MediatR for handler wiring; CQRS itself is library-agnostic. MediatR has community and commercial licensing options, so verify current terms at mediatr.io. Alternatives include direct DI-wired command/query handlers with custom interfaces.
Write side: command handler validates invariants and persists normalized state.

using MediatR;
using Microsoft.EntityFrameworkCore;
public sealed record PlaceOrderCommand(Guid CustomerId, IReadOnlyList<OrderLineInput> Lines)
    : IRequest<Guid>;

public sealed record OrderLineInput(Guid ProductId, int Quantity);

public sealed class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, Guid>
{
    private readonly OrderingDbContext _db;
    private readonly IMediator _mediator;
    public PlaceOrderHandler(OrderingDbContext db, IMediator mediator)
    {
        _db = db;
        _mediator = mediator;
    }

    public async Task<Guid> Handle(PlaceOrderCommand request, CancellationToken ct)
    {
        if (request.Lines.Count == 0)
            throw new ValidationException("Order must contain at least one line.");

        var customer = await _db.Customers.SingleOrDefaultAsync(c => c.Id == request.CustomerId, ct);
        if (customer is null)
            throw new ValidationException("Customer does not exist.");

        var productIds = request.Lines.Select(x => x.ProductId).Distinct().ToArray();
        var products = await _db.Products
            .Where(p => productIds.Contains(p.Id))
            .ToDictionaryAsync(p => p.Id, ct);
        foreach (var line in request.Lines)
        {
            if (!products.TryGetValue(line.ProductId, out var p) || !p.IsActive)
                throw new ValidationException($"Product {line.ProductId} is unavailable.");
        }

        var order = Order.Create(request.CustomerId, request.Lines);
        _db.Orders.Add(order);
        await _db.SaveChangesAsync(ct);
        // In-process publish is awaited; use outbox + broker for truly asynchronous projection.
        await _mediator.Publish(new OrderPlaced(order.Id, customer.Name, order.Total, order.CreatedUtc), ct);
        return order.Id;
    }
}

Read side: query handler loads a denormalized view optimized for the screen.

using Dapper;
using MediatR;
using System.Data;
public sealed record GetOrderSummaryQuery(Guid OrderId) : IRequest<OrderSummaryDto?>;

public sealed record OrderSummaryDto(
    Guid OrderId,
    string CustomerName,
    decimal Total,
    string Status,
    DateTime CreatedUtc);

public sealed class GetOrderSummaryHandler : IRequestHandler<GetOrderSummaryQuery, OrderSummaryDto?>
{
    private readonly IDbConnection _connection;
    public GetOrderSummaryHandler(IDbConnection connection) => _connection = connection;

    public async Task<OrderSummaryDto?> Handle(GetOrderSummaryQuery request, CancellationToken ct)
    {
        const string sql = """
            SELECT
                order_id     AS OrderId,
                customer_name AS CustomerName,
                total_amount  AS Total,
                status        AS Status,
                created_utc   AS CreatedUtc
            FROM read.order_summary
            WHERE order_id = @OrderId;
            """;
        var cmd = new CommandDefinition(sql, new { request.OrderId }, cancellationToken: ct);
        return await _connection.QuerySingleOrDefaultAsync<OrderSummaryDto>(cmd);
    }
}

A minimal projection that feeds the read model from events (PostgreSQL syntax using ON CONFLICT):

using MediatR;

public sealed record OrderPlaced(Guid OrderId, string CustomerName, decimal Total, DateTime CreatedUtc) : INotification;

public sealed class OrderSummaryProjection : INotificationHandler<OrderPlaced>
{
    private readonly IDbConnection _connection;
    public OrderSummaryProjection(IDbConnection connection) => _connection = connection;

    public Task Handle(OrderPlaced notification, CancellationToken cancellationToken)
    {
        const string upsert = """
            INSERT INTO read.order_summary (order_id, customer_name, total_amount, status, created_utc)
            VALUES (@OrderId, @CustomerName, @Total, 'Placed', @CreatedUtc)
            ON CONFLICT (order_id) DO UPDATE
            SET total_amount = EXCLUDED.total_amount,
                status = EXCLUDED.status;
            """;
        return _connection.ExecuteAsync(new CommandDefinition(upsert, new
        {
            notification.OrderId,
            notification.CustomerName,
            notification.Total,
            notification.CreatedUtc
        }, cancellationToken: cancellationToken));
    }
}

CQRS and Event Sourcing

CQRS pairs naturally with Event Sourcing because events are already the canonical change stream that can project into one or many read models. This makes rebuilding read views and supporting new query shapes easier.
Important distinction for interviews: CQRS does not require Event Sourcing.

Use both when auditability, temporal debugging, replay, and multiple read projections are first-class requirements.

Pitfalls

Tradeoffs: CQRS vs Simple CRUD

Criterion Simple CRUD model CQRS model
Read/write ratio close to 1:1 Usually sufficient Often unnecessary complexity
Read-heavy workloads Can degrade with heavy joins/index pressure Read model can be denormalized for low-latency queries
Domain invariants and complex write rules Possible but can bloat entity model Write model stays explicit and invariant-focused
Operational complexity Lower Higher (projections, lag, retries, idempotency)
Independent scaling Limited Strong, especially with separate stores
Decision rule: CQRS is usually worth it when at least two are true at once: high read:write ratio, complex query requirements, and clear need to scale read/write paths independently.

Questions

References


Whats next