Mediator — Centralise Object Communication
The Mediator pattern in C#: reduce coupling between components by routing all communication through a central mediator. Build it from scratch, then compare with MediatR.
Mediator — Centralise Object Communication
Mediator reduces direct dependencies between components by making them communicate through a central hub — the mediator. No component needs to know about the others, only about the mediator.
The Problem Without Mediator
Without Mediator: N components → N×(N-1) dependencies (each knows all others)
With Mediator: N components → N dependencies (each knows only the mediator)
Example: 4 UI components that all need to react to each other's changes
Without: 12 cross-references
With: 4 references to the mediatorHand-Rolled Mediator (No MediatR)
// Request/response marker interfaces
public interface IRequest<TResponse> { }
public interface IRequestHandler<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> HandleAsync(TRequest request, CancellationToken ct = default);
}
// The mediator — dispatches requests to handlers
public class Mediator
{
private readonly IServiceProvider _services;
public Mediator(IServiceProvider services) => _services = services;
public Task<TResponse> SendAsync<TResponse>(
IRequest<TResponse> request,
CancellationToken ct = default)
{
var handlerType = typeof(IRequestHandler<,>)
.MakeGenericType(request.GetType(), typeof(TResponse));
dynamic handler = _services.GetRequiredService(handlerType);
return handler.HandleAsync((dynamic)request, ct);
}
}
// Commands and Queries
public record CreateOrderCommand(int CustomerId, List<int> ProductIds)
: IRequest<int>;
public record GetOrderQuery(int OrderId)
: IRequest<OrderDto>;
// Handlers
public class CreateOrderHandler(IOrderRepository orders)
: IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> HandleAsync(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.ProductIds);
await orders.AddAsync(order, ct);
return order.Id;
}
}
public class GetOrderHandler(IOrderRepository orders)
: IRequestHandler<GetOrderQuery, OrderDto>
{
public async Task<OrderDto> HandleAsync(GetOrderQuery query, CancellationToken ct)
{
var order = await orders.GetByIdAsync(query.OrderId, ct)
?? throw new NotFoundException($"Order {query.OrderId} not found");
return new OrderDto(order.Id, order.Total, order.Status);
}
}
// Register in DI
builder.Services.AddScoped<Mediator>();
builder.Services.AddScoped<IRequestHandler<CreateOrderCommand, int>, CreateOrderHandler>();
builder.Services.AddScoped<IRequestHandler<GetOrderQuery, OrderDto>, GetOrderHandler>();
// Controller uses mediator — doesn't know which handler handles what
[ApiController]
[Route("orders")]
public class OrdersController(Mediator mediator) : ControllerBase
{
[Post("")]
public async Task<IActionResult> Create(CreateOrderCommand cmd)
=> Ok(await mediator.SendAsync(cmd));
[Get("{id}")]
public async Task<IActionResult> Get(int id)
=> Ok(await mediator.SendAsync(new GetOrderQuery(id)));
}Pipeline Behaviours (Cross-Cutting Concerns)
// Add validation, logging, caching to all requests uniformly
public interface IPipelineBehaviour<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
Task<TResponse> HandleAsync(
TRequest request,
Func<Task<TResponse>> next,
CancellationToken ct = default);
}
public class ValidationBehaviour<TRequest, TResponse>(
IEnumerable<IValidator<TRequest>> validators)
: IPipelineBehaviour<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> HandleAsync(
TRequest request,
Func<Task<TResponse>> next,
CancellationToken ct)
{
foreach (var validator in validators)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
throw new ValidationException(result.Errors);
}
return await next();
}
}Mediator vs MediatR
// MediatR 12.x (MIT licence) — production-ready mediator library
// Provides the same pattern with more features:
// - Pipeline behaviours (IRequestBehavior)
// - Notifications (publish/subscribe for domain events)
// - Streaming (IStreamRequest<T>)
// With MediatR:
builder.Services.AddMediatR(cfg =>
cfg.RegisterServicesFromAssembly(typeof(Program).Assembly));
// Handler becomes:
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, int>
{
public async Task<int> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
// ... same logic
}
}
// Send:
int orderId = await mediator.Send(new CreateOrderCommand(customerId, productIds));Mediator vs Facade
Mediator: bidirectional — colleagues communicate through it, decouple FROM EACH OTHER
Facade: unidirectional — external callers use it, hides internal complexity
Use Mediator when: objects need to communicate with each other
Use Facade when: you want to simplify a complex subsystem for external callersInterview Answer
"The Mediator pattern routes all inter-component communication through a central object, eliminating direct dependencies between components. In .NET, it maps directly to CQRS: commands and queries (requests) are dispatched through the mediator to their handlers — the controller doesn't know or reference the handler directly. This makes handlers independently testable (pass in a concrete handler, no controller needed) and makes it easy to add cross-cutting concerns via pipeline behaviours (validation runs before every command, logging runs after every request). MediatR 12.x is the standard library for this (MIT-licensed). For simple APIs or teams new to the pattern, building a hand-rolled mediator (~50 lines) gives the same structural benefit with no dependency. Key trade-off: the indirection can make call flow harder to trace in a debugger — each request/handler pair is a separate file with no direct call chain visible."
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.