MediatR is one of the most widely adopted libraries in .NET for implementing the mediator pattern. But its real power isn't just dispatching requests to handlers — it's the pipeline behaviour system that lets you wrap every request with cross-cutting concerns like logging, validation, and performance monitoring.

What Are Pipeline Behaviours?

A pipeline behaviour in MediatR is middleware for your requests. It wraps around every handler invocation, giving you a before and after hook without touching the handler itself. Think of it like ASP.NET Core middleware, but for your application layer.

The interface is straightforward:

Example.cs
public class LoggingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehaviour<TRequest, TResponse>> _logger;

    public LoggingBehaviour(ILogger<LoggingBehaviour<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Handling {RequestName}", typeof(TRequest).Name);

        var response = await next();

        _logger.LogInformation("Handled {RequestName}", typeof(TRequest).Name);

        return response;
    }
}

The next delegate calls the next behaviour in the pipeline, or the handler itself if there are no more behaviours. This gives you full control over the request lifecycle.

Registration

Register behaviours in your DI container. Order matters — behaviours run in the order they're registered:

Program.cs
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssemblyContaining<Program>();
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(LoggingBehaviour<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehaviour<,>));
    cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(PerformanceBehaviour<,>));
});

Validation Behaviour

One of the most common uses is integrating FluentValidation. Rather than validating in every handler, you validate once in the pipeline:

Example.cs
public class ValidationBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehaviour(IEnumerable<IValidator<TRequest>> validators)
    {
        _validators = validators;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        if (!_validators.Any())
            return await next();

        var context = new ValidationContext<TRequest>(request);

        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(f => f is not null)
            .ToList();

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

        return await next();
    }
}

Now any request that has a matching IValidator<T> registered gets validated automatically. No validator registered? The behaviour skips validation and calls the next step.

Performance Monitoring

Another practical example — flag slow requests:

Example.cs
public class PerformanceBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<PerformanceBehaviour<TRequest, TResponse>> _logger;
    private readonly Stopwatch _timer = new();

    public PerformanceBehaviour(
        ILogger<PerformanceBehaviour<TRequest, TResponse>> logger)
    {
        _logger = logger;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        _timer.Start();

        var response = await next();

        _timer.Stop();

        if (_timer.ElapsedMilliseconds > 500)
        {
            _logger.LogWarning(
                "Long running request: {RequestName} ({ElapsedMs}ms)",
                typeof(TRequest).Name,
                _timer.ElapsedMilliseconds);
        }

        return response;
    }
}

Constraining Behaviours to Specific Requests

You don't always want a behaviour to run for every request. Use marker interfaces to constrain:

Example.cs
public interface ICachedRequest<TResponse> : IRequest<TResponse>
{
    string CacheKey { get; }
    TimeSpan CacheDuration { get; }
}

public class CachingBehaviour<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : ICachedRequest<TResponse>
{
    private readonly IDistributedCache _cache;

    public CachingBehaviour(IDistributedCache cache)
    {
        _cache = cache;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var cached = await _cache.GetStringAsync(request.CacheKey, cancellationToken);
        if (cached is not null)
            return JsonSerializer.Deserialize<TResponse>(cached)!;

        var response = await next();

        await _cache.SetStringAsync(
            request.CacheKey,
            JsonSerializer.Serialize(response),
            new DistributedCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow = request.CacheDuration
            },
            cancellationToken);

        return response;
    }
}

Only requests implementing ICachedRequest<T> will hit this behaviour. Everything else passes through untouched.

When to Use Behaviours

Pipeline behaviours shine for concerns that apply broadly: logging, validation, authorisation checks, caching, transaction management, and exception handling. They keep your handlers focused on business logic rather than infrastructure plumbing.

Avoid overusing them for logic that only applies to one or two requests — at that point, just put the logic in the handler. The goal is reducing repetition, not adding indirection for its own sake.