583 lines
15 KiB
Markdown
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
|