Mediator & MediatR
Decoupling senders from handlers through a central dispatcher, with pipeline behaviors for cross-cutting concerns — the backbone of thin controllers and clean request handling in .NET.
The Mediator pattern routes communication through a central object so collaborators don't reference each other directly. In .NET, MediatR popularized it for application architecture: each request maps to one handler, controllers just send requests, and pipeline behaviors wrap every handler with cross-cutting concerns like validation, logging, and transactions — keeping business logic clean and dependencies loose.
Instead of objects calling each other directly (an N×N web of dependencies), they communicate through a mediator. Senders publish a request; the mediator routes it to the right handler. This collapses many-to-many coupling into many-to-one, so components don't need references to each other.
Each request type (CreateOrderCommand, GetOrderQuery) maps to exactly one handler. The sender (e.g. a controller) just calls mediator.Send(request) and gets a response — it has no idea which class handles it. Adding a feature means adding a request + handler pair, with no changes to callers.
The mediator wraps every handler in a pipeline of behaviors — logging, validation, caching, transactions, performance timing. Each behavior runs before and after the handler (like middleware), so cross-cutting concerns are written once and applied everywhere, instead of being copy-pasted into every handler.
A behavior can stop the pipeline before the handler runs. A ValidationBehavior that finds an invalid request throws (or returns a failure) immediately, so the handler is never invoked. This keeps validation, authorization, and caching out of the business logic entirely.
Key Concepts
A central object that routes requests to handlers, so senders and handlers don't reference each other.
Each request type maps to exactly one handler containing the business logic for it.
Middleware-like wrappers that run around every handler for cross-cutting concerns (logging, validation).
Controllers just send a request and return the result — no business logic, no handler knowledge.
A one-to-many message handled by zero or more handlers — for in-process domain events.
The mediator turns many-to-many dependencies into many-to-one, drastically reducing coupling.
1// A request and its single handler (MediatR style).2public record CreateOrder(string[] Items) : IRequest<Guid>;34public class CreateOrderHandler : IRequestHandler<CreateOrder, Guid> {5 public async Task<Guid> Handle(CreateOrder cmd, CancellationToken ct) {6 var order = Order.Create(cmd.Items); // just the business logic7 await _repo.AddAsync(order, ct);8 return order.Id;9 }10}1112// Cross-cutting concerns become pipeline behaviors that wrap EVERY handler:13public class ValidationBehavior<TReq, TRes> : IPipelineBehavior<TReq, TRes> {14 public async Task<TRes> Handle(TReq req, RequestHandlerDelegate<TRes> next, CancellationToken ct) {15 Validate(req); // throws → handler never runs (short-circuit)16 return await next(); // call the next behavior / the handler17 }18}1920// The controller stays thin — it knows nothing about handlers:21[HttpPost] public Task<Guid> Post(CreateOrder cmd) => _mediator.Send(cmd);
Mediator (via MediatR) is ubiquitous in modern .NET, especially alongside CQRS and clean architecture, and it's a common interview and code-review topic. It delivers thin controllers, centralized cross-cutting concerns, and loose coupling — but it can also add indirection and 'magic', so knowing when it helps versus when it obscures is the real skill.
Common Pitfalls
1Fat Controllers Bloated With Cross-Cutting Code
Every controller action repeats the same boilerplate: log the request, validate the DTO, start a transaction, call services, handle errors, log the response. The controllers are huge and the duplication is everywhere.
Cross-cutting concerns are copy-pasted into every action, and controllers mix HTTP concerns with business orchestration. A change to logging or validation policy means editing dozens of files, and it's easy to miss one.
Introduce a mediator: each action becomes mediator.Send(command). Logging, validation, and transaction handling move into pipeline behaviors that wrap every handler automatically. Controllers shrink to one line; the cross-cutting logic lives in exactly one place.
Takeaway: A mediator with pipeline behaviors centralizes cross-cutting concerns and slims controllers to dispatch-only. Write logging/validation/transactions once and they apply to every request consistently.
2Tangled Service-to-Service Dependencies
OrderService depends on InventoryService, EmailService, and AuditService; those depend on others. The dependency graph is a tangled web, constructors have ten parameters, and it's hard to test or change anything in isolation.
Direct object-to-object calls created dense many-to-many coupling. Each new interaction added another reference, constructors ballooned, and a change in one service rippled across many.
Route interactions through the mediator. When an order is placed, the handler sends commands or publishes an OrderPlaced notification; inventory, email, and audit handlers react independently. Services no longer hold references to each other — they depend only on the mediator and their own requests.
Takeaway: Use a mediator to break a many-to-many dependency web into independent request/handler pairs. Components become individually testable and changeable, communicating through messages rather than hard references.
Join the discussion
Comments
Loading comments...
Cookie preferences
We use essential cookies for sign-in and preferences. With your permission, we also use basic analytics to improve lessons.