FluentValidation for Minimal APIs: Clean Models, Clear Errors

The Validation Problem in Minimal APIs

If you've ever written the same null checks and range validations across multiple Minimal API endpoints, you know the pain. Controller-based APIs get automatic model validation from data annotations, but Minimal APIs don't have that built-in magic. You're left repeating validation logic or letting invalid data reach your handlers.

FluentValidation solves this by moving validation rules into dedicated classes that are testable, composable, and reusable. You define rules with a fluent syntax, register validators in DI, then apply them through endpoint filters. Invalid requests never reach your handler, and clients get consistent error responses.

You'll build validators for models, wire them up with endpoint filters, handle complex scenarios like nested objects, and learn how to return standardized validation errors. By the end, you'll have a pattern that scales from simple required fields to multi-step business rules.

Setting Up FluentValidation

FluentValidation is a NuGet package that provides a strongly-typed validation library. Install the main package, define validators by inheriting from AbstractValidator, and register them in your DI container. The framework handles the rest through dependency injection.

Create a validator class for each model you want to validate. Use RuleFor to define rules for properties, chaining validation methods like NotEmpty, MaximumLength, or custom predicates. The fluent API reads like English, making rules self-documenting.

Here's how to install FluentValidation and create a basic validator for a product registration request.

Terminal
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
Validators/CreateProductValidator.cs
using FluentValidation;

public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage("Product name is required")
            .MaximumLength(100).WithMessage("Name cannot exceed 100 characters");

        RuleFor(x => x.Price)
            .GreaterThan(0).WithMessage("Price must be greater than zero")
            .LessThan(10000).WithMessage("Price cannot exceed 10,000");

        RuleFor(x => x.Category)
            .NotEmpty().WithMessage("Category is required")
            .Must(BeValidCategory).WithMessage("Invalid category");
    }

    private bool BeValidCategory(string category)
    {
        var validCategories = new[] { "Electronics", "Clothing", "Books" };
        return validCategories.Contains(category);
    }
}

public record CreateProductRequest(string Name, decimal Price, string Category);

Each RuleFor call defines validation for one property. NotEmpty ensures the value isn't null or whitespace, while GreaterThan and MaximumLength apply range checks. The Must method takes a custom predicate for logic that doesn't fit standard validators.

WithMessage customizes error text returned to clients. Without it, FluentValidation uses default messages that aren't always user-friendly. Provide clear, actionable feedback so clients know exactly what to fix.

Wiring Validators with Endpoint Filters

Endpoint filters are the bridge between FluentValidation and Minimal APIs. Create a filter that extracts the model from arguments, resolves the validator from DI, runs validation, and returns errors if validation fails. This keeps validation logic out of your endpoint handlers.

The filter uses IServiceProvider to resolve validators dynamically based on argument type. Call Validate on the validator, check if the result is valid, and return a validation problem response for failures. Only valid requests proceed to the handler.

Here's a reusable validation filter that works with any validated type.

Filters/ValidationFilter.cs
using FluentValidation;

public class ValidationFilter<T> : IEndpointFilter
{
    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        var argument = context.Arguments.OfType<T>().FirstOrDefault();
        if (argument is null)
            return await next(context);

        var validator = context.HttpContext.RequestServices
            .GetService<IValidator<T>>();

        if (validator is null)
            return await next(context);

        var validationResult = await validator.ValidateAsync(argument);

        if (!validationResult.IsValid)
        {
            var errors = validationResult.Errors
                .GroupBy(e => e.PropertyName)
                .ToDictionary(
                    g => g.Key,
                    g => g.Select(e => e.ErrorMessage).ToArray()
                );

            return Results.ValidationProblem(errors);
        }

        return await next(context);
    }
}

The filter is generic over the type being validated. It extracts the argument, resolves the matching validator, and runs validation asynchronously. Errors are grouped by property name and converted into the dictionary format that Results.ValidationProblem expects.

This filter works for any model with a registered validator. Apply it to endpoints, and validation happens automatically before your handler runs.

Registering Validators and Applying Filters

Register validators in your DI container using AddValidatorsFromAssemblyContaining. This scans your assembly for all AbstractValidator subclasses and registers them automatically. No manual registration needed for each validator.

Apply the validation filter to endpoints with AddEndpointFilter. Pass the generic ValidationFilter type with your request model as the type parameter. The filter resolves the validator and applies rules before the handler executes.

Here's a complete example showing registration and usage.

Program.cs
using FluentValidation;

var builder = WebApplication.CreateBuilder(args);

// Register all validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();

var app = builder.Build();

app.MapPost("/products", (CreateProductRequest request) =>
{
    // Validation already passed if we reach here
    return Results.Created($"/products/{Guid.NewGuid()}", request);
})
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();

app.Run();

The AddValidatorsFromAssemblyContaining method finds all validators automatically. When the POST endpoint receives a request, the filter runs first, validates the CreateProductRequest, and either returns errors or allows the handler to proceed.

Complex Validation Scenarios

FluentValidation handles scenarios that data annotations struggle with. Validate one property based on another, perform async database lookups, or compose validators for nested objects. These patterns keep validation logic centralized and testable.

Use When to apply conditional rules. Check related properties with custom predicates, or call MustAsync for async validation like checking if an email already exists. Nested object validation uses SetValidator to delegate to child validators.

Here's an example with conditional rules and nested validation.

Validators/CreateOrderValidator.cs
using FluentValidation;

public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.CustomerId)
            .GreaterThan(0).WithMessage("Valid customer ID required");

        RuleFor(x => x.ShippingAddress)
            .NotNull().WithMessage("Shipping address required")
            .SetValidator(new AddressValidator());

        RuleFor(x => x.Items)
            .NotEmpty().WithMessage("Order must contain at least one item")
            .ForEach(item => item.SetValidator(new OrderItemValidator()));

        RuleFor(x => x.ExpressShipping)
            .Equal(false)
            .When(x => x.TotalAmount < 50)
            .WithMessage("Express shipping requires orders over $50");
    }
}

public class AddressValidator : AbstractValidator<Address>
{
    public AddressValidator()
    {
        RuleFor(x => x.Street).NotEmpty();
        RuleFor(x => x.City).NotEmpty();
        RuleFor(x => x.ZipCode).Matches(@"^\d{5}$");
    }
}

public class OrderItemValidator : AbstractValidator<OrderItem>
{
    public OrderItemValidator()
    {
        RuleFor(x => x.Quantity).GreaterThan(0);
        RuleFor(x => x.Price).GreaterThan(0);
    }
}

public record CreateOrderRequest(
    int CustomerId,
    Address ShippingAddress,
    List<OrderItem> Items,
    decimal TotalAmount,
    bool ExpressShipping);

public record Address(string Street, string City, string ZipCode);
public record OrderItem(int ProductId, int Quantity, decimal Price);

SetValidator delegates validation of nested objects to specialized validators. ForEach applies a validator to each item in a collection. When applies rules conditionally based on other property values, perfect for business rules that depend on context.

Build a Validated API

Create a user registration endpoint with validation to see the complete flow from validator definition to error response.

Steps

  1. Scaffold project: dotnet new web -n ValidationDemo
  2. Move into folder: cd ValidationDemo
  3. Install packages: dotnet add package FluentValidation and dotnet add package FluentValidation.DependencyInjectionExtensions
  4. Replace Program.cs with code below
  5. Execute: dotnet run
  6. Test with invalid data: curl -X POST https://localhost:5001/register -H "Content-Type: application/json" -d '{"email":"bad","password":"123"}'
Program.cs
using FluentValidation;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
var app = builder.Build();

app.MapPost("/register", (RegisterRequest request) =>
    Results.Ok(new { Message = "Registration successful" }))
   .AddEndpointFilter<ValidationFilter<RegisterRequest>>();

app.Run();

public record RegisterRequest(string Email, string Password);

public class RegisterRequestValidator : AbstractValidator<RegisterRequest>
{
    public RegisterRequestValidator()
    {
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Password).MinimumLength(8);
    }
}
ValidationDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
</Project>

What you'll see

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Email": ["'Email' is not a valid email address."],
    "Password": ["Password must be at least 8 characters"]
  }
}

Testing Validators

Validators are standalone classes perfect for unit testing. Test each rule independently without spinning up a web server or making HTTP requests. FluentValidation provides a TestValidate helper that returns results you can assert against.

Create test cases for valid inputs, boundary conditions, and invalid values. Verify that the right properties have errors and that error messages match expectations. This catches validation bugs before they reach production.

Here's an xUnit test for the product validator.

Tests/CreateProductValidatorTests.cs
using FluentValidation.TestHelper;
using Xunit;

public class CreateProductValidatorTests
{
    private readonly CreateProductValidator _validator = new();

    [Fact]
    public void Should_Have_Error_When_Name_Is_Empty()
    {
        var request = new CreateProductRequest("", 100m, "Electronics");
        var result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Name);
    }

    [Fact]
    public void Should_Not_Have_Error_When_Valid()
    {
        var request = new CreateProductRequest("Laptop", 999m, "Electronics");
        var result = _validator.TestValidate(request);
        result.ShouldNotHaveAnyValidationErrors();
    }

    [Fact]
    public void Should_Have_Error_When_Price_Is_Zero()
    {
        var request = new CreateProductRequest("Laptop", 0m, "Electronics");
        var result = _validator.TestValidate(request);
        result.ShouldHaveValidationErrorFor(x => x.Price);
    }
}

TestValidate runs validation and returns a result you can query. ShouldHaveValidationErrorFor asserts that a specific property failed validation. ShouldNotHaveAnyValidationErrors confirms valid inputs pass all rules. These helpers make validation testing concise and readable.

Common Questions

Why use FluentValidation instead of data annotations?

FluentValidation separates validation logic from models, making rules testable and reusable. Complex validation like checking related properties or calling services is cleaner than with attributes. You can also compose validators and build conditional rules that data annotations can't handle easily.

How do I validate nested objects with FluentValidation?

Use RuleFor with SetValidator to validate nested properties. Create separate validators for child objects, then reference them in the parent validator. FluentValidation automatically validates the nested object and flattens errors into the response with proper property paths.

Can I perform async validation rules?

Yes, use MustAsync for rules that need async operations like database checks. Validate unique email addresses or external API calls within validators. Call ValidateAsync in your filter instead of Validate to ensure async rules execute properly without blocking.

What's the best way to return validation errors?

Use Results.ValidationProblem with a dictionary mapping property names to error arrays. This follows RFC 7807 problem details format that clients expect. Extract errors from ValidationResult.Errors and group by PropertyName for consistent, machine-readable responses.

Back to Articles