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
-
Complex Business Logic
- Many validation rules
- Complex workflows
- Domain-driven design
-
Different Read/Write Patterns
- Read-heavy system (10:1 read:write ratio)
- Complex queries vs simple writes
- Need different optimizations
-
Audit Requirements
- Track all changes
- Who did what when
- Compliance (GDPR, SOX, HIPAA)
-
Scalability Needs
- High read traffic
- Geographic distribution
- Read replicas
-
Event-Driven Architecture
- Microservices
- Event sourcing
- Real-time updates
❌ Not Recommended
-
Simple CRUD
- Basic create/read/update/delete
- No complex business logic
- Same model for reads and writes
-
Small Applications
- Few entities
- Simple workflows
- Small team
-
Tight Deadlines
- Team unfamiliar with CQRS
- Need to ship quickly
- Prototype/MVP
-
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?
- Metadata Discovery - How Svrnty.CQRS discovers commands/queries
- Modular Solution Structure - Organizing your CQRS application
- Event Streaming - Event-sourced CQRS
See Also
- Getting Started: Introduction - CQRS fundamentals
- Best Practices: Command Design - Designing effective commands
- Best Practices: Query Design - Query optimization patterns
- Tutorials: E-Commerce Example - Real-world CQRS application