Skip to content

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.

┌──────────┐ 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 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 side
public record PlaceOrderCommand(Guid CustomerId, List<OrderLine> Lines);
public class PlaceOrderHandler : ICommandHandler<PlaceOrderCommand, Guid>
{
public async Task<Guid> HandleAsync(PlaceOrderCommand cmd) { ... }
}
// Read side
public record GetOrderQuery(Guid OrderId);
public class GetOrderHandler : IQueryHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> HandleAsync(GetOrderQuery query) { ... }
}
Terminal window
dotnet add package MediatR

Command:

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);
}
}

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 event
await _eventBus.PublishAsync(new ProductCreatedEvent(product.Id, product.Name));
// Read model handler rebuilds the read projection
public class ProductCreatedHandler : IEventHandler<ProductCreatedEvent>
{
public async Task HandleAsync(ProductCreatedEvent e)
{
await _readDb.UpsertProductSummaryAsync(new ProductSummary(e.Id, e.Name));
}
}

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
BenefitDetail
Independent scalingScale read replicas without touching the write side
Simplified queriesRead models are flat DTOs — no complex joins
Clearer intentCommands and queries are self-documenting
Easier testingHandlers are small, focused, and easily unit testable