Learnixo

.NET & C# Development · Lesson 41 of 229

Mediator — Centralize Object Communication

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 mediator

Hand-Rolled Mediator (No MediatR)

C#
// 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)

C#
// 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

C#
// 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 callers

Interview 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."