Skip to main content

CQRS Pattern

Command Query Responsibility Segregation (CQRS) is a pattern that separates read operations (Queries) from write operations (Commands). SpecKit implements CQRS using MediatR to achieve clean separation of concerns and improved testability.

CQRS Overview

Commands

Purpose: Modify system stateCharacteristics:
  • Task-based (imperative)
  • Can fail with business rule violations
  • May publish domain events
  • Should be validated
  • Return confirmation/result
Examples:
  • CreateReservationCommand
  • AddToCartCommand
  • CheckoutOrderCommand

Queries

Purpose: Retrieve data without side effectsCharacteristics:
  • Read-only operations
  • Never modify state
  • Can be cached
  • Optimized for specific views
  • Always succeed (or return empty)
Examples:
  • GetAllEventsQuery
  • GetEventSeatmapQuery
  • GetOrderQuery

MediatR Implementation

Setup & Registration

<!-- services/inventory/src/Inventory.Application/Inventory.Application.csproj -->
<PackageReference Include="MediatR" Version="12.0.0" />
<PackageReference Include="MediatR.Extensions.Microsoft.DependencyInjection" Version="11.1.0" />
// services/inventory/src/Api/Program.cs
var builder = WebApplication.CreateBuilder(args);

// Register MediatR with all handlers in Application assembly
builder.Services.AddMediatR(cfg => 
    cfg.RegisterServicesFromAssembly(
        typeof(CreateReservationCommand).Assembly
    )
);

builder.Services.AddInfrastructure(builder.Configuration);

var app = builder.Build();
What This Does:
  • Scans Inventory.Application assembly for handlers
  • Registers all IRequestHandler<TRequest, TResponse> implementations
  • Enables dependency injection of IMediator

Commands in Detail

Commands are immutable records that implement IRequest<TResponse>:
// services/inventory/src/Application/UseCases/CreateReservation/CreateReservationCommand.cs
using MediatR;
using Inventory.Application.DTOs;

namespace Inventory.Application.UseCases.CreateReservation;

/// <summary>
/// Command to create a seat reservation with distributed locking.
/// </summary>
public record CreateReservationCommand(
    Guid SeatId,
    string CustomerId
) : IRequest<CreateReservationResponse>;

/// <summary>
/// Response containing the created reservation details.
/// </summary>
public record CreateReservationResponse(
    Guid ReservationId,
    Guid SeatId,
    string CustomerId,
    DateTime ExpiresAt,
    string Status
);
Design Principles:
  • Use record for immutability
  • Include all required data in constructor
  • Explicit response type for type safety
  • Self-documenting with XML comments

Queries in Detail

Queries request data without modifying state:
// services/catalog/src/Application/UseCases/GetAllEvents/GetAllEventsQuery.cs
using MediatR;

namespace Catalog.Application.UseCases.GetAllEvents;

/// <summary>
/// Query to retrieve all available events.
/// </summary>
public record GetAllEventsQuery : IRequest<GetAllEventsResponse>;

/// <summary>
/// Response containing the list of events.
/// </summary>
public record GetAllEventsResponse(
    IEnumerable<EventDto> Events
);

public record EventDto
{
    public Guid Id { get; init; }
    public string Name { get; init; } = string.Empty;
    public DateTime Date { get; init; }
    public string Location { get; init; } = string.Empty;
    public string Description { get; init; } = string.Empty;
    public int TotalSeats { get; init; }
    public int AvailableSeats { get; init; }
}
Query with Parameters:
// services/catalog/src/Application/UseCases/GetEventSeatmap/GetEventSeatmapQuery.cs
public record GetEventSeatmapQuery(Guid EventId) 
    : IRequest<GetEventSeatmapResponse>;

public record GetEventSeatmapResponse(
    Guid EventId,
    string EventName,
    IEnumerable<SeatDto> Seats
);

public record SeatDto
{
    public Guid Id { get; init; }
    public string Section { get; init; } = string.Empty;
    public string Row { get; init; } = string.Empty;
    public string Number { get; init; } = string.Empty;
    public bool Reserved { get; init; }
    public string? CurrentReservationId { get; init; }
}

Complete Service Examples

// services/ordering/src/Application/UseCases/AddToCart/AddToCartCommand.cs
public record AddToCartCommand(
    string SeatId,
    string? UserId,
    string? GuestToken
) : IRequest<AddToCartResponse>;

public record AddToCartResponse(
    Guid OrderId,
    string State,
    IEnumerable<OrderItemDto> Items,
    decimal TotalAmount
);
// services/ordering/src/Application/UseCases/AddToCart/AddToCartHandler.cs
public class AddToCartHandler 
    : IRequestHandler<AddToCartCommand, AddToCartResponse>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ReservationStore _reservationStore;

    public async Task<AddToCartResponse> Handle(
        AddToCartCommand request,
        CancellationToken cancellationToken)
    {
        // Validate reservation exists
        var reservation = _reservationStore.GetReservation(request.SeatId);
        if (reservation == null)
        {
            throw new InvalidOperationException(
                "No active reservation found for this seat"
            );
        }

        // Get or create draft order
        var order = await _orderRepository.GetDraftOrderAsync(
            request.UserId,
            request.GuestToken,
            cancellationToken
        );

        if (order == null)
        {
            order = new Order
            {
                Id = Guid.NewGuid(),
                UserId = request.UserId,
                GuestToken = request.GuestToken,
                State = "draft",
                CreatedAt = DateTime.UtcNow
            };
            await _orderRepository.CreateAsync(order, cancellationToken);
        }

        // Add item to order
        order.AddItem(reservation);
        await _orderRepository.UpdateAsync(order, cancellationToken);

        // Return response
        return new AddToCartResponse(
            OrderId: order.Id,
            State: order.State,
            Items: order.Items.Select(i => new OrderItemDto
            {
                SeatId = i.SeatId,
                SeatNumber = i.SeatNumber,
                Price = i.Price
            }),
            TotalAmount: order.TotalAmount
        );
    }
}
// services/ordering/src/Application/UseCases/CheckoutOrder/CheckoutOrderCommand.cs
public record CheckoutOrderCommand(
    Guid OrderId,
    string PaymentMethod,
    string? UserId,
    string? GuestToken
) : IRequest<CheckoutOrderResponse>;

public record CheckoutOrderResponse(
    Guid OrderId,
    string State,
    decimal TotalAmount,
    DateTime CheckedOutAt
);
// services/ordering/src/Application/UseCases/CheckoutOrder/CheckoutOrderHandler.cs
public class CheckoutOrderHandler 
    : IRequestHandler<CheckoutOrderCommand, CheckoutOrderResponse>
{
    private readonly IOrderRepository _orderRepository;
    private readonly ReservationStore _reservationStore;

    public async Task<CheckoutOrderResponse> Handle(
        CheckoutOrderCommand request,
        CancellationToken cancellationToken)
    {
        // Fetch order
        var order = await _orderRepository.GetByIdAsync(
            request.OrderId,
            cancellationToken
        );

        if (order == null)
            throw new KeyNotFoundException($"Order {request.OrderId} not found");

        // Validate ownership
        if (order.UserId != request.UserId && 
            order.GuestToken != request.GuestToken)
        {
            throw new UnauthorizedAccessException(
                "Order does not belong to user"
            );
        }

        // Validate state
        if (order.State != "draft")
        {
            throw new InvalidOperationException(
                $"Cannot checkout order in state: {order.State}"
            );
        }

        // Validate all reservations still active
        foreach (var item in order.Items)
        {
            var reservation = _reservationStore.GetReservation(item.SeatId);
            if (reservation == null || reservation.Status != "active")
            {
                throw new InvalidOperationException(
                    $"Reservation for seat {item.SeatId} is no longer valid"
                );
            }
        }

        // Update order state
        order.State = "pending_payment";
        order.PaymentMethod = request.PaymentMethod;
        order.CheckedOutAt = DateTime.UtcNow;

        await _orderRepository.UpdateAsync(order, cancellationToken);

        return new CheckoutOrderResponse(
            OrderId: order.Id,
            State: order.State,
            TotalAmount: order.TotalAmount,
            CheckedOutAt: order.CheckedOutAt.Value
        );
    }
}

CQRS Benefits in SpecKit

Separation of Concerns

Commands:
  • Complex validation logic
  • Distributed locking
  • Event publishing
  • Transaction management
Queries:
  • Simple data retrieval
  • Optimized projections
  • No side effects
  • Cacheable

Independent Scaling

Read vs Write Workloads:
  • Catalog queries (high volume) can be cached
  • Reservation commands (write-heavy) use locks
  • Can use read replicas for queries
  • Commands remain on primary database

Testability

Unit Testing:
[Fact]
public async Task Handle_ValidCommand_CreatesReservation()
{
    // Arrange
    var mockRedisLock = new MockRedisLock("token-123");
    var mockKafka = new MockKafkaProducer();
    var handler = new CreateReservationCommandHandler(
        context, mockRedisLock, mockKafka
    );
    var command = new CreateReservationCommand(
        seatId, "customer-123"
    );

    // Act
    var response = await handler.Handle(command, CancellationToken.None);

    // Assert
    Assert.NotEqual(Guid.Empty, response.ReservationId);
    Assert.Equal("active", response.Status);
}

Clear Intent

Task-Based Interface:Instead of generic CRUD:
repository.Create(reservation);
Use business-focused commands:
mediator.Send(new CreateReservationCommand(seatId, customerId));
Makes code self-documenting and aligned with business operations.

MediatR Pipeline Behaviors (Advanced)

MediatR supports pipeline behaviors for cross-cutting concerns:
// Logging behavior
public class LoggingBehavior<TRequest, TResponse> 
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;
        
        _logger.LogInformation(
            "Handling {RequestName}: {@Request}",
            requestName,
            request
        );

        var response = await next();

        _logger.LogInformation(
            "Handled {RequestName}: {@Response}",
            requestName,
            response
        );

        return response;
    }
}
Registration:
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(CreateReservationCommand).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
});
Other Use Cases:
  • Validation (FluentValidation integration)
  • Performance monitoring
  • Transaction management
  • Caching

CQRS vs Traditional Layered Architecture

// Controller
public class ReservationsController
{
    private readonly IReservationRepository _repository;
    private readonly IRedisLock _lock;
    private readonly IKafkaProducer _kafka;

    [HttpPost]
    public async Task<IActionResult> Create(CreateReservationRequest request)
    {
        // All logic in controller
        var lockToken = await _lock.AcquireLockAsync(...);
        try
        {
            var seat = await _repository.GetSeatAsync(request.SeatId);
            if (seat.Reserved) return BadRequest();
            
            var reservation = new Reservation { ... };
            await _repository.AddAsync(reservation);
            await _kafka.ProduceAsync(...);
            
            return Ok(reservation);
        }
        finally
        {
            await _lock.ReleaseLockAsync(...);
        }
    }
}
Problems:
  • Business logic in controller
  • Hard to test (requires mocking HTTP context)
  • Difficult to reuse logic

Event-Driven Architecture

See how commands publish domain events

Hexagonal Architecture

Understand how CQRS fits within ports & adapters

Microservices Design

Learn how each service implements CQRS

System Architecture

View complete architecture overview

Build docs developers (and LLMs) love