dotnet-cqrs/docs/architecture/cqrs-pattern.md

15 KiB

CQRS Pattern

A deep dive into the Command Query Responsibility Segregation pattern and how Svrnty.CQRS implements it.

What is CQRS?

CQRS (Command Query Responsibility Segregation) is an architectural pattern that separates read operations (queries) from write operations (commands).

Core Principle

"A method should either change the state of an object or return a result, but not both." - Bertrand Meyer (Command-Query Separation)

CQRS extends this principle to the architectural level.

Pattern Components

Commands (Write Operations)

Commands change system state but typically don't return data.

Characteristics:

  • Imperative naming (CreateUser, PlaceOrder, CancelSubscription)
  • Contain all data needed for the operation
  • May return confirmation data (ID, status)
  • Should be validated
  • Can fail (validation, business rules)
  • Not idempotent (usually)

Example:

// Command definition
public record PlaceOrderCommand
{
    public int CustomerId { get; init; }
    public List<OrderItem> Items { get; init; } = new();
    public string ShippingAddress { get; init; } = string.Empty;
}

// Handler
public class PlaceOrderCommandHandler : ICommandHandler<PlaceOrderCommand, int>
{
    private readonly IOrderRepository _orders;
    private readonly IInventoryService _inventory;
    private readonly IEventPublisher _events;

    public async Task<int> HandleAsync(PlaceOrderCommand command, CancellationToken cancellationToken)
    {
        // 1. Validate business rules
        await ValidateInventory(command.Items);

        // 2. Change state
        var order = new Order
        {
            CustomerId = command.CustomerId,
            Items = command.Items,
            Status = OrderStatus.Pending
        };

        await _orders.AddAsync(order, cancellationToken);

        // 3. Publish events
        await _events.PublishAsync(new OrderPlacedEvent { OrderId = order.Id });

        // 4. Return result
        return order.Id;
    }
}

Queries (Read Operations)

Queries return data without changing state.

Characteristics:

  • Question-based naming (GetOrder, SearchProducts, FetchCustomer)
  • Never modify state
  • Always return data
  • Idempotent (can call multiple times)
  • Can be cached
  • Should be fast

Example:

// Query definition
public record GetOrderQuery
{
    public int OrderId { get; init; }
}

// DTO (what we return)
public record OrderDto
{
    public int Id { get; init; }
    public string CustomerName { get; init; } = string.Empty;
    public List<OrderItemDto> Items { get; init; } = new();
    public decimal TotalAmount { get; init; }
    public string Status { get; init; } = string.Empty;
}

// Handler
public class GetOrderQueryHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
    private readonly IOrderRepository _orders;

    public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken cancellationToken)
    {
        // 1. Fetch data (no state changes)
        var order = await _orders.GetByIdAsync(query.OrderId, cancellationToken);

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

        // 2. Map to DTO
        return new OrderDto
        {
            Id = order.Id,
            CustomerName = order.Customer.Name,
            Items = order.Items.Select(MapToDto).ToList(),
            TotalAmount = order.TotalAmount,
            Status = order.Status.ToString()
        };
    }
}

Benefits of CQRS

1. Separation of Concerns

Problem: Traditional services mix reads and writes:

public class OrderService
{
    public void CreateOrder(OrderDto dto) { /* write */ }
    public void UpdateOrder(int id, OrderDto dto) { /* write */ }
    public void CancelOrder(int id) { /* write */ }
    public OrderDto GetOrder(int id) { /* read */ }
    public List<OrderDto> SearchOrders(string criteria) { /* read */ }
}

Solution: Separate into commands and queries:

// Write operations
CreateOrderCommandHandler
UpdateOrderCommandHandler
CancelOrderCommandHandler

// Read operations
GetOrderQueryHandler
SearchOrdersQueryHandler

Benefits:

  • Smaller, focused handlers
  • Single Responsibility Principle
  • Easier to understand and maintain

2. Independent Scaling

Scale reads and writes independently:

                    ┌─ Read Replica 1
Read Model (SQL) ───┼─ Read Replica 2
                    └─ Read Replica 3

Write Model (SQL) ─── Single Writer

Benefits:

  • Read-heavy systems can scale reads
  • Write-heavy systems can optimize writes
  • Different storage technologies (SQL for writes, NoSQL for reads)

3. Optimized Models

Different models for different purposes:

Write Model (Normalized):

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public Customer Customer { get; set; } = null!;
    public List<OrderLine> Lines { get; set; } = new();
}

public class OrderLine
{
    public int Id { get; set; }
    public int ProductId { get; set; }
    public Product Product { get; set; } = null!;
    public int Quantity { get; set; }
}

Read Model (Denormalized):

public class OrderSummary
{
    public int OrderId { get; set; }
    public string CustomerName { get; set; } = string.Empty;
    public string CustomerEmail { get; set; } = string.Empty;
    public int TotalItems { get; set; }
    public decimal TotalAmount { get; set; }
    public string Status { get; set; } = string.Empty;
    // Pre-computed, optimized for display
}

Benefits:

  • Write model enforces referential integrity
  • Read model optimized for queries
  • No JOIN overhead on reads

4. Fine-Grained Security

Authorization per command/query:

public class PlaceOrderAuthorizationService : ICommandAuthorizationService<PlaceOrderCommand>
{
    public Task<bool> CanExecuteAsync(PlaceOrderCommand command, ClaimsPrincipal user)
    {
        // Only authenticated users can place orders
        return Task.FromResult(user.Identity?.IsAuthenticated == true);
    }
}

public class ViewOrderAuthorizationService : IQueryAuthorizationService<GetOrderQuery>
{
    public async Task<bool> CanExecuteAsync(GetOrderQuery query, ClaimsPrincipal user)
    {
        // Users can only view their own orders (or admins)
        if (user.IsInRole("Admin"))
            return true;

        var userId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var order = await _orders.GetByIdAsync(query.OrderId);

        return order?.CustomerId.ToString() == userId;
    }
}

Benefits:

  • Granular permissions
  • Different permissions for reads vs writes
  • Easy to audit

5. Testability

Handlers are easy to unit test:

[Fact]
public async Task PlaceOrder_WithValidData_ReturnsOrderId()
{
    // Arrange
    var mockRepository = new Mock<IOrderRepository>();
    var handler = new PlaceOrderCommandHandler(mockRepository.Object);

    var command = new PlaceOrderCommand
    {
        CustomerId = 1,
        Items = new List<OrderItem> { new() { ProductId = 1, Quantity = 2 } }
    };

    // Act
    var result = await handler.HandleAsync(command, CancellationToken.None);

    // Assert
    Assert.True(result > 0);
    mockRepository.Verify(r => r.AddAsync(It.IsAny<Order>(), It.IsAny<CancellationToken>()), Times.Once);
}

6. Event Sourcing Integration

CQRS naturally fits with event sourcing:

public class PlaceOrderCommandHandler : ICommandHandlerWithWorkflow<PlaceOrderCommand, int, OrderWorkflow>
{
    public async Task<int> HandleAsync(PlaceOrderCommand command, OrderWorkflow workflow, CancellationToken cancellationToken)
    {
        // Create order
        var order = new Order { /* ... */ };

        // Emit event (event sourcing)
        workflow.EmitOrderPlaced(new OrderPlacedEvent
        {
            OrderId = order.Id,
            CustomerId = command.CustomerId,
            Items = command.Items
        });

        return order.Id;
    }
}

Trade-offs

Complexity

Cost: More files, classes, and concepts to learn.

Mitigation:

  • Start simple (single-project structure)
  • Use templates/code generators
  • Good naming conventions
  • Clear documentation

Code Duplication

Cost: Similar logic may appear in multiple handlers.

Mitigation:

  • Shared services for common logic
  • Base handler classes (if appropriate)
  • Domain services
  • Accept some duplication (prefer clarity over DRY)

Eventual Consistency

Cost: With separate read/write models, reads may lag behind writes.

Example:

// User places order
POST /api/command/placeOrder
 Returns 201 Created with orderId: 123

// Immediately query order
GET /api/query/getOrder?orderId=123
 May return 404 if read model not updated yet

Mitigation:

  • Return complete data from commands
  • Client-side optimistic updates
  • Polling/WebSockets for updates
  • Accept eventual consistency for non-critical data

CQRS Variants

1. Simple CQRS (Svrnty.CQRS Default)

Same database, different models:

Commands → Write Handlers → Database
Queries  → Read Handlers  → Database (same)

Benefits:

  • Simple to understand
  • No data synchronization
  • Strong consistency

2. CQRS with Read Models

Separate read models (views, projections):

Commands → Write Handlers → Database (write)
                                ↓
                          Event Publisher
                                ↓
                          Projection Handlers
                                ↓
Queries  → Read Handlers  → Database (read)

Benefits:

  • Optimized read models
  • Different database technologies
  • Eventual consistency acceptable

3. Event-Sourced CQRS

Events are the source of truth:

Commands → Write Handlers → Event Store (append-only)
                                ↓
                          Event Subscribers
                                ↓
                          Projection Handlers
                                ↓
Queries  → Read Handlers  → Projections (materialized views)

Benefits:

  • Complete audit trail
  • Time-travel debugging
  • Rebuild projections from events
  • Event replay

When to Use CQRS

Good Fit

  1. Complex Business Logic

    • Many validation rules
    • Complex workflows
    • Domain-driven design
  2. Different Read/Write Patterns

    • Read-heavy system (10:1 read:write ratio)
    • Complex queries vs simple writes
    • Need different optimizations
  3. Audit Requirements

    • Track all changes
    • Who did what when
    • Compliance (GDPR, SOX, HIPAA)
  4. Scalability Needs

    • High read traffic
    • Geographic distribution
    • Read replicas
  5. Event-Driven Architecture

    • Microservices
    • Event sourcing
    • Real-time updates
  1. Simple CRUD

    • Basic create/read/update/delete
    • No complex business logic
    • Same model for reads and writes
  2. Small Applications

    • Few entities
    • Simple workflows
    • Small team
  3. Tight Deadlines

    • Team unfamiliar with CQRS
    • Need to ship quickly
    • Prototype/MVP
  4. Strong Consistency Required

    • All reads must reflect latest writes immediately
    • No eventual consistency acceptable
    • Simple CQRS (same DB) might work

Anti-Patterns

1. CQRS for Everything

Problem: Using CQRS for simple CRUD operations.

Solution: Use CQRS selectively for complex domains.

2. Anemic Handlers

Problem: Handlers with no logic, just calling repository.

public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
    // ❌ No validation, no business logic
    return await _repository.AddAsync(new User { Name = command.Name });
}

Solution: Put business logic in handlers.

public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
{
    // ✅ Validation, business rules, events
    await ValidateUniqueEmail(command.Email);

    var user = User.Create(command.Name, command.Email);
    await _repository.AddAsync(user);
    await _events.PublishAsync(new UserCreatedEvent { UserId = user.Id });

    return user.Id;
}

3. Returning Domain Entities from Queries

Problem: Exposing internal domain models.

// ❌ Don't return domain entities
public async Task<User> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
    return await _repository.GetByIdAsync(query.UserId);
}

Solution: Always return DTOs.

// ✅ Return DTOs
public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
    var user = await _repository.GetByIdAsync(query.UserId);
    return new UserDto { Id = user.Id, Name = user.Name, Email = user.Email };
}

4. Queries That Modify State

Problem: Queries with side effects.

// ❌ Query that changes state
public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken cancellationToken)
{
    var order = await _repository.GetByIdAsync(query.OrderId);

    order.LastViewedAt = DateTime.UtcNow; // ❌ Modifying state!
    await _repository.UpdateAsync(order);

    return MapToDto(order);
}

Solution: Keep queries read-only. Use commands for state changes.

// ✅ Separate command for tracking
POST /api/command/trackOrderView { "orderId": 123 }

// ✅ Query is read-only
GET /api/query/getOrder?orderId=123

CQRS in Svrnty.CQRS

Svrnty.CQRS implements Simple CQRS by default:

// Commands change state
public class CreateUserCommandHandler : ICommandHandler<CreateUserCommand, int>
{
    public async Task<int> HandleAsync(CreateUserCommand command, CancellationToken cancellationToken)
    {
        // Write to database
    }
}

// Queries return data
public class GetUserQueryHandler : IQueryHandler<GetUserQuery, UserDto>
{
    public async Task<UserDto> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
    {
        // Read from database
    }
}

Optional upgrades:

  • Event streaming for event-sourced CQRS
  • Projections for optimized read models
  • Consumer groups for scalable event processing

What's Next?

See Also