CQRS — Command Query Responsibility Segregation — gets a bad reputation for complexity. Most developers picture microservices, Kafka, and whiteboards full of arrows. But it doesn't have to be that way.
In this deep dive, I'll show you how to implement CQRS in .NET with a real production-ready example that you can apply immediately.
What CQRS Actually Means
The core principle is simple:
- Commands change state (create, update, delete). They return minimal data.
- Queries read state. They never mutate anything.
That's it. No mandatory bus. No separate databases. No PhD in messaging.
The Problem with Traditional CRUD
In a traditional CRUD API, the same service handles everything. This works at small scale, but you'll hit friction points:
- A read query unnecessarily loads a domain model packed with validation logic
- A write operation eager-loads relationships just to compute a return value
- Your
OrderServiceballoons to 1,500 lines and every change feels risky
Real-World Example: Order Management
Let's build an Order management feature — the kind you'd see in an e-commerce or inventory system.
Project Structure
I use a feature-slice layout inspired by Vertical Slice Architecture:
/Features
/Orders
/Commands
CreateOrderCommand.cs
CreateOrderCommandHandler.cs
UpdateOrderStatusCommand.cs
UpdateOrderStatusCommandHandler.cs
/Queries
GetOrderByIdQuery.cs
GetOrderByIdQueryHandler.cs
GetOrdersByCustomerQuery.cs
GetOrdersByCustomerQueryHandler.cs
OrderDto.cs
This beats the traditional /Services, /Repositories, /Models split. You stop hunting across folders.
Implementing Commands
The CreateOrder Command
// CreateOrderCommand.cs
public record CreateOrderCommand(
Guid CustomerId,
List<OrderItemDto> Items,
ShippingAddress Address
) : IRequest<Guid>;
// CreateOrderCommandHandler.cs
public class CreateOrderCommandHandler
: IRequestHandler<CreateOrderCommand, Guid>
{
private readonly AppDbContext _db;
public CreateOrderCommandHandler(AppDbContext db)
{
_db = db;
}
public async Task<Guid> Handle(
CreateOrderCommand request,
CancellationToken cancellationToken)
{
// Business validation
if (!await _db.Customers.ExistsAsync(request.CustomerId, cancellationToken))
throw new NotFoundException("Customer not found");
var order = new Order
{
Id = Guid.NewGuid(),
CustomerId = request.CustomerId,
Status = OrderStatus.Pending,
Items = request.Items.Select(i => new OrderItem
{
ProductId = i.ProductId,
Quantity = i.Quantity,
UnitPrice = await _db.Products.GetPriceAsync(i.ProductId)
}).ToList(),
ShippingAddress = request.Address,
CreatedAt = DateTime.UtcNow
};
// Domain event for side effects
order.AddDomainEvent(new OrderCreatedEvent(order.Id, order.CustomerId));
_db.Orders.Add(order);
await _db.SaveChangesAsync(cancellationToken);
return order.Id;
}
}
Key insight: Commands do work and return only the new ID. No need to load the full object back.
The UpdateOrderStatus Command
// UpdateOrderStatusCommand.cs
public record UpdateOrderStatusCommand(
Guid OrderId,
OrderStatus NewStatus,
string Reason
) : IRequest<bool>;
// UpdateOrderStatusCommandHandler.cs
public class UpdateOrderStatusCommandHandler
: IRequestHandler<UpdateOrderStatusCommand, bool>
{
private readonly AppDbContext _db;
private readonly IEventBus _eventBus;
public UpdateOrderStatusCommandHandler(
AppDbContext db,
IEventBus eventBus)
{
_db = db;
_eventBus = eventBus;
}
public async Task<bool> Handle(
UpdateOrderStatusCommand request,
CancellationToken cancellationToken)
{
var order = await _db.Orders
.FirstOrDefaultAsync(o => o.Id == request.OrderId, cancellationToken);
if (order is null)
return false;
var previousStatus = order.Status;
order.UpdateStatus(request.NewStatus, request.Reason);
// Publish events for status changes
if (request.NewStatus == OrderStatus.Shipped)
{
order.AddDomainEvent(new OrderShippedEvent(order.Id));
}
await _db.SaveChangesAsync(cancellationToken);
return true;
}
}
Implementing Queries
The GetOrderById Query
// GetOrderByIdQuery.cs
public record GetOrderByIdQuery(Guid OrderId)
: IRequest<OrderDetailDto?>;
// GetOrderByIdQueryHandler.cs
public class GetOrderByIdQueryHandler
: IRequestHandler<GetOrderByIdQuery, OrderDetailDto?>
{
private readonly AppDbContext _db;
public GetOrderByIdQueryHandler(AppDbContext db)
{
_db = db;
}
public async Task<OrderDetailDto?> Handle(
GetOrderByIdQuery request,
CancellationToken cancellationToken)
{
return await _db.Orders
.AsNoTracking()
.Where(o => o.Id == request.OrderId)
.Select(o => new OrderDetailDto(
o.Id,
o.CustomerId,
o.Customer.Name,
o.Status,
o.Items.Select(i => new OrderItemDto(
i.ProductId,
i.Product.Name,
i.Quantity,
i.UnitPrice
)).ToList(),
o.ShippingAddress.ToDto(),
o.TotalAmount,
o.CreatedAt,
o.UpdatedAt
))
.FirstOrDefaultAsync(cancellationToken);
}
}
Two critical optimizations:
AsNoTracking()— Queries never mutate data, so EF's change tracker is pure overhead. This alone cuts query times by 20-30% on high-volume endpoints.Project directly to DTO in SQL — Use
.Select()to project at the database level. Never load full entities when you only need a few columns.
The GetOrdersByCustomer Query (Read Model Optimization)
// GetOrdersByCustomerQuery.cs
public record GetOrdersByCustomerQuery(
Guid CustomerId,
int Page = 1,
int PageSize = 20
) : IRequest<PagedResult<OrderSummaryDto>>;
// GetOrdersByCustomerQueryHandler.cs
public class GetOrdersByCustomerQueryHandler
: IRequestHandler<GetOrdersByCustomerQuery, PagedResult<OrderSummaryDto>>
{
private readonly ReadOnlyDbContext _readDb; // Separate read DB
public GetOrdersByCustomerQueryHandler(ReadOnlyDbContext readDb)
{
_readDb = readDb;
}
public async Task<PagedResult<OrderSummaryDto>> Handle(
GetOrdersByCustomerQuery request,
CancellationToken cancellationToken)
{
var query = _readDb.OrderSummaries
.AsNoTracking()
.Where(s => s.CustomerId == request.CustomerId);
var total = await query.CountAsync(cancellationToken);
var items = await query
.OrderByDescending(o => o.CreatedAt)
.Skip((request.Page - 1) * request.PageSize)
.Take(request.PageSize)
.ToListAsync(cancellationToken);
return new PagedResult<OrderSummaryDto>(items, total, request.Page, request.PageSize);
}
}
Notice the separate read database context. This is the CQRS pattern scaling up — different models optimized for different workloads.
Pipeline Behaviors: Validation & Logging
Don't put validation inside handlers. Use MediatR's IPipelineBehavior:
public class ValidationBehavior<TRequest, TResponse>
: IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly IEnumerable<IValidator<TRequest>> _validators;
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
{
_validators = validators;
}
public async Task<TResponse> Handle(
TRequest request,
RequestHandlerDelegate<TResponse> next,
CancellationToken cancellationToken)
{
if (!_validators.Any())
return await next();
var context = new ValidationContext<TRequest>(request);
var failures = _validators
.Select(v => v.Validate(context))
.SelectMany(r => r.Errors)
.Where(f => f != null)
.ToList();
if (failures.Count != 0)
throw new ValidationException(failures);
return await next();
}
}
// FluentValidation example
public class CreateOrderCommandValidator
: AbstractValidator<CreateOrderCommand>
{
public CreateOrderCommandValidator()
{
RuleFor(x => x.CustomerId)
.NotEmpty();
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("Order must have at least one item");
RuleForEach(x => x.Items)
.ChildRules(item =>
{
item.RuleFor(i => i.Quantity)
.GreaterThan(0);
});
}
}
Thin Controllers
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator)
{
_mediator = mediator;
}
[HttpPost]
public async Task<IActionResult> Create(
[FromBody] CreateOrderCommand command,
CancellationToken ct)
{
var id = await _mediator.Send(command, ct);
return CreatedAtAction(nameof(GetById), new { id }, new { Id = id });
}
[HttpGet("{id:guid}")]
public async Task<IActionResult> GetById(Guid id, CancellationToken ct)
{
var result = await _mediator.Send(new GetOrderByIdQuery(id), ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("customer/{customerId:guid}")]
public async Task<IActionResult> GetByCustomer(
Guid customerId,
[FromQuery] int page = 1,
[FromQuery] int pageSize = 20,
CancellationToken ct = default)
{
var result = await _mediator.Send(
new GetOrdersByCustomerQuery(customerId, page, pageSize), ct);
return Ok(result);
}
}
The controller is now a thin routing layer. It knows nothing about databases, business rules, or domain models.
When NOT to Use CQRS
I've seen teams apply CQRS to every microservice regardless of complexity. For a service with 5 endpoints and simple CRUD, it's overkill. Apply it when:
- The domain has real business logic beyond simple data mapping
- Read and write loads are significantly asymmetric
- Multiple developers work on the same service simultaneously
- You need clear audit trails or plan to add Event Sourcing later
For simpler services, a straightforward repository pattern or minimal API is the better call.
Key Takeaways
- CQRS is a code-level pattern — separate read/write models in code, not necessarily separate databases
- MediatR makes it clean — thin controllers, focused handlers, easy unit testing
-
Always use
AsNoTracking()in query handlers — EF's change tracker is pure overhead -
Project to DTOs at the database level with
.Select()— never load full entities when you only need a few columns - Pipeline behaviors handle cross-cutting concerns — keep handlers focused on one job
- Return minimal data from commands — just IDs, not entire objects
- CQRS + caching is a natural fit — query handlers are pure reads, ideal for Redis
Resources
- CQRS with Event Sourcing in .NET — The natural companion pattern
- MediatR in .NET: Complete Guide — The library that makes CQRS feel natural
What patterns have worked for your team? Share your experiences in the comments.
Connect with me:
[www.linkedin.com/in/vikrant-bagal]

Top comments (0)