MediatRAugust 2026 · 7 min read

CQRS with MediatR Pipeline Behaviors: Validation, Logging, and Transactions

TL;DR

MediatR pipeline behaviors are middleware that runs around every command or query handler. In a .NET 10 app you can use them for FluentValidation, structured logging, and transactional UnitOfWork wrapping, so cross-cutting concerns live in one place instead of inside every handler.

CQRS with MediatR pipeline behaviors are what turn a tidy handler folder into a genuinely robust application. Every command and query in MediatR travels through an ordered pipeline before it reaches its handler. A behavior is an implementation of IPipelineBehavior<TRequest, TResponse> that runs before, after, or around the handler itself. Validation, structured logging, performance measurement, and transaction handling all belong here, so your handlers stay focused on business logic and nothing else.

This article walks through the three behaviors every .NET 10 application needs: FluentValidation for input validation, a logging behavior for observability, and a transaction or UnitOfWork behavior for atomicity. You will also see how to control the order in which behaviors run, because that ordering determines whether a failed validation still gets logged.

CQRS with MediatR Pipeline Behaviors in .NET 10

A behavior wraps the next delegate in the pipeline. The skeleton below is deliberately minimal: it invokes next() and returns whatever the handler produced. The same shape works for validation, where the check runs before next(), and for transactions, where the commit happens after.

public class NoOpBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        return await next();
    }
}

Behaviors are registered as scoped or transient services in the container. Because MediatR resolves them through dependency injection, a behavior can ask for a logger, a validator, or a database context just like any other service. That is the whole trick: the pipeline is simply a chain of DI services, so composition stays idiomatic.

Validation with FluentValidation

A validation behavior runs before the handler and stops the pipeline when the request is invalid. Define one validator per request with FluentValidation, then scan the assembly so every validator is registered automatically.

public class CreateProductCommandValidator
    : AbstractValidator<CreateProductCommand>
{
    public CreateProductCommandValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

The behavior collects every validator registered for the request type and runs them all before the handler. If any rule fails, it throws ValidationException and the handler never executes.

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 ct)
    {
        if (!_validators.Any()) return await next();

        var context = new ValidationContext<TRequest>(request);
        var failures = _validators
            .Select(v => v.Validate(context))
            .SelectMany(r => r.Errors)
            .Where(f => f != null)
            .ToList();

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

        return await next();
    }
}

When validation fails, the behavior throws before the handler runs. An API exception filter or a Blazor Server error handler converts that exception into a bad-request response or a form error, so your handlers never contain a single validation check.

Logging Every Command and Query

A logging behavior gives you a complete audit trail of every operation, including how long it took. The timing version below uses a stopwatch and logs a single structured message at the end of the request, which is far more useful than a before-and-after pair of lines.

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 ct)
    {
        var sw = Stopwatch.StartNew();
        var response = await next();
        sw.Stop();

        _logger.LogInformation(
            "{Request} handled in {Elapsed} ms",
            typeof(TRequest).Name, sw.ElapsedMilliseconds);

        return response;
    }
}

Structured logging means the request name and the elapsed milliseconds become queryable fields in your log store, not text buried in a string. When something is slow, you can group by request type and find the worst offenders in seconds.

Transactions and UnitOfWork

For operations that touch several tables, a transaction behavior wraps the handler and the save in a single atomic unit. EF Core exposes Database.BeginTransactionAsync, which is all you need for the classic UnitOfWork pattern: commit once at the end of the pipeline, roll back automatically on any exception.

public class TransactionBehavior<TRequest, TResponse>
    : IPipelineBehavior<TRequest, TResponse>
    where TRequest : IRequest<TResponse>
{
    private readonly AppDbContext _db;

    public TransactionBehavior(AppDbContext db) => _db = db;

    public async Task<TResponse> Handle(
        TRequest request,
        RequestHandlerDelegate<TResponse> next,
        CancellationToken ct)
    {
        await using var transaction =
            await _db.Database.BeginTransactionAsync(ct);
        var response = await next();
        await transaction.CommitAsync(ct);
        return response;
    }
}

Notice that the handler never calls SaveChangesAsync. The database context is the UnitOfWork: the handler adds and updates entities, and the transaction behavior commits the whole batch at the end of the pipeline. If anything throws, the using block disposes the transaction and EF Core rolls back automatically.

Ordering CQRS with MediatR Behaviors

MediatR runs behaviors in the order they are registered, and that order matters. Register validation after logging if you want every attempt recorded, or before it if you want rejected requests to skip the success log. A common production layout keeps logging first, validation second, and the transaction innermost.

builder.Services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(typeof(Program).Assembly);
});

builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(LoggingBehavior<,>));
builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(ValidationBehavior<,>));
builder.Services.AddTransient(
    typeof(IPipelineBehavior<,>),
    typeof(TransactionBehavior<,>));

With this registration, a request flows through logging, then validation, then the transaction, and finally the handler. FluentValidation classes are picked up by the assembly scan, so the validation behavior resolves every validator it needs without any further configuration.

Key Takeaways

  • Behaviors implement IPipelineBehavior and wrap the handler in an ordered pipeline
  • FluentValidation behaviors stop invalid requests before business logic runs
  • Logging behaviors provide timing and audit data without touching handlers
  • Transaction behaviors give you UnitOfWork atomicity with automatic rollback
  • Registration order determines pipeline order, so choose it deliberately

Ready to see these behaviors in production?

Every Indotalent product is a complete .NET 10 application where MediatR behaviors handle validation, logging, and transactions across every slice. Complete source code — $21 each.

Explore Products