Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 025 / 100

MediatR pipeline behaviors: .NET lessons from production

Validation, logging and transactions — written once.

Expona Admin6 min read

MediatR Pipeline Behaviors: Wrapping Your Handlers with Reusable Logic

In production systems, you often need to run the same logic around every command or query—validation, logging, transactions, security checks. Rather than scattering that code across dozens of handlers, MediatR pipeline behaviors let you plug in middleware-like components that wrap every request. I'll show you how to leverage them effectively to keep your codebase DRY and maintainable.

What Are Behaviors and Why They Matter

A behavior is a generic pipeline component that wraps a handler's execution. Think of it like middleware in an HTTP pipeline, but for your in-process commands and queries. When you send a request through MediatR, it flows through a chain of behaviors before reaching the actual handler, then back out. This gives you a single place to inject cross-cutting concerns.

I've used behaviors in every production system I've built with MediatR. In our e-commerce platform, we had validation, logging, transaction management, and audit tracking all living in separate behaviors. If we'd coded those directly into each handler, we'd have duplicated the same logic 40+ times and made maintenance a nightmare.

The power of behaviors is reusability and consistency. You write validation logic once, attach it as a behavior, and it runs for every command automatically. No opt-in, no missed handlers.

How Behaviors Wrap Every Handler Like Middleware

A behavior implements IPipelineBehavior<TRequest, TResponse>. Its Handle method receives the request and a next delegate that points to the next behavior in the pipeline (or the handler itself if it's the last behavior).

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("Starting request {RequestName}", requestName);

        try
        {
            var response = await next();
            _logger.LogInformation("Completed request {RequestName}", requestName);
            return response;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Exception in request {RequestName}", requestName);
            throw;
        }
    }
}

When you register this behavior and send a request, MediatR calls the behavior's Handle method. The behavior runs code before calling next(), then runs code after. That's the middleware pattern. If you register multiple behaviors, they nest—first registered wraps the outside, last registered is closest to the handler.

ValidationBehavior: Run Validators Before the Handler

One of the most common use cases is validation. Instead of injecting IValidator<T> into every handler, you write a single behavior that runs FluentValidation (or any validator) for every request.

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)
    {
        var context = new ValidationContext<TRequest>(request);
        var failures = new List<ValidationFailure>();

        foreach (var validator in _validators)
        {
            var result = await validator.ValidateAsync(context, cancellationToken);
            failures.AddRange(result.Errors);
        }

        if (failures.Any())
        {
            throw new ValidationException(failures);
        }

        return await next();
    }
}

Now when you send a CreateOrderCommand with invalid data, the behavior catches it before your handler even runs. No more null checks and defensive coding in the handler itself. The handler can assume the request is valid.

I've seen teams register this behavior globally so it applies to everything. In our CRM system, we had around 60 commands, and moving validation into a behavior eliminated dozens of repetitive null-check blocks.

TransactionBehavior: Wrap Commands in a Unit of Work

Commands usually modify data. You want them wrapped in a database transaction, so either everything succeeds or everything rolls back. A transaction behavior makes that automatic.

csharp
public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly IUnitOfWork _unitOfWork;

    public TransactionBehavior(IUnitOfWork unitOfWork)
    {
        _unitOfWork = unitOfWork;
    }

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken cancellationToken)
    {
        using (var transaction = await _unitOfWork.BeginTransactionAsync(cancellationToken))
        {
            try
            {
                var response = await next();
                await _unitOfWork.CommitAsync(cancellationToken);
                return response;
            }
            catch
            {
                await _unitOfWork.RollbackAsync(cancellationToken);
                throw;
            }
        }
    }
}

Register this behavior only for commands, not queries:

csharp
services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
});

With this in place, every command is automatically transactional. If your handler throws an exception halfway through, the transaction rolls back. In our ride-hailing platform, we had a CompleteRideCommand that updated the ride status, charged the customer, and credited the driver. Wrapping it in a transaction behavior meant if the credit failed, the charge and status update would both roll back—no partial states.

Order of Registration Is Order of Execution

Behaviors execute in the order you register them, like nested middleware. If you register validation first, then transaction, then logging, the flow is:

  1. Logging behavior starts, logs the request
  2. Transaction behavior starts, begins transaction
  3. Validation behavior runs, validates request
  4. Handler runs
  5. Validation behavior finishes (no exception, so it passes through)
  6. Transaction behavior finishes, commits
  7. Logging behavior finishes, logs completion

This order matters. You want validation before the transaction starts, so invalid requests don't touch the database. You want logging on the outside so it captures the full request-response cycle including transaction commit time.

I usually register behaviors in this order: logging, transaction, validation. Logging wraps everything so we capture timing. Transaction wraps the handler. Validation runs last so the handler gets clean data.

csharp
services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
    cfg.AddOpenBehavior(typeof(LoggingBehavior<,>));
    cfg.AddOpenBehavior(typeof(TransactionBehavior<,>));
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

Other Common Behaviors

Beyond validation, logging, and transactions, I've built behaviors for:

  • Audit behavior: Log who ran what command and when to an audit table
  • Authorization behavior: Check permissions before the handler runs
  • Caching behavior: For queries, return cached results if available
  • Performance monitoring: Track slow queries, alert if a request exceeds a threshold
  • Circuit breaker: Fail fast if a downstream service is down

Each one is a single class that applies automatically to all relevant requests. In our SaaS platform, we had an authorization behavior that checked tenant access on every command, preventing accidental cross-tenant data leaks.

Common Mistakes

  • Registering behaviors for queries that shouldn't have transactions. Queries don't modify data, so wrapping them in a transaction is unnecessary overhead. Use a marker interface or conditional logic to apply behaviors selectively. We used ICommand and IQuery interfaces and checked the request type in the behavior.
  • Forgetting to await next(). If you don't await the next delegate, the handler never runs. This is an easy mistake when you're copying from synchronous middleware examples.
  • Throwing exceptions without context. When validation fails, throw a well-structured exception that includes the violations. Generic InvalidOperationException doesn't help your API layer format a proper response.
  • Over-relying on behaviors for business logic. Behaviors should handle cross-cutting concerns. Complex validation or domain logic belongs in a validator or in the handler itself, not in a behavior that's hard to test in isolation.
  • Not testing behaviors independently. Mock the next delegate and test the behavior's logic in isolation. If it always calls a real database, you can't test it efficiently.

Key Takeaways

  • Behaviors wrap handlers like middleware, giving you a single place for cross-cutting concerns.
  • ValidationBehavior eliminates repeated validation code across dozens of handlers.
  • TransactionBehavior ensures commands are atomic without manual transaction management in every handler.
  • Registration order matters—logging wraps the outside, validation runs closest to the handler.
  • Use marker interfaces (ICommand, IQuery) to selectively apply behaviors instead of running them everywhere.
  • Behaviors reduce duplication and make your code more maintainable, but don't overuse them for business logic.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles