Introduction

Command Query Responsibility Segregation (CQRS) is an architectural pattern that separates reads (queries) from writes (commands). Rather than using a single data model and layer to both modify and read state, CQRS encourages designing optimized models and pathways for each. This separation can improve scalability, performance, and clarity—especially in complex domains—while introducing new challenges around consistency, messaging, and operational complexity.

This guide provides a practical, vendor-neutral overview of CQRS: what it is, when it helps, how to implement it (with and without event sourcing), and the pitfalls to avoid. Code examples are provided to illustrate implementation techniques.

Note: CQRS is not an all-or-nothing proposition. Many teams start with a conventional CRUD approach and apply CQRS selectively to the most complex or performance-sensitive parts of a system.

Table of Contents

What Is CQRS?

CQRS stands for Command Query Responsibility Segregation. It is based on the principle that commands (operations that change state) and queries (operations that read state) have different performance, scalability, and correctness needs. Rather than forcing both through the same model and data store, CQRS allows each to be designed independently.

  • Commands typically focus on enforcing domain invariants, validation, and transactional integrity.
  • Queries optimize for read performance—often denormalizing data and using specialized indexes or storage to serve fast, shape-specific responses.

CQRS does not require event sourcing, microservices, or a message broker. It can be implemented simply in-process or scaled out to distributed systems as needed.

When to Use (and When Not To)

Consider CQRS when:

  • You have complex domains with rich business rules and invariants.
  • Your read and write workloads differ significantly (e.g., read-heavy dashboards).
  • You need to scale reads independently from writes.
  • You require different storage technologies for reads and writes (polyglot persistence).
  • You need auditability and traceability (often paired with event sourcing).

Avoid or defer CQRS when:

  • The domain is simple and CRUD suffices.
  • Operational complexity and messaging infrastructure are not justified.
  • Strong, immediate consistency across all consumers is mandatory and you lack the infrastructure to manage distributed consistency.
  • Your team lacks experience with messaging, concurrency, and eventual consistency.

Rule of thumb: Start simple. Apply CQRS to hotspots (bounded contexts or aggregates) where it brings clear, measurable benefits.

Core Concepts

Commands vs. Queries

  • Command: Tells the system to perform an action that may change state. It is imperative and should be validated and authorized. Commands do not return domain data—at most, an acknowledgment, identifier, or result status.
  • Query: Asks for information without changing state. Queries should be side-effect free and cache-friendly.

Write Model vs. Read Model

  • Write model: Encapsulates domain logic, validation, and invariants. Typically normalized, designed around aggregates or domain entities.
  • Read model: Denormalized, precomputed views, or specialized indices tailored to specific query patterns.

Consistency Models

  • Strong consistency: Writes are immediately visible to subsequent reads. Achievable in-process or with carefully managed transactions.
  • Eventual consistency: Reads may lag behind writes; read models catch up as events/changes propagate. Common in distributed CQRS.

Architectural Options

In-Process CQRS

  • Single application process.
  • Commands and queries separated at the code level (handlers, services).
  • A single database can be used with separate schemas/tables/views.
  • Simplest to operate; good entry point.

Distributed CQRS with Messaging

  • Commands handled by a write service and persisted.
  • Changes published as events/messages to a bus or stream.
  • Read services/projectors subscribe and update read stores.
  • Enables independent scaling and technology choices per side.

CQRS with and without Event Sourcing

  • Without event sourcing: The write model persists current state (e.g., relational tables). Events for read projections are derived from data changes or application-level events.
  • With event sourcing: The write model stores immutable domain events as the source of truth. Current state is rebuilt by replaying events; read models subscribe to the same event stream.

Event sourcing increases auditability and temporal queries but adds complexity. Many teams use CQRS without event sourcing successfully.

Data Modeling and Projections

Designing the Write Model

  • Model aggregates that enforce invariants.
  • Keep transactional boundaries tight.
  • Use normalized schema to avoid conflicting updates.
  • Apply optimistic concurrency with version numbers.

Designing the Read Model

  • Design for the queries you actually need (shape-first).
  • Denormalize aggressively: store pre-joined, precomputed fields.
  • Use storage optimized for reads: caches, search engines, time-series DBs, or column stores.
  • Allow multiple read models per use case.

Projection Pipelines

  • Projectors subscribe to changes (events or change data capture).
  • They transform and write to read stores.
  • Ensure idempotency: reprocessing must not corrupt the view.
  • Handle backpressure, retries, and dead-lettering.

Schema Evolution and Versioning

  • Use event versioning (upcasting) or payload adapters when schemas evolve.
  • Maintain backward compatibility where possible.
  • Read models can be rebuilt from the event stream or migrated via backfill jobs.

Workflows and Reliability Patterns

Idempotency and Deduplication

  • Assign stable message IDs or aggregate versions to detect duplicates.
  • Make projector updates idempotent (UPSERTs, compare-and-set).
  • Ensure command handling is idempotent when retried.

Optimistic Concurrency Control

  • Include a version in commands or persistence.
  • Reject writes if the version has advanced; clients may retry with updated state or apply conflict resolution rules.

The Outbox Pattern

  • Write the domain change and an “outbox” record in the same transaction.
  • A background relay publishes outbox records to the message bus.
  • Guarantees no “dual writes” inconsistencies between DB and bus.

Sagas and Process Managers

  • Coordinate long-running, multi-aggregate workflows.
  • Maintain state transitions and compensating actions.
  • Use correlation IDs to group related messages.

Implementation Examples

C#: Commands, Queries, Handlers

Below is a minimal example without external frameworks, using optimistic concurrency and the outbox pattern.

// Domain
public record PlaceOrder(Guid OrderId, Guid CustomerId, IReadOnlyList<OrderLine> Lines, int ExpectedVersion);
public record OrderLine(Guid ProductId, int Quantity, decimal UnitPrice);

// Events
public record OrderPlaced(Guid OrderId, Guid CustomerId, decimal Total, DateTimeOffset OccurredAt);

// Aggregate
public class Order
{
    public Guid Id { get; private set; }
    public Guid CustomerId { get; private set; }
    public decimal Total { get; private set; }
    public int Version { get; private set; }

    public static (Order order, OrderPlaced @event) Place(PlaceOrder cmd, Func<Guid, bool> customerExists)
    {
        if (!customerExists(cmd.CustomerId)) throw new InvalidOperationException("Customer not found");
        if (cmd.Lines.Count == 0) throw new InvalidOperationException("At least one line item required");
        if (cmd.Lines.Any(l => l.Quantity <= 0 || l.UnitPrice <= 0)) throw new InvalidOperationException("Invalid line");

        var total = cmd.Lines.Sum(l => l.Quantity * l.UnitPrice);
        var evt = new OrderPlaced(cmd.OrderId, cmd.CustomerId, total, DateTimeOffset.UtcNow);

        var order = new Order
        {
            Id = cmd.OrderId,
            CustomerId = cmd.CustomerId,
            Total = total,
            Version = cmd.ExpectedVersion + 1
        };
        return (order, evt);
    }
}

// Infrastructure: write repo with outbox
public interface IUnitOfWork
{
    Task<int> SaveChangesAsync(CancellationToken ct);
}

public class OutboxMessage
{
    public Guid Id { get; set; } = Guid.NewGuid();
    public string Type { get; set; } = default!;
    public string Payload { get; set; } = default!;
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

public interface IOrderWriteRepository
{
    Task<Order?> Get(Guid id, CancellationToken ct);
    Task Add(Order order, CancellationToken ct);
    Task Update(Order order, int expectedVersion, CancellationToken ct);
    Task AddOutbox(OutboxMessage message, CancellationToken ct);
}

public class PlaceOrderHandler
{
    private readonly IOrderWriteRepository _repo;
    private readonly IUnitOfWork _uow;

    public PlaceOrderHandler(IOrderWriteRepository repo, IUnitOfWork uow)
    {
        _repo = repo; _uow = uow;
    }

    public async Task<Guid> Handle(PlaceOrder cmd, CancellationToken ct)
    {
        var existing = await _repo.Get(cmd.OrderId, ct);
        if (existing != null) throw new InvalidOperationException("Order already exists");

        var (order, evt) = Order.Place(cmd, customerId => true /* lookup */);

        await _repo.Add(order, ct);
        await _repo.AddOutbox(new OutboxMessage {
            Type = nameof(OrderPlaced),
            Payload = System.Text.Json.JsonSerializer.Serialize(evt)
        }, ct);

        await _uow.SaveChangesAsync(ct); // transactional write + outbox
        return order.Id;
    }
}

// Query side (read repo)
public record GetOrderSummary(Guid OrderId);
public record OrderSummary(Guid OrderId, Guid CustomerId, decimal Total, string Status);

public interface IOrderReadRepository
{
    Task<OrderSummary?> GetOrderSummary(Guid id, CancellationToken ct);
}

public class GetOrderSummaryHandler
{
    private readonly IOrderReadRepository _repo;
    public GetOrderSummaryHandler(IOrderReadRepository repo) => _repo = repo;

    public Task<OrderSummary?> Handle(GetOrderSummary q, CancellationToken ct)
        => _repo.GetOrderSummary(q.OrderId, ct);
}

The outbox messages are published by a background relay:

public class OutboxRelay
{
    private readonly IDbConnection _db;
    private readonly IMessageBus _bus;

    public OutboxRelay(IDbConnection db, IMessageBus bus)
    {
        _db = db; _bus = bus;
    }

    public async Task RunOnceAsync(CancellationToken ct)
    {
        var messages = await _db.QueryAsync<OutboxMessage>(
            "SELECT TOP 100 Id, Type, Payload FROM Outbox WHERE PublishedAt IS NULL ORDER BY CreatedAt", transaction: null);
        foreach (var m in messages)
        {
            await _bus.PublishAsync(m.Type, m.Payload, ct);
            await _db.ExecuteAsync("UPDATE Outbox SET PublishedAt = SYSUTCDATETIME() WHERE Id = @Id", new { m.Id });
        }
    }
}

SQL: Building a Read Model Projection

Assuming events are published and a projector updates a denormalized read table:

-- Read model table
CREATE TABLE OrderSummaries (
  OrderId UNIQUEIDENTIFIER PRIMARY KEY,
  CustomerId UNIQUEIDENTIFIER NOT NULL,
  Total DECIMAL(18,2) NOT NULL,
  Status NVARCHAR(32) NOT NULL,
  Version INT NOT NULL
);

-- Idempotent UPSERT for projection (using MERGE)
MERGE OrderSummaries AS target
USING (VALUES (@OrderId, @CustomerId, @Total, 'Placed', @Version)) AS src (OrderId, CustomerId, Total, Status, Version)
ON target.OrderId = src.OrderId
WHEN MATCHED AND target.Version < src.Version THEN
  UPDATE SET CustomerId = src.CustomerId, Total = src.Total, Status = src.Status, Version = src.Version
WHEN NOT MATCHED THEN
  INSERT (OrderId, CustomerId, Total, Status, Version)
  VALUES (src.OrderId, src.CustomerId, src.Total, src.Status, src.Version);

The projector reads an OrderPlaced event and executes the above statement. The Version ensures idempotency and prevents out-of-order overwrites.

TypeScript: A Lightweight Projector

type OrderPlaced = {
  type: 'OrderPlaced';
  orderId: string;
  customerId: string;
  total: number;
  occurredAt: string;
  version: number;
};

interface ReadModel {
  upsertOrderSummary: (summary: {
    orderId: string; customerId: string; total: number; status: string; version: number;
  }) => Promise<void>;
}

export class OrderProjector {
  constructor(private readonly readModel: ReadModel) {}

  async onMessage(msg: unknown): Promise<void> {
    const evt = msg as OrderPlaced;
    if (evt.type !== 'OrderPlaced') return;

    await this.readModel.upsertOrderSummary({
      orderId: evt.orderId,
      customerId: evt.customerId,
      total: evt.total,
      status: 'Placed',
      version: evt.version
    });
  }
}

This projector is idempotent if the read model persists version and discards older versions or duplicates.

Testing Strategies

  • Unit tests for commands: Given-When-Then style
    • Given an existing aggregate version
    • When a command is handled
    • Then the correct state change and events occur; conflicting versions are rejected
  • Property-based tests for invariants and boundary conditions
  • Contract tests for queries: Ensure shape and semantics match client expectations
  • End-to-end tests for projection pipelines: Feed events and assert read models converge
  • Performance tests:
    • Load, latency, and tail behavior under backpressure
    • Rebuild times for read models

Tip: Keep read models rebuildable. This simplifies recovery from bugs and supports schema evolution.

Observability and Operations

  • Tracing
    • Propagate correlation and causation IDs across commands, events, and projections
    • Visualize end-to-end flows, including saga steps
  • Metrics
    • Command rate, success/failure, latency
    • Projection lag (max event timestamp vs. now)
    • Outbox depth, retry counts, DLQ size
  • Logging
    • Structured logs with aggregate IDs and versions
    • Redact sensitive fields
  • Backpressure and scaling
    • Tune consumer concurrency
    • Use partitioning/sharding by aggregate ID for ordering guarantees
  • Recovery
    • Ability to pause consumers, reprocess from checkpoints, rebuild read models
    • Dead-letter queues with triage workflows

Migration Strategies

  • Strangler pattern: Introduce a new read model for a high-value query while leaving writes unchanged.
  • Dual writes with outbox: Start publishing events alongside existing transactions.
  • Incremental projection: Build read models for specific endpoints; gradually switch clients to use them.
  • Event sourcing later: If needed, add event capture via change data capture (CDC) or application events before moving the source of truth to an event store.
  • Safeguards
    • Feature flags to toggle projections
    • Shadow read models to validate parity before cutover

Security and Compliance Considerations

  • Authorization
    • Enforce permissions on commands strictly; validate actor rights to change state
    • Apply query authorization/filters on read models; avoid leaking data through denormalization
  • Data minimization
    • Store only necessary fields in read models; separate PII
  • Right to be forgotten
    • If using event sourcing, consider GDPR-compliant strategies (encryption keys per subject, redaction events, or segregated PII stores)
  • Multi-tenant isolation
    • Partition read/write stores by tenant where needed; ensure projections keep tenant boundaries

Common Pitfalls and How to Avoid Them

  • Overengineering
    • Don’t split everything. Apply CQRS where it pays for itself.
  • Dual-write inconsistencies
    • Use the outbox pattern; avoid writing to DB and publishing to bus in separate transactions.
  • Missing idempotency
    • Design projectors and handlers to tolerate duplicates and reordering.
  • Leaky invariants
    • Keep invariants in the write model; do not rely on read model state to make write decisions.
  • Unbounded projection lag
    • Monitor lag and implement backpressure, scaling, and alerting.
  • Tight coupling to event schemas
    • Version events and provide upcasters/adapters.

Conclusion

CQRS separates the concerns of writing and reading data so each can be optimized independently. When applied thoughtfully—to the right parts of a system—it unlocks clearer domain models, faster queries, independent scaling, and better evolvability. However, it introduces operational complexity: eventual consistency, message processing, idempotency, and schema evolution must be handled deliberately.

Start small. Implement in-process CQRS where a single database and separated models suffice, then evolve to messaging and multiple read models as needs grow. Use proven reliability patterns like the outbox, optimistic concurrency, and sagas. Invest in observability and testing so that your system remains understandable and resilient as it scales.

With pragmatic adoption and discipline, CQRS can be a powerful tool in your architecture toolbox—improving performance, clarity, and agility without sacrificing correctness.