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

583 lines
15 KiB
Markdown

# 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:**
```csharp
// 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:**
```csharp
// 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:
```csharp
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:
```csharp
// 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):**
```csharp
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):**
```csharp
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:
```csharp
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:
```csharp
[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:
```csharp
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:**
```csharp
// 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
### ❌ Not Recommended
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.
```csharp
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.
```csharp
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.
```csharp
// ❌ Don't return domain entities
public async Task<User> HandleAsync(GetUserQuery query, CancellationToken cancellationToken)
{
return await _repository.GetByIdAsync(query.UserId);
}
```
**Solution:** Always return DTOs.
```csharp
// ✅ 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.
```csharp
// ❌ 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.
```csharp
// ✅ 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:
```csharp
// 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](metadata-discovery.md)** - How Svrnty.CQRS discovers commands/queries
- **[Modular Solution Structure](modular-solution-structure.md)** - Organizing your CQRS application
- **[Event Streaming](../event-streaming/README.md)** - Event-sourced CQRS
## See Also
- [Getting Started: Introduction](../getting-started/01-introduction.md) - CQRS fundamentals
- [Best Practices: Command Design](../best-practices/command-design.md) - Designing effective commands
- [Best Practices: Query Design](../best-practices/query-design.md) - Query optimization patterns
- [Tutorials: E-Commerce Example](../tutorials/ecommerce-example/README.md) - Real-world CQRS application