Start Debugging

Minimal API validation vs FluentValidation in ASP.NET Core 11: which should you pick?

Use the built-in source-generated validation for synchronous, attribute-expressible rules in ASP.NET Core 11; reach for FluentValidation when you need async rules, complex cross-field logic, or validation kept out of your domain models.

If you are starting a fresh ASP.NET Core 11 minimal API, use the built-in validation: call AddValidation(), annotate your request record with DataAnnotations, and a source generator returns a 400 ProblemDetails before your handler runs, with zero dependencies and Native AOT support. Reach for FluentValidation (currently 12.1.1, Apache 2.0) only when you need something the built-in feature genuinely cannot do: asynchronous rules that hit a database, rich cross-field logic, or validation kept entirely out of your domain models. The decisive axis is async and complex conditional rules. If you need those, FluentValidation wins; if you do not, the in-box feature is the lower-friction choice. Everything below targets .NET 11 with Microsoft.NET.Sdk.Web and C# 14; the built-in feature first shipped in .NET 10 and is unchanged in 11.

The feature matrix

This is the table you came for. Every row reflects .NET 11 and FluentValidation 12.1.1.

FeatureBuilt-in (DataAnnotations)FluentValidation 12
Dependencynone (in-box, .NET 10+)NuGet package, Apache 2.0
Mechanismcompile-time source generatorruntime reflection + expression trees
Native AOT / trimmingfriendly (no runtime reflection)needs care, partial support
Async rules (DB / HTTP lookups)noyes (MustAsync, ValidateAsync)
Cross-field rulesIValidatableObject (verbose)first-class (RuleFor(...).When(...))
Where rules liveon the model, as attributesin a separate validator class
Conditional / rule setslimitedWhen/Unless/WhenAsync, rule sets
Auto 400 in a minimal APIyes, built-in endpoint filtermanual: you write the endpoint filter
Error response shapeProblemDetails (RFC 9457) for freeyou map failures to a response yourself
Reusable composite rulescustom ValidationAttributeSetValidator, rule chaining, inheritance

The short read: the built-in feature optimizes for “turn it on and forget about it” on simple models, and FluentValidation optimizes for expressive power on complex ones. Neither is strictly better. The rows that flip most decisions are async rules and where the rules live.

When to pick the built-in validation

The in-box validation, covered step by step in how to validate request bodies in minimal APIs without controllers, is the right default in these cases:

When to pick FluentValidation

FluentValidation 12 earns its dependency when the built-in feature hits a wall:

What each one looks like in code

The built-in version is whatever annotations you already know, plus one service registration:

// .NET 11, C# 14 -- Program.cs
using System.ComponentModel.DataAnnotations;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
var app = builder.Build();

app.MapPost("/products", (CreateProduct product) =>
    TypedResults.Created($"/products/{product.Sku}", product));

app.Run();

public record CreateProduct(
    [Required, Length(3, 20)] string Sku,
    [Required, MinLength(2)] string Name,
    [Range(1, 10_000)] int Quantity);

An invalid body never reaches the handler. It comes back as a 400 with a ProblemDetails payload keyed by Sku, Name, and Quantity automatically.

The FluentValidation equivalent moves the rules into a validator class and, because the in-box auto-validation pipeline is deprecated (more on that below), wires it up through an endpoint filter you call explicitly:

// .NET 11, C# 14 -- FluentValidation 12.1.1
using FluentValidation;
using FluentValidation.Results;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddScoped<IValidator<CreateProduct>, CreateProductValidator>();
var app = builder.Build();

app.MapPost("/products", (CreateProduct product) =>
        TypedResults.Created($"/products/{product.Sku}", product))
   .AddEndpointFilter(async (ctx, next) =>
   {
       var product = ctx.GetArgument<CreateProduct>(0);
       var validator = ctx.HttpContext.RequestServices
           .GetRequiredService<IValidator<CreateProduct>>();

       ValidationResult result = await validator.ValidateAsync(product);
       if (!result.IsValid)
           return Results.ValidationProblem(result.ToDictionary());

       return await next(ctx);
   });

public record CreateProduct(string Sku, string Name, int Quantity);

public sealed class CreateProductValidator : AbstractValidator<CreateProduct>
{
    public CreateProductValidator()
    {
        RuleFor(x => x.Sku).NotEmpty().Length(3, 20);
        RuleFor(x => x.Name).NotEmpty().MinimumLength(2);
        RuleFor(x => x.Quantity).InclusiveBetween(1, 10_000);
    }
}

That is more code, but it is also where the power lives. Add an async uniqueness check and the built-in feature simply cannot follow:

// .NET 11, C# 14 -- FluentValidation 12.1.1
public sealed class CreateProductValidator : AbstractValidator<CreateProduct>
{
    public CreateProductValidator(IProductRepository repo)
    {
        RuleFor(x => x.Sku)
            .NotEmpty()
            .Length(3, 20)
            .MustAsync(async (sku, ct) => !await repo.SkuExistsAsync(sku, ct))
            .WithMessage("A product with this SKU already exists.");
    }
}

MustAsync hits the database during validation. There is no DataAnnotations attribute that does this without you opening a DbContext inside the rule, and IValidatableObject.Validate is synchronous, so it cannot await anything. This is the wall that sends teams to FluentValidation.

You can factor the endpoint-filter boilerplate into a small generic extension so each endpoint just chains .WithValidation<CreateProduct>(). The mechanics of composing filters across a route group are the same ones described in organizing minimal API endpoints with MapGroup, so a single MapGroup(...).AddEndpointFilter(...) can validate an entire group.

The benchmark: startup, not steady state

The honest performance story here is not request throughput. For a handful of properties, both approaches validate in microseconds and the cost is dwarfed by JSON deserialization and whatever your handler does. The measurable difference is at the edges: cold start and AOT.

Because the built-in feature is generated at compile time, it adds no startup reflection. FluentValidation builds its rule chains with expression trees that compile on first use, so the first validation of each type pays a one-time JIT and expression-compile cost. On a warm server that cost is amortized to nothing. On a cold-starting serverless function that handles one request and freezes, you pay it on a meaningful fraction of invocations.

The sharper distinction is publishing. A Native AOT minimal API with the built-in validator publishes and runs with no trim warnings, because there is no reflection to preserve. The same app using FluentValidation will surface trimming warnings unless you keep the validated types and their members rooted, since the library reflects over your model and member-access expressions. That is not “FluentValidation is slow,” it is “FluentValidation costs you trim-safety work that the in-box feature does not.” If your deployment target is AOT, treat that as the deciding factor rather than any nanosecond count. Numbers older than this .NET 11 cycle should be re-measured before you trust them; validation internals moved between .NET 8 and .NET 10.

The gotcha that picks for you: the deprecated AspNetCore package

If you go looking for FluentValidation’s ASP.NET Core integration, you will find the FluentValidation.AspNetCore package and its old automatic-validation pipeline. Do not start there. The maintainers deprecated it, and the documentation is explicit: “We no longer recommend using this approach for new projects but it is still available for legacy implementations.” It does not run async validators (it throws), it never supported minimal APIs or Blazor, and its implicit behavior is hard to debug. The recommended path in 2026 is the manual one shown above: register IValidator<T> in DI and call ValidateAsync yourself, from an endpoint filter for minimal APIs or from the handler. If a tutorial tells you to call AddFluentValidationAutoValidation(), it predates this guidance.

A second gotcha cuts the other way, in FluentValidation’s favor: licensing fear is misplaced here. FluentValidation remains Apache 2.0 and free, including for commercial use. The library that changed to a restrictive paid license in January 2025 was Fluent Assertions, a different project with a confusingly similar name. They are unrelated, and choosing FluentValidation does not expose you to a per-seat fee. If a policy reviewer flags “Fluent*” on your dependency list, that is the distinction to make.

The last one is a correctness trap unique to FluentValidation: if any rule in a validator is async, every call site must use ValidateAsync. A stray synchronous Validate() on a validator that contains a MustAsync rule throws at runtime, not at compile time, so it can pass tests that never exercise that path and fail in production. Standardize on ValidateAsync everywhere to avoid the trap entirely.

The recommendation, restated

Default to the built-in validation in ASP.NET Core 11. It is free, it is AOT-friendly, it hands you a standard ProblemDetails contract, and for the synchronous, attribute-shaped rules that make up most request validation it is strictly less work than adding a dependency. Choose FluentValidation deliberately, when you have hit a specific limit: asynchronous rules that need I/O, conditional cross-field logic that IValidatableObject makes ugly, or an architectural rule that validation must not touch your domain models. Many real systems end up with both, and that is fine: let the in-box feature guard the simple DTOs and let FluentValidation own the handful of request types with genuinely complex rules. The mistake is reaching for a validation library on a greenfield .NET 11 service out of habit, before you have a rule the framework cannot already express for free.

Sources

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Back