All articles

C# Interview Guide: CQRS with MediatR — Commands, Queries, Pipeline Behaviours

3 min read
#0
0 reads
0 reading now
0 shares

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.

ShareLinkedInX

Comments

Share your thoughts without signing in.