When building enterprise applications using CQRS and Clean Architecture, one common trap is scattering validation logic across your controllers, command handlers, or domain entities. This leads to code duplication, inconsistent error responses, and bloated handlers that violate the Single Responsibility Principle.

A much cleaner, more robust approach is to combine FluentValidation with MediatR Pipeline Behaviors. This intercepts incoming requests automatically before they ever reach your command or query handlers, validating rules cleanly and rejecting malformed requests instantly.

In this comprehensive guide, we will walk through setting up FluentValidation with MediatR pipeline behaviors in an ASP.NET Core Clean Architecture project.

Why Use MediatR Pipeline Behaviors for Validation?

MediatR pipeline behaviors act as middleware for your application requests. Instead of manually invoking a validator inside every single command handler, a generic validation behavior intercepts all requests implementing IRequest<T>, runs any registered validators asynchronously, and throws a validation exception if rules fail.

Benefits include:

  • Separation of Concerns: Handlers focus solely on business logic; validation logic lives entirely in dedicated validator classes.

  • DRY (Don't Repeat Yourself): Validation happens globally and automatically through the pipeline.

  • Early Exit: Invalid requests are rejected before hitting the database or executing business logic.

Step 1: Install Required NuGet Packages

Navigate to your Application project (YourSolution.Application) and install FluentValidation along with its dependency injection extensions:

Bash

dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions

Step 2: Create a Validator for Your Command

FluentValidation allows you to write strongly-typed, expressive validation rules using AbstractValidator<T>.

Create a validator class for your CreateProductCommand:

C#

using FluentValidation;
using YourSolution.Application.Features.Products.Commands;

namespace YourSolution.Application.Features.Products.Validators
{
    public class CreateProductCommandValidator : AbstractValidator<CreateProductCommand>
    {
        public CreateProductCommandValidator()
        {
            RuleFor(p => p.Name)
                .NotEmpty().WithMessage("Product name is required.")
                .MaximumLength(100).WithMessage("Product name cannot exceed 100 characters.");

            RuleFor(p => p.Price)
                .GreaterThan(0).WithMessage("Product price must be greater than zero.");
        }
    }
}

Step 3: Implement the MediatR Pipeline Behavior

Next, create a generic pipeline behavior. This class implements MediatR's IPipelineBehavior<TRequest, TResponse> interface. It checks if any validators are registered for the incoming request type, runs them, and collects any failures.

Create a file named ValidationBehavior.cs in your Application layer:

C#

using FluentValidation;
using MediatR;

namespace YourSolution.Application.Common.Behaviors
{
    public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
        where TRequest : notnull
    {
        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())
            {
                var context = new ValidationContext<TRequest>(request);

                // Execute all validators concurrently
                var validationResults = await Task.WhenAll(
                    _validators.Select(v => v.ValidateAsync(context, cancellationToken))
                );

                // Aggregate all error failures
                var failures = validationResults
                    .SelectMany(r => r.Errors)
                    .Where(f => f != null)
                    .ToList();

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

            // If validation passes, proceed to the command/query handler
            return await next();
        }
    }
}

Step 4: Register Validators and Pipeline Behaviors in DI

Now, wire everything into your Application layer's dependency injection container. You need to tell MediatR about the pipeline behavior and tell FluentValidation to scan your assembly for all validator classes.

Update your DependencyInjection.cs inside the Application project:

C#

using FluentValidation;
using Microsoft.Extensions.DependencyInjection;
using System.Reflection;
using YourSolution.Application.Common.Behaviors;

namespace YourSolution.Application
{
    public static class DependencyInjection
    {
        public static IServiceCollection AddApplication(this IServiceCollection services)
        {
            var assembly = Assembly.GetExecutingAssembly();

            // 1. Register MediatR handlers and add the open pipeline behavior
            services.AddMediatR(cfg => {
                cfg.RegisterServicesFromAssembly(assembly);
                cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
            });

            // 2. Automatically discover and register all FluentValidation validators
            services.AddValidatorsFromAssembly(assembly);

            return services;
        }
    }
}

Step 5: Handle Validation Exceptions Globally

When the ValidationBehavior throws a FluentValidation ValidationException, your API needs to catch it and return a clean 400 Bad Request containing the specific error messages, rather than letting it bubble up as an unhandled 500 Internal Server Error.

You can manage this gracefully using global exception handling middleware or ASP.NET Core's built-in problem details feature.

Summary

By combining FluentValidation with MediatR pipeline behaviors, you achieve:

  • Zero Boilerplate: Controllers and handlers never have to manually check ModelState.IsValid or invoke validators.

  • Centralized Rules: All validation rules are neatly organized within dedicated validator classes.

  • Consistent Error Handling: Every invalid request is intercepted and rejected uniformly across the entire application.