← Back to writing

CQRS as a Supporting Architectural Pattern

dddcqrssoftware-architecturedomain-modeling

This is the seventh deep-dive in a series on Domain-Driven Design. It builds on the concepts introduced in Domain-Driven Design: A Practical Overview.

Every application does two different things with data: it changes state and it reads state. Most architectures use the same model for both: the same entities, the same repository, the same data structures flowing from the database to the API response. This works until it doesn’t.

The moment your read requirements diverge from your write requirements (and they always do), you start making compromises. The aggregate that’s perfect for enforcing invariants is too heavy for a list view. The flat DTO that’s perfect for the dashboard doesn’t have enough structure for a domain operation. You add projection methods, you create view models, you build query services alongside your repositories.

CQRS (Command Query Responsibility Segregation) formalizes this split. Commands change state. Queries read state. They use different models, different paths, and potentially different storage.


The command side

A command is an instruction to change domain state. It’s a record that carries the intent and the data needed to fulfill it:

public sealed record CreateBoard(
    Guid ProductId,
    string Name,
    string CreatedBy,
    BoardType Type = BoardType.ProcessModeling,
    Guid? ParentBoardId = null);

Commands are named in the imperative: CreateBoard, AdvancePhase, AddSticky, CompleteBoard. They describe what should happen, not what happened. They carry the minimum data needed for the operation.

A command handler processes the command. It loads the aggregate through a repository, performs the domain operation, and saves:

public class CreateBoardHandler
{
    private readonly IBoardRepository _boardRepository;
    private readonly IUnitOfWork _unitOfWork;

    public CreateBoardHandler(IBoardRepository boardRepository,
        IUnitOfWork unitOfWork)
    {
        _boardRepository = boardRepository;
        _unitOfWork = unitOfWork;
    }

    public async Task<BoardId> HandleAsync(CreateBoard command,
        CancellationToken cancellationToken)
    {
        var productId = ProductId.From(command.ProductId);
        BoardId? parentBoardId = null;

        if (command.ParentBoardId.HasValue)
        {
            parentBoardId = BoardId.From(command.ParentBoardId.Value);
            var parentBoard = await _boardRepository
                .GetByIdAsync(parentBoardId, cancellationToken)
                ?? throw EntityNotFoundException
                    .For<Board>(command.ParentBoardId.Value);

            if (!Board.CanBeChildOf(command.Type, parentBoard.Type))
                throw new DomainException(
                    $"A {command.Type} board cannot be a child " +
                    $"of a {parentBoard.Type} board.");
        }

        var board = Board.Create(productId, command.Name,
            command.CreatedBy, command.Type, parentBoardId);

        await _boardRepository.AddAsync(board, cancellationToken);
        await _unitOfWork.SaveChangesAsync(cancellationToken);

        return board.Id;
    }
}

The command handler is where domain logic and infrastructure meet. It translates primitive values from the command into domain types (ProductId.From, BoardId.From), invokes the aggregate’s factory method, and persists through the repository. The handler is thin. Validation lives in the aggregate, persistence lives in the repository, and the handler orchestrates.


The query side

The query side is simpler. No aggregates. No domain operations. No Unit of Work. Just data flowing from the database to the caller.

A query repository returns read models, flat immutable records designed for the consumer:

public interface IBoardQueryRepository
{
    Task<BoardInfo?> GetByIdAsync(Guid id,
        CancellationToken cancellationToken = default);
    Task<IReadOnlyList<BoardSummaryInfo>> GetVersionsByProductAndNameAsync(
        ProductId productId, string name,
        CancellationToken cancellationToken = default);
}

Notice the parameter types. The query repository takes a raw Guid for GetByIdAsync, not a BoardId. It doesn’t need a domain type. It’s not performing a domain operation. It’s looking up data.

A query handler retrieves and returns the read model:

public class GetBoardHandler
{
    private readonly IBoardQueryRepository _queryRepository;

    public GetBoardHandler(IBoardQueryRepository queryRepository)
    {
        _queryRepository = queryRepository;
    }

    public async Task<BoardInfo> HandleAsync(Guid boardId,
        CancellationToken cancellationToken)
    {
        var board = await _queryRepository
            .GetByIdAsync(boardId, cancellationToken);

        if (board is null)
            throw EntityNotFoundException.For<Board>(boardId);

        return board;
    }
}

The query handler is simple. Load the read model, return it. No aggregate construction. No mapping through domain types. The BoardInfo read model goes directly from the query repository to the API response.


Read models

Read models are the data structures that queries return. They are designed for the consumer, not the domain. They’re flat where the domain is nested. They use primitives where the domain uses value objects. They’re immutable, serialization-friendly, and carry no behavior.

public sealed record BoardInfo
{
    public required Guid Id { get; init; }
    public required Guid ProductId { get; init; }
    public required string Name { get; init; }
    public required string CreatedBy { get; init; }
    public required DateTime CreatedAt { get; init; }
    public required string Status { get; init; }
    public required string Type { get; init; }
    public required string? Description { get; init; }
    public required IReadOnlyList<StickyInfo> Stickies { get; init; }
    public required IReadOnlyList<ConnectionInfo> Connections { get; init; }
}

Compare this to the Board aggregate. The aggregate has BoardStatus (an enum). The read model has string. The aggregate has BoardId (a typed ID). The read model has Guid. The aggregate has Position (a value object on each sticky). The read model has X and Y as separate decimal properties on StickyInfo.

Read models live in the domain project, not the API project. This is a deliberate choice. They are part of the domain’s public interface, not an API concern. The naming convention is consistent: BoardInfo, StickyInfo, OrganizationInfo. The Info suffix signals “read model.” No behavior, just data.

The mapping from data entities to read models uses ToInfo() extension methods:

public static BoardInfo ToInfo(this BoardData data)
{
    return new BoardInfo
    {
        Id = data.Id,
        ProductId = data.ProductId,
        Name = data.Name,
        CreatedBy = data.CreatedBy,
        CreatedAt = data.CreatedAt,
        Status = data.Status,
        Type = data.Type,
        Description = data.Description,
        Stickies = data.Stickies.Select(s => s.ToInfo()).ToList(),
        Connections = data.Connections.Select(c => c.ToInfo()).ToList()
    };
}

No domain objects are created during a read. The data flows from the database through the data model to the read model to the API response, bypassing the domain layer entirely.


The handler pattern without MediatR

Many CQRS implementations use MediatR or a similar library to dispatch commands and queries. StormBoard doesn’t. Instead, handlers are plain classes registered through DI with assembly scanning:

// Convention: class name ends with "Handler"
// Registered via assembly scanning at startup
public class CreateBoardHandler { ... }
public class GetBoardHandler { ... }
public class AddStickyHandler { ... }
public class AdvancePhaseHandler { ... }

API endpoints inject handlers directly:

group.MapPost("/", async (CreateBoardRequest request,
    CreateBoardHandler handler,
    CancellationToken cancellationToken) =>
{
    var command = new CreateBoard(
        request.ProductId, request.Name, userId, request.Type);
    var boardId = await handler.HandleAsync(command, cancellationToken);
    return Results.Created($"/api/boards/{boardId.Value}",
        new { id = boardId.Value });
});

group.MapGet("/{id:guid}", async (Guid id,
    GetBoardHandler handler,
    CancellationToken cancellationToken) =>
{
    var board = await handler.HandleAsync(id, cancellationToken);
    return Results.Ok(board);
});

No mediator, no pipeline behaviors, no generic IRequest<T> interfaces. The endpoint creates the command, calls the handler, maps the result. The dependency graph is explicit. You can navigate from endpoint to handler to repository in your IDE without resolving generic type parameters.

This works well for a monolith. If you need cross-cutting concerns like logging or validation, add them through decorators or middleware, not through a mediator pipeline.


Minimal API endpoints as the boundary

API endpoints are the outermost boundary. They handle HTTP concerns (request parsing, response mapping, status codes, authentication) and delegate to handlers. No business logic lives here.

group.MapDelete("/{id:guid}", async (Guid id,
    DeleteBoardHandler handler,
    CancellationToken cancellationToken) =>
{
    await handler.HandleAsync(new DeleteBoard(id), cancellationToken);
    return Results.NoContent();
}).RequireAuthorization("RequireFacilitator");

Request DTOs are defined alongside the endpoint. They’re API concerns, not domain concerns:

public sealed record CreateBoardRequest(
    string Name, string? Type = null, Guid? ParentBoardId = null);

The endpoint translates between API types and domain types. The CreateBoardRequest has a string? Type. The CreateBoard command has a BoardType. The endpoint does the conversion. This keeps the domain clean and the API flexible. If the API serialization format changes, the domain doesn’t.


When CQRS is worth it

CQRS adds complexity. You have two paths through the system instead of one. You have separate models for reading and writing. You need mapping logic in both directions. This is worth it when:

Read and write shapes diverge. Your aggregate has nested entities with invariants. Your list view needs five flat columns. Building both from the same model requires compromises in both directions. Separate models let each optimize for its purpose.

Read and write scaling differ. Your system handles 100 writes per minute and 10,000 reads per minute. With CQRS, you can optimize the read path independently. Add caching, denormalize the read store, use a different database for queries, all without affecting the write path.

Commands need domain validation. Your writes go through aggregates that enforce invariants. Your reads just need data. Forcing reads through the aggregate means constructing objects you never modify. That’s wasted allocation and wasted mapping.

CQRS is not worth it for simple CRUD. If your read model and write model are the same shape (a settings page, a user profile, a simple form), one model is simpler and sufficient.


CQRS without event sourcing

CQRS and event sourcing are often mentioned together, but they’re independent patterns. Event sourcing stores the history of state changes instead of current state. CQRS separates reads from writes. You can use either without the other.

StormBoard uses CQRS without event sourcing. Commands go through aggregates and persist current state to SQL Server via EF Core. Queries read from the same SQL Server through query repositories. The read and write sides share a database. The models are different, but the storage isn’t.

This is lightweight CQRS, the practical subset that gives you 80% of the benefit with 20% of the complexity. You get separate read models, clean query paths, and optimized command handlers. You don’t need a message bus, event store, or read-side projections.


Common mistakes

Conflating CQRS with event sourcing. CQRS is about separating read and write models. Event sourcing is about storing events instead of state. You can use CQRS with a traditional database. Many systems should.

Over-abstracting the handler pattern. A generic ICommandHandler<TCommand, TResult> interface with a pipeline of behaviors feels clean but hides the dependency graph. When you need to debug a failing command, you’re navigating through generic type resolution instead of following a direct call chain. Start simple. Concrete handlers, direct injection.

Returning aggregates from command handlers. If your command handler returns the aggregate so the endpoint can project it, the command side is doing query work. Return the ID or a minimal result. Let the caller query for the full read model if needed.

Read models that duplicate domain logic. If your BoardInfo has a method like CanAdvancePhase(), you’ve put domain logic in a read model. Read models are data. Domain logic belongs on the aggregate.

Too many query repositories. One query repository per aggregate is a good starting point, but don’t force it. If the dashboard needs data from boards, organizations, and products in one query, a DashboardQueryRepository that joins across tables is fine. Query repositories aren’t bound by aggregate boundaries. Only command repositories are.


CQRS is a supporting pattern, not a primary one. It works best when paired with aggregates and repositories. The command side enforces invariants through aggregates. The query side returns data through read models. The two sides share a database but use different models, different paths, and different optimization strategies.

The final post in this series covers identity modeling and explicit domain types: strongly-typed IDs, smart enums, value objects, and guard clauses that make invalid states unrepresentable.

Found this useful?

If this post helped you, consider buying me a coffee.

Buy me a coffee

Comments