--- name: cqs-patterns description: Command Query Separation (CQS) and CQRS patterns for .NET. Use when designing methods, handlers, and application architecture. Ensures predictable, testable code. allowed-tools: Read, Grep, Glob, Edit, Write --- # Command Query Separation (CQS) ## The Principle > A method should either be a **Command** that performs an action, or a **Query** that returns data, but not both. ``` ┌─────────────────────────────────────────────────────────┐ │ METHOD │ ├───────────────────────┬─────────────────────────────────┤ │ COMMAND │ QUERY │ ├───────────────────────┼─────────────────────────────────┤ │ - Changes state │ - Returns data │ │ - Returns void │ - No side effects │ │ - "Do something" │ - "Tell me something" │ │ - Imperative verbs │ - Noun or question │ │ (Create, Update, │ (Get, Find, Is, Has, │ │ Delete, Process) │ Calculate, Count) │ └───────────────────────┴─────────────────────────────────┘ ``` --- ## CQS at Method Level ### Violation Examples ```csharp // BAD: Method both modifies state AND returns data public class Stack { public T Pop() // Both removes item AND returns it { var item = _items[_count - 1]; _count--; return item; } } // BAD: Getter with side effects public class Counter { private int _value; public int Value { get { return _value++; } // Query that modifies state! } } // BAD: Command returns data public class UserService { public User CreateUser(string email, string name) // Returns created user { var user = new User { Email = email, Name = name }; _repository.Add(user); return user; // CQS violation } } ``` ### Correct CQS Implementation ```csharp // GOOD: Separated commands and queries public class Stack { // Query - returns data, no side effects public T Peek() { if (_count == 0) throw new InvalidOperationException("Stack is empty"); return _items[_count - 1]; } // Command - modifies state, returns void public void Pop() { if (_count == 0) throw new InvalidOperationException("Stack is empty"); _count--; } // Usage: separate calls var top = stack.Peek(); stack.Pop(); } // GOOD: Counter with pure query public class Counter { private int _value; public int Value => _value; // Pure query public void Increment() => _value++; // Command } // GOOD: User service with CQS public class UserService { // Command - creates user, returns identifier only public Guid CreateUser(string email, string name) { var userId = Guid.NewGuid(); var user = new User { Id = userId, Email = email, Name = name }; _repository.Add(user); return userId; // Returning ID is acceptable } // Query - retrieves user public User? GetUser(Guid userId) { return _repository.GetById(userId); } } ``` --- ## CQS Exceptions Some situations justify combining command and query: ### 1. Atomic Operations ```csharp // Acceptable: Interlocked operations need to be atomic public int IncrementAndGet() { return Interlocked.Increment(ref _counter); } // Acceptable: Compare-and-swap patterns public bool TryUpdate(int expected, int newValue) { return Interlocked.CompareExchange(ref _value, newValue, expected) == expected; } ``` ### 2. Fluent APIs ```csharp // Acceptable: Builder pattern returns this public class QueryBuilder { public QueryBuilder Where(string condition) { _conditions.Add(condition); return this; // Returns modified builder } public QueryBuilder OrderBy(string column) { _orderBy = column; return this; } } ``` ### 3. Factory Methods ```csharp // Acceptable: Creation returns the created object public static Order Create(int customerId, IEnumerable items) { return new Order { Id = Guid.NewGuid(), CustomerId = customerId, Items = items.ToList() }; } ``` --- ## CQRS - Command Query Responsibility Segregation CQRS applies CQS at the **architectural level**, using separate models for reads and writes. ``` ┌─────────────────────────────────────┐ │ APPLICATION │ └─────────────────────────────────────┘ │ ┌───────────────┴───────────────┐ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ COMMANDS │ │ QUERIES │ │ (Write) │ │ (Read) │ └───────────────┘ └───────────────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ Command │ │ Query │ │ Handlers │ │ Handlers │ └───────────────┘ └───────────────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ Domain Model │ │ Read Model │ │ (Rich) │ │ (DTOs) │ └───────────────┘ └───────────────┘ │ │ ▼ ▼ ┌───────────────┐ ┌───────────────┐ │ Write DB │──────────────▶│ Read DB │ │ (Normalized) │ Sync/Events │ (Optimized) │ └───────────────┘ └───────────────┘ ``` ### Commands and Queries ```csharp // === MARKER INTERFACES === public interface ICommand { } public interface ICommand { } public interface IQuery { } // === COMMANDS === public record CreateOrderCommand( int CustomerId, List Items ) : ICommand; public record UpdateOrderStatusCommand( Guid OrderId, OrderStatus NewStatus ) : ICommand; public record CancelOrderCommand(Guid OrderId) : ICommand; // === QUERIES === public record GetOrderByIdQuery(Guid OrderId) : IQuery; public record GetOrdersByCustomerQuery( int CustomerId, int Page = 1, int PageSize = 10 ) : IQuery>; public record GetOrderStatisticsQuery( DateTime From, DateTime To ) : IQuery; ``` ### Command Handlers ```csharp // === HANDLER INTERFACES === public interface ICommandHandler where TCommand : ICommand { Task HandleAsync(TCommand command, CancellationToken ct = default); } public interface ICommandHandler where TCommand : ICommand { Task HandleAsync(TCommand command, CancellationToken ct = default); } // === IMPLEMENTATION === public class CreateOrderCommandHandler : ICommandHandler { private readonly IOrderRepository _orderRepository; private readonly ICustomerRepository _customerRepository; private readonly IEventPublisher _eventPublisher; public CreateOrderCommandHandler( IOrderRepository orderRepository, ICustomerRepository customerRepository, IEventPublisher eventPublisher) { _orderRepository = orderRepository; _customerRepository = customerRepository; _eventPublisher = eventPublisher; } public async Task HandleAsync(CreateOrderCommand command, CancellationToken ct) { // Validate var customer = await _customerRepository.GetByIdAsync(command.CustomerId, ct); if (customer == null) throw new CustomerNotFoundException(command.CustomerId); // Create domain entity var order = Order.Create(command.CustomerId); foreach (var item in command.Items) { order.AddItem(item.ProductId, item.Quantity, item.UnitPrice); } // Persist await _orderRepository.AddAsync(order, ct); // Publish domain event await _eventPublisher.PublishAsync(new OrderCreatedEvent(order.Id), ct); return order.Id; } } public class CancelOrderCommandHandler : ICommandHandler { private readonly IOrderRepository _orderRepository; private readonly IEventPublisher _eventPublisher; public async Task HandleAsync(CancelOrderCommand command, CancellationToken ct) { var order = await _orderRepository.GetByIdAsync(command.OrderId, ct); if (order == null) throw new OrderNotFoundException(command.OrderId); order.Cancel(); await _orderRepository.UpdateAsync(order, ct); await _eventPublisher.PublishAsync(new OrderCancelledEvent(order.Id), ct); } } ``` ### Query Handlers ```csharp public interface IQueryHandler where TQuery : IQuery { Task HandleAsync(TQuery query, CancellationToken ct = default); } public class GetOrderByIdQueryHandler : IQueryHandler { private readonly IDbConnection _connection; public GetOrderByIdQueryHandler(IDbConnection connection) { _connection = connection; } public async Task HandleAsync(GetOrderByIdQuery query, CancellationToken ct) { // Direct SQL for optimized reads const string sql = @" SELECT o.Id, o.CustomerId, o.Status, o.Total, o.CreatedAt, c.Name as CustomerName, c.Email as CustomerEmail FROM Orders o JOIN Customers c ON o.CustomerId = c.Id WHERE o.Id = @OrderId"; return await _connection.QuerySingleOrDefaultAsync( new CommandDefinition(sql, new { query.OrderId }, cancellationToken: ct)); } } public class GetOrdersByCustomerQueryHandler : IQueryHandler> { private readonly IDbConnection _connection; public async Task> HandleAsync( GetOrdersByCustomerQuery query, CancellationToken ct) { const string sql = @" SELECT Id, Status, Total, CreatedAt FROM Orders WHERE CustomerId = @CustomerId ORDER BY CreatedAt DESC OFFSET @Offset ROWS FETCH NEXT @PageSize ROWS ONLY; SELECT COUNT(*) FROM Orders WHERE CustomerId = @CustomerId;"; using var multi = await _connection.QueryMultipleAsync( new CommandDefinition(sql, new { query.CustomerId, Offset = (query.Page - 1) * query.PageSize, query.PageSize }, cancellationToken: ct)); var items = (await multi.ReadAsync()).ToList(); var totalCount = await multi.ReadSingleAsync(); return new PagedResult(items, query.Page, query.PageSize, totalCount); } } ``` ### Dispatcher Pattern ```csharp public interface IDispatcher { Task SendAsync(ICommand command, CancellationToken ct = default); Task SendAsync(ICommand command, CancellationToken ct = default); Task QueryAsync(IQuery query, CancellationToken ct = default); } public class Dispatcher : IDispatcher { private readonly IServiceProvider _serviceProvider; public Dispatcher(IServiceProvider serviceProvider) { _serviceProvider = serviceProvider; } public async Task SendAsync(ICommand command, CancellationToken ct) { var handlerType = typeof(ICommandHandler<,>).MakeGenericType(command.GetType(), typeof(TResult)); dynamic handler = _serviceProvider.GetRequiredService(handlerType); return await handler.HandleAsync((dynamic)command, ct); } public async Task SendAsync(ICommand command, CancellationToken ct) { var handlerType = typeof(ICommandHandler<>).MakeGenericType(command.GetType()); dynamic handler = _serviceProvider.GetRequiredService(handlerType); await handler.HandleAsync((dynamic)command, ct); } public async Task QueryAsync(IQuery query, CancellationToken ct) { var handlerType = typeof(IQueryHandler<,>).MakeGenericType(query.GetType(), typeof(TResult)); dynamic handler = _serviceProvider.GetRequiredService(handlerType); return await handler.HandleAsync((dynamic)query, ct); } } ``` ### Usage in Controllers ```csharp [ApiController] [Route("api/[controller]")] public class OrdersController : ControllerBase { private readonly IDispatcher _dispatcher; public OrdersController(IDispatcher dispatcher) { _dispatcher = dispatcher; } [HttpPost] public async Task> CreateOrder( CreateOrderRequest request, CancellationToken ct) { var command = new CreateOrderCommand(request.CustomerId, request.Items); var orderId = await _dispatcher.SendAsync(command, ct); return CreatedAtAction(nameof(GetOrder), new { id = orderId }, orderId); } [HttpGet("{id}")] public async Task> GetOrder(Guid id, CancellationToken ct) { var query = new GetOrderByIdQuery(id); var order = await _dispatcher.QueryAsync(query, ct); return order == null ? NotFound() : Ok(order); } [HttpPost("{id}/cancel")] public async Task CancelOrder(Guid id, CancellationToken ct) { var command = new CancelOrderCommand(id); await _dispatcher.SendAsync(command, ct); return NoContent(); } } ``` --- ## Benefits of CQS/CQRS | Benefit | Description | |---------|-------------| | **Testability** | Commands and queries are isolated, easy to test | | **Scalability** | Read and write sides can scale independently | | **Optimization** | Read models optimized for queries, write models for business rules | | **Clarity** | Clear intent - methods either change state or return data | | **Maintainability** | Single responsibility, easier to modify | | **Debugging** | Queries are safe to call repeatedly | --- ## When to Use CQRS ### Good Fit - Complex domains with different read/write patterns - High-read, low-write scenarios - Systems requiring audit trails - Event-sourced systems - Microservices with separate read replicas ### Not Needed - Simple CRUD applications - Small teams/projects - Consistent read/write patterns - When added complexity outweighs benefits --- ## Quick Reference ```csharp // Pure Query - Safe, no side effects public Customer? GetCustomer(int id); public IReadOnlyList FindOrdersByStatus(OrderStatus status); public bool IsEmailAvailable(string email); public int CountActiveUsers(); public decimal CalculateTotalRevenue(DateTime from, DateTime to); // Pure Command - Changes state, returns void (or ID) public void CreateCustomer(Customer customer); public void UpdateOrderStatus(Guid orderId, OrderStatus status); public void DeleteProduct(int productId); public Guid PlaceOrder(OrderRequest request); // ID return is acceptable ```