.NETMedium
Mediator pattern in .NET — MediatR walkthrough with CQRS
The Mediator pattern decouples senders and receivers. In .NET, MediatR is the de-facto implementation and pairs naturally with CQRS (Command Query Responsibility Segregation).
The idea
Instead of controllers calling services calling other services calling repositories, the controller sends a request to a single IMediator. The mediator routes it to the matching handler.
CQRS — split reads from writes
- Command — mutates state, returns nothing or just an ID.
- Query — reads state, never mutates.
Two different shapes, two different optimization paths.
Code
// Request
public record PlaceOrder(Guid CustomerId, List<LineItem> Items) : IRequest<Guid>;
// Handler
public class PlaceOrderHandler(AppDb db, IOutbox outbox) : IRequestHandler<PlaceOrder, Guid> {
public async Task<Guid> Handle(PlaceOrder cmd, CancellationToken ct) {
var order = Order.New(cmd.CustomerId, cmd.Items);
db.Orders.Add(order);
await outbox.PublishAsync(new OrderPlaced(order.Id), ct);
await db.SaveChangesAsync(ct);
return order.Id;
}
}
// Controller
public class OrdersController(IMediator mediator) : ControllerBase {
[HttpPost] public async Task<IActionResult> Post(PlaceOrder cmd)
=> Ok(await mediator.Send(cmd));
}
Pipeline behaviors — the killer feature
Add cross-cutting concerns once, every command + query gets them:
public class LoggingBehavior<TReq, TRes>(ILogger<TReq> log) : IPipelineBehavior<TReq, TRes>
where TReq : notnull
{
public async Task<TRes> Handle(TReq request, RequestHandlerDelegate<TRes> next, CancellationToken ct) {
var sw = Stopwatch.StartNew();
try { return await next(); }
finally { log.LogInformation("{Req} took {Ms}ms", typeof(TReq).Name, sw.ElapsedMilliseconds); }
}
}
builder.Services.AddMediatR(c => c.RegisterServicesFromAssembly(typeof(Program).Assembly));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
builder.Services.AddTransient(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
Validation, transaction wrapping, caching, retry — all live in behaviors. Handlers stay tiny.
When CQRS is overkill
- Small CRUD apps where reads and writes share the same model with no shape divergence.
- Where the "command" has no business logic worth a separate handler ("update field X to Y").
When it pays
- The read model needs to be aggressively cached / denormalized; the write model needs to stay normalized.
- Multiple input channels (HTTP, gRPC, queue consumer) hitting the same handler.
- Auditing / replay is required — every command can be persisted as an event.
Common bad signal
Handler is one line that calls service.DoThing(cmd.X). The mediator is paying its weight only if the handler is the logic.