The Mediator Pattern Beyond the Basics cover image

The Mediator Pattern Beyond the Basics

By Julian Krause · Sunday, June 15, 2025 · ~3 min read

MediatR has become the de facto standard for implementing the Mediator pattern in .NET, but most tutorials stop at the basics: send a request, get a response. In production systems, the real value comes from the pipeline behaviors that surround your handlers. Here is how we use MediatR as the backbone of our application layer.

Pipeline Behaviors Are the Real Power

Pipeline behaviors let you wrap cross-cutting concerns around every request handler without touching the handler code. Think of them as middleware for your application layer.

public class ValidationBehavior<TRequest, TResponse>(
    IEnumerable<IValidator<TRequest>> validators)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!validators.Any())
            return await next(cancellationToken);

        var context = new ValidationContext<TRequest>(request);
        var failures = (await Task.WhenAll(
                validators.Select(v => v.ValidateAsync(context, cancellationToken))))
            .SelectMany(result => result.Errors)
            .Where(f => f is not null)
            .ToList();

        if (failures.Count > 0)
            throw new ValidationException(failures);

        return await next(cancellationToken);
    }
}

Register behaviors in order — they execute like a pipeline, outermost first:

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(TransactionBehavior<,>));
});

Transaction Management via Behaviors

One of the most useful behaviors wraps each command in a database transaction. Queries skip the transaction entirely. This pattern ensures consistency without polluting every handler with transaction management code.

public class TransactionBehavior<TRequest, TResponse>(AppDbContext dbContext)
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICommand<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        await using var transaction = await dbContext.Database
            .BeginTransactionAsync(cancellationToken);

        try
        {
            var response = await next(cancellationToken);
            await transaction.CommitAsync(cancellationToken);
            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}

The constraint where TRequest : ICommand<TResponse> ensures this only applies to commands, not queries.

Notifications for Side Effects

Domain events can be published as MediatR notifications. Multiple handlers can react to the same event independently, keeping your handlers focused on their primary responsibility.

public record OrderConfirmedNotification(Guid OrderId, Guid CustomerId) : INotification;

public class SendConfirmationEmailHandler(IEmailService emailService)
    : INotificationHandler<OrderConfirmedNotification>
{
    public async Task Handle(OrderConfirmedNotification notification, CancellationToken ct)
    {
        await emailService.SendOrderConfirmationAsync(notification.OrderId, ct);
    }
}

public class UpdateInventoryHandler(IInventoryService inventoryService)
    : INotificationHandler<OrderConfirmedNotification>
{
    public async Task Handle(OrderConfirmedNotification notification, CancellationToken ct)
    {
        await inventoryService.ReserveStockAsync(notification.OrderId, ct);
    }
}

When Not to Use MediatR

MediatR adds indirection. When you have a simple CRUD application with no cross-cutting concerns, injecting a service directly is simpler and more debuggable. The mediator pattern earns its complexity when you have multiple behaviors, need to decouple handlers from their callers, or want a uniform request processing pipeline. If you find yourself creating a handler that just calls a single service method, you have over-abstracted.


Comments (3)

Elena Sunday, February 15, 2026 5:08 PM

This is exactly what I was looking for, thank you!

Felix Monday, February 16, 2026 5:08 PM

I have a question: does this also apply to older versions?

David Saturday, March 14, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Greta Friday, March 13, 2026 8:08 PM

I agree, great article!

Hannah Saturday, March 7, 2026 11:08 PM

Exactly! I had the same thought.

Leave a Comment