Q27 — CQRS with MediatR — Commands, Queries, Pipeline Behaviours
Level: Senior | Topic: Architecture
CQRS in a Nutshell
Command Query Responsibility Segregation separates operations that change state (Commands) from operations that read state (Queries). Commands return a result ID or void. Queries return data and have no side effects. MediatR implements the Mediator pattern — controllers dispatch requests to handlers without knowing the handler's implementation.
MediatR Pipeline Behaviours
Pipeline behaviours wrap every handler like middleware — execute code before/after any request. This is the clean way to add cross-cutting concerns: logging (every request), validation (every command), transactions (every command that writes), caching (specific queries).
Code Example
// ── COMMAND — changes state, returns OrderId
public record PlaceOrderCommand(string CustomerId, List<OrderLineRequest> Lines)
: IRequest<string>;
// FluentValidation for the command
public class PlaceOrderCommandValidator : AbstractValidator<PlaceOrderCommand>
{
public PlaceOrderCommandValidator()
{
RuleFor(x => x.CustomerId).NotEmpty().MaximumLength(100);
RuleFor(x => x.Lines).NotEmpty().WithMessage("Order must have at least one line");
RuleForEach(x => x.Lines).ChildRules(line =>
{
line.RuleFor(l => l.Quantity).GreaterThan(0);
line.RuleFor(l => l.UnitPrice).GreaterThan(0);
});
}
}
// Command Handler
public class PlaceOrderHandler : IRequestHandler<PlaceOrderCommand, string>
{
private readonly IOrderRepository _repo;
private readonly IEventPublisher _events;
public PlaceOrderHandler(IOrderRepository repo, IEventPublisher events)
{ _repo = repo; _events = events; }
public async Task<string> Handle(PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Lines.Select(l =>
new OrderLine(l.ProductId, l.Quantity, l.UnitPrice)));
await _repo.SaveAsync(order, ct);
await _events.PublishAsync(new OrderCreatedEvent(order.Id, cmd.CustomerId), ct);
return order.Id;
}
}
// ── QUERY — reads state, no side effects
public record GetOrderQuery(string OrderId) : IRequest<OrderDto?>;
public class GetOrderHandler : IRequestHandler<GetOrderQuery, OrderDto?>
{
private readonly IOrderReadRepository _readRepo;
public GetOrderHandler(IOrderReadRepository readRepo) => _readRepo = readRepo;
public Task<OrderDto?> Handle(GetOrderQuery query, CancellationToken ct)
=> _readRepo.GetByIdAsync(query.OrderId, ct); // can use read replica or Redis
}
// ── PIPELINE BEHAVIOURS — cross-cutting concerns
// Logging — wraps every request
public class LoggingBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var name = typeof(TRequest).Name;
_logger.LogInformation("Handling {RequestName}", name);
var sw = Stopwatch.StartNew();
try { return await next(); }
finally { _logger.LogInformation("Handled {RequestName} in {Ms}ms", name, sw.ElapsedMilliseconds); }
}
}
// Validation — auto-validates using registered FluentValidation validators
public class ValidationBehaviour<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : notnull
{
public async Task<TResponse> Handle(
TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
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.Any()) throw new ValidationException(failures);
return await next();
}
}
// ── THIN CONTROLLER — just dispatches to MediatR
[ApiController, Route("api/orders")]
public class OrdersController : ControllerBase
{
private readonly IMediator _mediator;
public OrdersController(IMediator mediator) => _mediator = mediator;
[HttpPost]
public async Task<IActionResult> PlaceOrder(
[FromBody] PlaceOrderCommand cmd, CancellationToken ct)
{
var orderId = await _mediator.Send(cmd, ct);
return CreatedAtAction(nameof(GetOrder), new { id = orderId }, new { id = orderId });
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(string id, CancellationToken ct)
{
var order = await _mediator.Send(new GetOrderQuery(id), ct);
return order is null ? NotFound() : Ok(order);
}
}
Senior Insight
Pipeline behaviours are MediatR's killer feature — they give you AOP-style cross-cutting without reflection or code generation. Register behaviours in order: Logging → Validation → Transaction. Queries should never go through the Transaction behaviour. Keep handlers single-responsibility: one handler, one use case. If a handler is importing 5+ dependencies, it's doing too much — split it.