Remigiusz ZalewskiRemigiusz Zalewski

How to Use FluentValidation with MediatR in ASP.NET Core (Clean Architecture)

fluentvalidation-mediatr-pipeline-behavior

Introduction

Validation logic has a way of ending up in the wrong place - half in the endpoint, half at the top of the handler, duplicated across the create and update paths. The clean answer with MediatR is a pipeline behavior: a piece of middleware that wraps every request, runs the FluentValidation validators registered for that request type, and only calls the handler if everything passes.

This video wires that up in a Clean Architecture solution on .NET 9. A CreateBookingCommand has a CreateBookingCommandValidator; the handler never mentions validation; the endpoint never mentions validation; and a bad request fails before the handler is even constructed.

🎬 Watch the full video here:


The validator

FluentValidation rules live in a class per request type, inheriting AbstractValidator<T>:

public class CreateBookingCommandValidator : AbstractValidator<CreateBookingCommand>
{
    public CreateBookingCommandValidator()
    {
        RuleFor(x => x.RoomId).NotEmpty().WithMessage("RoomId is required");
        RuleFor(x => x.StartDate)
            .NotEmpty()
            .LessThan(x => x.EndDate).WithMessage("Start date needs to be before end date");
        RuleFor(x => x.Notes).MaximumLength(500);
    }
}

Rules read like sentences, cross-field checks (StartDate vs EndDate) are natural, and the whole set of constraints for a command is in one discoverable place.

The pipeline behavior

IPipelineBehavior<TRequest, TResponse> is MediatR's middleware interface. This one collects every validator registered for the request, runs them all, and throws if any fail:

public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
    var context = new ValidationContext<TRequest>(request);

    var results = await Task.WhenAll(_validators.Select(v => v.ValidateAsync(context, ct)));
    var failures = results.SelectMany(r => r.Errors).Where(f => f is not null).ToList();

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

    return await next();
}

Key points:

  • It injects IEnumerable<IValidator<TRequest>> - zero, one, or many validators per request, all run.
  • next() is the rest of the pipeline (ultimately the handler). Not calling it is how validation short-circuits.
  • A request type with no validator registered sails straight through - the behavior is a no-op for it.

Registration

services.AddMediatR(cfg =>
{
    cfg.RegisterServicesFromAssembly(assembly);
    cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
});

services.AddValidatorsFromAssembly(assembly);

AddOpenBehavior registers the behavior for every request. AddValidatorsFromAssembly scans and registers every AbstractValidator<> in the assembly, so adding a new validator is just adding the file - no registration edit.

Turning ValidationException into a 400

The behavior throws FluentValidation.ValidationException. Left alone that becomes a 500. You catch it in one place - an IExceptionHandler or exception-handling middleware - and turn its Errors collection into a 400 Bad Request with a Problem Details body listing the field errors. That is the only place in the whole app that knows validation failures map to 400.

Why the behavior beats the alternatives

  • Validating in the endpoint: every endpoint repeats the wiring, and it does not protect handlers invoked from other handlers or from a background job.
  • Validating at the top of the handler: couples the handler to validation, and it is easy to forget on a new handler.
  • [ApiController] automatic model validation: only covers data-annotation attributes on the bound model and only at the HTTP boundary. The behavior runs FluentValidation rules for any MediatR request, wherever it originates.

The behavior is written once and every current and future command gets it automatically.

Common pitfalls

  • Not handling ValidationException centrally. Without the handler, clients get a 500 instead of a useful 400.
  • Behavior order. If you have several behaviors (logging, validation, transactions), register them in the order they should wrap - validation should generally run before anything with side effects.
  • Expecting DI in the validator. Validators can take constructor dependencies (for example to check uniqueness against a repository), but keep database calls in validators deliberate - they run on every request.
  • Async rules without ValidateAsync. The behavior calls ValidateAsync; make sure custom async rules are actually awaited.
  • One giant validator. One validator per request type keeps rules close to the thing they validate.

Key Takeaways

  • Put validation in a MediatR IPipelineBehavior so it runs for every request before the handler, regardless of where the request originated.
  • Write one AbstractValidator<T> per request type; FluentValidation rules are readable and handle cross-field checks cleanly.
  • The behavior injects IEnumerable<IValidator<TRequest>>, runs them all, and throws ValidationException on failure instead of calling next().
  • Register with AddOpenBehavior(typeof(ValidationBehavior<,>)) and AddValidatorsFromAssembly(...) so new validators need no wiring.
  • Catch ValidationException in one central handler and convert it to a 400 with field-level errors.
  • Handlers and endpoints stay completely free of validation code.

Get the Full Source Code

The complete runnable solution - the validation behavior, the booking command and validator, the MediatR registration, and the exception handler that shapes the 400 - is available to Patreon supporters. If you want to POST invalid payloads and watch the pipeline reject them instead of rebuilding it from the walkthrough above, you can find it on Patreon.

Resources