# MediatR Pipeline Behaviors: A Practical Guide to Cross-Cutting Concerns

> MediatR pipeline behaviors let you handle logging, validation, caching, and transactions without touching your handlers. Here are four practical IPipelineBehavior implementations you can use today.

Published: 2026-08-11. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/mediatr-pipeline-behaviors

MediatR pipeline behaviors wrap your request handlers like middleware: each behavior runs code before and after the handler, or short-circuits the pipeline entirely.
That makes them the natural home for cross-cutting concerns like logging, validation, caching, and transactions.

Your command handlers should read like business logic and nothing else.
In practice, they tend to accumulate that plumbing until the actual use case is buried.
Pipeline behaviors pull it out into reusable classes that apply automatically, and they're one of the main reasons to use MediatR at all.

## Why Pipeline Behaviors

Every [Clean Architecture](https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design) project eventually runs into the same problem. You need logging in every handler. Validation before every command. Caching for expensive queries. And you don't want to copy-paste that logic into dozens of classes.

MediatR's `IPipelineBehavior<TRequest, TResponse>` solves this by wrapping your handlers with reusable middleware. Think of it like ASP.NET Core middleware, but for your [CQRS](https://milanjovanovic.tech/blog/cqrs-pattern-the-way-it-should-have-been-from-the-start) pipeline. Each behavior gets a chance to run code before and after the handler, or short-circuit the pipeline entirely.

I'll walk you through four behaviors I use in almost every project.

One thing before we start: [**MediatR is now a commercial product**](https://milanjovanovic.tech/blog/mediatr-and-masstransit-going-commercial-what-this-means-for-you) for larger companies.
Everything in this article still applies, and the same pattern works with hand-rolled handler interfaces and Scrutor decorators if you'd rather not take the dependency.
I show that decorator-based approach in [**balancing cross-cutting concerns in Clean Architecture**](https://milanjovanovic.tech/blog/balancing-cross-cutting-concerns-in-clean-architecture).

## How Does IPipelineBehavior Work?

A pipeline behavior implements `IPipelineBehavior<TRequest, TResponse>`. It receives the request, a `next` delegate that calls the next behavior (or the handler itself), and a cancellation token.

```csharp
public class MyBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        // Run code BEFORE the handler.

        var response = await next();

        // Run code AFTER the handler.

        return response;
    }
}
```

The `next` delegate is the key. Calling it passes control down the pipeline. You can inspect the request before calling `next`, inspect the response after, or skip `next` entirely to short-circuit.

## Logging Behavior

The first behavior I add to any project is logging with elapsed time. It gives you visibility into every request flowing through the system without a single log statement in your handlers.

```csharp
public class LoggingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly ILogger<LoggingBehavior<TRequest, TResponse>> _logger;

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

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        var requestName = typeof(TRequest).Name;

        _logger.LogInformation("Handling {Request}", requestName);

        var stopwatch = Stopwatch.StartNew();

        var response = await next();

        stopwatch.Stop();

        _logger.LogInformation(
            "Handled {Request} in {ElapsedMs}ms",
            requestName,
            stopwatch.ElapsedMilliseconds);

        return response;
    }
}
```

Every command and query gets timed automatically. If something runs slow, you'll see it in your logs immediately.

## Validation Behavior

Validation is a perfect candidate for a pipeline behavior. You run all FluentValidation validators for the incoming request, and if anything fails, you short-circuit the pipeline before the handler ever executes.

```csharp
public class ValidationBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IEnumerable<IValidator<TRequest>> _validators;

    public ValidationBehavior(
        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(failure => failure is not null)
            .ToArray();

        if (failures.Length != 0)
        {
            throw new ValidationException(failures);
        }

        return await next();
    }
}
```

Notice the behavior never calls `next()` when validation fails. The handler stays clean - it only deals with business logic, never input validation. You can pair this with the [Result pattern](https://milanjovanovic.tech/blog/functional-error-handling-in-dotnet-with-the-result-pattern) if you prefer returning errors over throwing exceptions.

## Caching Behavior

For query caching, I use a marker interface called `ICacheable`. Only queries that implement it get cached. Everything else passes straight through.

```csharp
public interface ICacheable
{
    string CacheKey { get; }
    TimeSpan CacheDuration { get; }
}

public class CachingBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>, ICacheable
{
    private readonly IDistributedCache _cache;
    private readonly ILogger<CachingBehavior<TRequest, TResponse>> _logger;

    public CachingBehavior(
        IDistributedCache cache,
        ILogger<CachingBehavior<TRequest, TResponse>> logger)
    {
        _cache = cache;
        _logger = logger;
    }

    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)
        {
            _logger.LogInformation(
                "Cache hit for {CacheKey}", request.CacheKey);

            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;
    }
}
```

Apply it to a query by implementing `ICacheable`:

```csharp
public record GetProductByIdQuery(Guid ProductId)
    : IRequest<ProductResponse>, ICacheable
{
    public string CacheKey => $"product:{ProductId}";
    public TimeSpan CacheDuration => TimeSpan.FromMinutes(5);
}
```

The handler has no idea caching exists. It just returns data, and the behavior handles the rest.

## Transaction Behavior

Commands that modify data often need to run inside a database transaction. Instead of wrapping every handler in a `BeginTransaction`/`CommitAsync` block, you can use a behavior with a marker interface.

```csharp
public interface ITransactional;

public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>, ITransactional
{
    private readonly ApplicationDbContext _dbContext;
    private readonly ILogger<TransactionBehavior<TRequest, TResponse>> _logger;

    public TransactionBehavior(
        ApplicationDbContext dbContext,
        ILogger<TransactionBehavior<TRequest, TResponse>> logger)
    {
        _dbContext = dbContext;
        _logger = logger;
    }

    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();

            await _dbContext.SaveChangesAsync(cancellationToken);
            await transaction.CommitAsync(cancellationToken);

            _logger.LogInformation(
                "Transaction committed for {Request}",
                typeof(TRequest).Name);

            return response;
        }
        catch
        {
            await transaction.RollbackAsync(cancellationToken);
            throw;
        }
    }
}
```

Mark any command that needs a transaction:

```csharp
public record PlaceOrderCommand(Guid CustomerId, List<OrderItem> Items)
    : IRequest<Guid>, ITransactional;
```

If the handler throws, the transaction rolls back automatically. No try-catch clutter in your business logic.

## Registering Behaviors

MediatR's `AddOpenBehavior` method handles registration. You call it inside `AddMediatR`, and MediatR figures out the generic type arguments at runtime.

```csharp
builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(PlaceOrderCommand).Assembly);

    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(CachingBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});
```

Don't forget to register your FluentValidation validators too:

```csharp
builder.Services.AddValidatorsFromAssembly(
    typeof(PlaceOrderCommand).Assembly);
```

## Execution Order

The order you call `AddOpenBehavior` determines the execution order. Behaviors execute from the outside in, like layers of an onion.

With the registration above, a request flows like this:

![A request passes through LoggingBehavior, ValidationBehavior, CachingBehavior, and TransactionBehavior before reaching the handler, then the response flows back out through each behavior in reverse order](https://milanjovanovic.tech/blogs/articles/mediatr-pipeline-behaviors/pipeline-execution-order.png)

1. **LoggingBehavior** - starts the stopwatch, logs the request name
2. **ValidationBehavior** - runs validators, short-circuits if invalid
3. **CachingBehavior** - returns cached data if available (for cacheable queries)
4. **TransactionBehavior** - begins a transaction (for transactional commands)
5. **Handler** - runs the actual business logic

The response then flows back up through each behavior in reverse order. This means the logging behavior captures the total time including validation, caching, and transaction overhead.

Think carefully about ordering. Logging should always be outermost so it captures everything. Validation should run before caching - there's no point caching an invalid request.

## Summary

Pipeline behaviors keep your handlers focused on business logic. Logging, validation, caching, and transactions all live in their own classes, registered once, and applied automatically across every request that matches the constraints.

The pattern scales well. Even with eight or nine behaviors in the pipeline, handlers stay just as clean as they were with zero. If you're building a [Clean Architecture](https://milanjovanovic.tech/blog/clean-architecture-and-the-benefits-of-structured-software-design) application with MediatR, pipeline behaviors are the single best tool for managing cross-cutting concerns.

Thanks for reading, and stay awesome!

---

## Frequently asked questions

### What is a MediatR pipeline behavior?

A pipeline behavior implements IPipelineBehavior and wraps request handlers like middleware. It can run code before and after the handler, or short-circuit the pipeline entirely, which makes it ideal for logging, validation, caching, and transactions.

### In what order do MediatR pipeline behaviors execute?

In registration order, from the outside in. The first behavior registered wraps all the others, so register logging first to capture total time, and validation before caching so invalid requests are never cached.

### How do I apply a behavior to only some requests?

Add a marker interface like ICacheable or ITransactional as a generic constraint on the behavior. The DI container only applies the behavior to requests that satisfy the constraint; everything else bypasses it.

### How do I register an open generic pipeline behavior?

Use AddOpenBehavior inside the AddMediatR configuration, for example cfg.AddOpenBehavior(typeof(LoggingBehavior<,>)). The plain AddBehavior method throws for open generic types.

### Can I use pipeline behaviors without MediatR?

Yes. The same pattern works with your own ICommandHandler interfaces plus decorators registered through a library like Scrutor. MediatR makes it convenient, but the decorator pattern is what does the work.
