CQRS — Command Query Responsibility Segregation
CQRS splits an application’s operations into two distinct models: Commands (writes) and Queries (reads). Each side is optimized independently.
Core Concept
Section titled “Core Concept” ┌──────────┐ Command ┌──────────────────┐ Client │ │ ─────────────► │ Write Model │ │ │ │ (Domain + DB) │ │ │ Query └──────────────────┘ │ │ ─────────────► ┌──────────────────┐ └──────────┘ │ Read Model │ │ (Flat DTOs + DB)│ └──────────────────┘- Command: Changes state, returns nothing (or just an ID)
- Query: Returns data, never changes state
Without vs With CQRS
Section titled “Without vs With CQRS”Without CQRS — one service handles everything:
public class OrderService{ public async Task PlaceOrderAsync(PlaceOrderDto dto) { ... } // write public async Task<OrderDto> GetOrderAsync(Guid id) { ... } // read public async Task<List<OrderSummary>> GetOrdersAsync() { ... } // read}With CQRS — separated:
// Write sidepublic record PlaceOrderCommand(Guid CustomerId, List<OrderLine> Lines);public class PlaceOrderHandler : ICommandHandler<PlaceOrderCommand, Guid>{ public async Task<Guid> HandleAsync(PlaceOrderCommand cmd) { ... }}
// Read sidepublic record GetOrderQuery(Guid OrderId);public class GetOrderHandler : IQueryHandler<GetOrderQuery, OrderDto>{ public async Task<OrderDto> HandleAsync(GetOrderQuery query) { ... }}Implementing with MediatR (C#)
Section titled “Implementing with MediatR (C#)”dotnet add package MediatRCommand:
public record CreateProductCommand(string Name, decimal Price) : IRequest<Guid>;
public class CreateProductHandler : IRequestHandler<CreateProductCommand, Guid>{ private readonly IProductRepository _repo;
public CreateProductHandler(IProductRepository repo) => _repo = repo;
public async Task<Guid> Handle(CreateProductCommand cmd, CancellationToken ct) { var product = new Product(cmd.Name, cmd.Price); await _repo.AddAsync(product, ct); return product.Id; }}Query:
public record GetProductQuery(Guid Id) : IRequest<ProductDto?>;
public class GetProductHandler : IRequestHandler<GetProductQuery, ProductDto?>{ private readonly IReadDbContext _db;
public GetProductHandler(IReadDbContext db) => _db = db;
public async Task<ProductDto?> Handle(GetProductQuery query, CancellationToken ct) { return await _db.Products .Where(p => p.Id == query.Id) .Select(p => new ProductDto(p.Id, p.Name, p.Price)) .FirstOrDefaultAsync(ct); }}Controller:
[ApiController][Route("api/products")]public class ProductsController : ControllerBase{ private readonly IMediator _mediator;
public ProductsController(IMediator mediator) => _mediator = mediator;
[HttpPost] public async Task<IActionResult> Create(CreateProductCommand cmd) { var id = await _mediator.Send(cmd); return CreatedAtAction(nameof(Get), new { id }, null); }
[HttpGet("{id}")] public async Task<IActionResult> Get(Guid id) { var product = await _mediator.Send(new GetProductQuery(id)); return product is null ? NotFound() : Ok(product); }}Separate Read and Write Databases
Section titled “Separate Read and Write Databases”The real power of CQRS is using different databases optimized for each purpose:
Write side: SQL Server (normalized, ACID, strong consistency)Read side: Redis / Elasticsearch / denormalized SQL views (fast reads)Synchronize via domain events or outbox pattern:
// After saving to write DB, publish eventawait _eventBus.PublishAsync(new ProductCreatedEvent(product.Id, product.Name));
// Read model handler rebuilds the read projectionpublic class ProductCreatedHandler : IEventHandler<ProductCreatedEvent>{ public async Task HandleAsync(ProductCreatedEvent e) { await _readDb.UpsertProductSummaryAsync(new ProductSummary(e.Id, e.Name)); }}When to Use CQRS
Section titled “When to Use CQRS”Good fit for:
- High read-to-write ratio (reads can scale independently)
- Complex domain with many aggregate rules
- Different performance requirements for reads vs writes
- Event sourcing (CQRS pairs naturally with it)
Avoid when:
- Simple CRUD applications — CQRS adds significant complexity
- Small teams with limited bandwidth
- Requirements don’t justify the overhead
Benefits
Section titled “Benefits”| Benefit | Detail |
|---|---|
| Independent scaling | Scale read replicas without touching the write side |
| Simplified queries | Read models are flat DTOs — no complex joins |
| Clearer intent | Commands and queries are self-documenting |
| Easier testing | Handlers are small, focused, and easily unit testable |