ASP.NET Core  

Creating a Custom Validation Pipeline in ASP.NET Core Using FluentValidation

FluentValidation is one of the cleanest and most maintainable ways to validate incoming API requests in ASP.NET Core. But instead of calling validators manually inside controllers, the best practice is to build a custom validation pipeline that runs automatically before the controller executes.

This article shows a complete, step-by-step implementation with real-world patterns used in enterprise applications.

1. Why Build a Custom Validation Pipeline?

A validation pipeline helps you:

  • Remove validation logic from controllers

  • Enforce consistent validation across all endpoints

  • Automatically validate every request DTO

  • Return clean, structured validation errors

  • Add logging and audit in one place

2. Install FluentValidation

dotnet add package FluentValidation
dotnet add package FluentValidation.AspNetCore

3. Create Your Request DTO

public class CreateUserRequest
{
    public string FullName { get; set; }
    public string Email { get; set; }
    public int? Age { get; set; }
}

4. Create a FluentValidator for the DTO

using FluentValidation;

public class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
    public CreateUserRequestValidator()
    {
        RuleFor(x => x.FullName)
            .NotEmpty()
            .MaximumLength(50);

        RuleFor(x => x.Email)
            .NotEmpty()
            .EmailAddress();

        RuleFor(x => x.Age)
            .NotNull()
            .InclusiveBetween(18, 60);
    }
}

5. Build a Custom Validation Pipeline (Middleware/Filter)

Option: Use an Action Filter (cleaner and recommended)

using FluentValidation;
using FluentValidation.Results;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

public class ValidationFilter : IAsyncActionFilter
{
    private readonly IServiceProvider _serviceProvider;

    public ValidationFilter(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        foreach (var argument in context.ActionArguments.Values)
        {
            if (argument == null) continue;

            var validatorType = typeof(IValidator<>).MakeGenericType(argument.GetType());
            var validator = _serviceProvider.GetService(validatorType) as IValidator;

            if (validator == null) continue;

            ValidationResult result = await validator.ValidateAsync(new ValidationContext<object>(argument));

            if (!result.IsValid)
            {
                var errors = result.Errors
                    .Select(e => new { field = e.PropertyName, message = e.ErrorMessage });

                context.Result = new BadRequestObjectResult(new
                {
                    Status = "ValidationFailed",
                    Errors = errors
                });

                return;
            }
        }

        await next();
    }
}

6. Register Pipeline + Validators in Program.cs

builder.Services.AddScoped<IValidator<CreateUserRequest>, CreateUserRequestValidator>();

builder.Services.AddControllers(options =>
{
    options.Filters.Add<ValidationFilter>();   // apply pipeline globally
});

7. Create Controller — NO validation logic needed!

[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
    [HttpPost]
    public IActionResult CreateUser([FromBody] CreateUserRequest request)
    {
        return Ok(new { Message = "User created successfully." });
    }
}

Pipeline validates automatically.

8. Sample Error Response

{
  "status": "ValidationFailed",
  "errors": [
    { "field": "Email", "message": "Email is not valid" },
    { "field": "Age", "message": "Age must be between 18 and 60" }
  ]
}

9. Adding Custom Rules (Real-World Examples)

Check for Duplicate Email (Business Rule)

RuleFor(x => x.Email)
    .MustAsync(async (email, _) =>
    {
        return !await userRepository.EmailExists(email);
    })
    .WithMessage("Email already exists");

10. Add Global Error Formatting

Create a reusable error result:

public static class ValidationErrorFormatter
{
    public static object Format(ValidationResult result) =>
        result.Errors.Select(e => new
        {
            field = e.PropertyName,
            message = e.ErrorMessage
        });
}

Use it inside your filter.

11. Advanced: Add Logging for Validation Failures

Modify filter:

private readonly ILogger<ValidationFilter> _logger;

_logger.LogWarning("Validation failed for {Type}: {@Errors}",
    argument.GetType().Name, errors);

12. Optional: Use MediatR Pipeline Behavior (DDD / Clean Architecture)

For CQRS users:

public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, 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 = _validators
            .Select(v => v.Validate(context))
            .SelectMany(result => result.Errors)
            .Where(f => f != null)
            .ToList();

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

        return await next();
    }
}

13. Real-World Best Practices

  • Always create separate validators for DTOs

  • Never validate inside controllers

  • Add unit tests for validators

  • Use dependency validation (business rules)

  • Enable localization for error messages

  • Return consistent error formatting

14. Final Folder Structure

/Validators
    CreateUserRequestValidator.cs
/Filters
    ValidationFilter.cs
/DTOs
    CreateUserRequest.cs
/Controllers
    UsersController.cs
/Program.cs