Minimal API validation in .NET 10: what AddValidation checks and what it skips

The single AddValidation line at the top, then a bad JSON order body on the left and the 400 response on the right naming Reference, Quantity, Email, Address.City and Lines[0].Sku; a note says the body carries no type and no status without AddProblemDetails, and a strip at the bottom lists the four things the same call steps over
COMPANION REPO · LIVE FROM GITHUB

A minimal API endpoint used to ignore the attributes on its own parameters. You could write [Range(1, 500)] int quantity in a handler signature, send 900, and the handler would run with 900 in hand. ASP.NET Core MVC controllers have enforced DataAnnotations from the beginning, which is what made the gap easy to trip over: the attributes compile, they show up in the OpenAPI document, and nothing checks them. The usual answers were FluentValidation or an endpoint filter you wrote once and copied into the next project.

.NET 10 closes it with one call:

builder.Services.AddValidation();

That's the whole registration. No package reference, no InterceptorsNamespaces entry in the csproj, nothing on the endpoints themselves. A Roslyn generator that ships as an analyzer inside the ASP.NET Core reference pack reads your endpoint signatures at build time, works out which types it can validate, and intercepts that AddValidation() call with a generated resolver. An endpoint filter then runs the resolver before each handler.

The part that took me a while to map is where it stops. Several kinds of input carry attributes, go through an endpoint on an app that called AddValidation(), and come back as a cheerful 200. I hit the first one while moving a service to .NET 10 and spent a while convinced I had put the call in the wrong place. Everything below was measured on SDK 10.0.201 with the ASP.NET Core 10.0.5 runtime; the demo at the end runs it all as tests.

What the one line turns on

The request types stay ordinary. Nothing in this file knows the feature exists:

public record OrderRequest
{
    [Required(AllowEmptyStrings = false)] public string Reference { get; init; } = "";
    [Range(1, 500)]                       public int Quantity    { get; init; }
    [EmailAddress]                        public string? Email   { get; init; }

    public AddressRequest? Address { get; init; }
    public List<LineRequest> Lines { get; init; } = [];
}

app.MapPost("/orders", (OrderRequest order) => Results.Ok(order.Reference));

Post a body that breaks every rule in it and you get a 400 with all of them at once, not the first one. This is the response body, wrapped across lines here because the real one arrives as a single line:

{"title":"One or more validation errors occurred.","errors":{
  "Reference":["The Reference field is required."],
  "Quantity":["The field Quantity must be between 1 and 500."],
  "Email":["The Email field is not a valid e-mail address."],
  "Address.City":["The City field is required."],
  "Address.Postcode":["The field Postcode must be a string with a maximum length of 8."],
  "Lines[0].Sku":["The Sku field is required."],
  "Lines[0].Qty":["The field Qty must be between 1 and 99."]}}

Nested objects get a dotted path, items of a List<T> get an index. Beyond the body, the filter also checks query, route and header parameters, the properties of an [AsParameters] type, a form-bound complex parameter, your own ValidationAttribute subclasses, and IValidatableObject.Validate. The keys are worth a table of their own, because they are the part a client binds to:

| Input | Key in errors | |---|---| | [Required] string Reference on the body record | Reference | | AddressRequest.City | Address.City | | second item of a List<LineRequest> | Lines[1].Sku | | [Range(1, 100)] int page from the query string | page | | [Range(1, 10_000)] int id from the route | id | | [FromHeader(Name = "X-Tenant")] string? tenant | tenant | | [AsParameters] PageArgs with a Page property | Page |

Two of those surprise people. The header key is tenant, the C# parameter name, not the X-Tenant the caller actually sent. And the body keys are CLR property names, so a property carrying [JsonPropertyName("full_name")] still reports as Name. [Display(Name = "Customer name")] changes the message text and leaves the key alone, which is the combination most likely to send a front-end developer looking for a bug in their own mapping code.

Attributes on a positional record's parameters work, which isn't obvious from the outside. The C# compiler leaves the attribute on the primary constructor parameter and doesn't copy it to the generated property, so typeof(ShipmentRequest).GetProperty("Carrier") reports no ValidationAttribute at all. The generator reads the constructor parameter instead, and [Required(AllowEmptyStrings = false)] string Carrier is enforced.

ONE VALIDATOR · WHAT IT REACHES AND WHAT IT WALKS PAST
INPUT, ONE FIELD AT A TIME
reference: ""body · [Required]✕
quantity: 900body · [Range(1, 500)]✕
email: "nope"body · [EmailAddress]✕
address.city: ""nested object · [Required]✕
lines[0].sku: ""List<T> item · [Required]✕
lines[0].sku: ""same rule, property typed T[]—
lines["a"].sku: ""same rule, property typed Dictionary—
q: absentquery · [Required] on string?—
RESPONSE
400 · application/json Reference Quantity Email Address.City Lines[0].Sku
five fields named, three walked past — the three reach your handler with the values above, and the 200 looks exactly like a request that passed

The response is not problem+json until you ask for it

The 400 above has a title and an errors dictionary, and stops there. No type, no status, and the Content-Type is application/json, not application/problem+json. A client that switches on the problem media type, or reads status out of the body won't recognise it.

Register the problem details service as well:

builder.Services.AddValidation();
builder.Services.AddProblemDetails();

and the same request produces the document you probably expected (trace id trimmed here, it is a real one in the response):

{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"Reference":["The Reference field is required."],"Quantity":["The field Quantity must be between 1 and 500."]},"traceId":"00-1c78…-00"}

application/problem+json this time, with type, status and a traceId. The status code is 400 either way, so this is easy to ship without noticing.

An array property is stepped over

Here is the one that cost me the most time. These two records differ in one token:

public record BasketWithList
{
    [Required(AllowEmptyStrings = false)] public string Owner { get; init; } = "";
    public List<LineRequest> Lines { get; init; } = [];
}

public record BasketWithArray
{
    [Required(AllowEmptyStrings = false)] public string Owner { get; init; } = "";
    public LineRequest[] Lines { get; init; } = [];
}

Give their endpoints byte-identical JSON with one bad line in it. The List version answers 400 with Lines[0].Sku. The array version answers 200. An empty Owner is still reported on both, so the endpoint looks like it's validating, and the next person to read the DTO sees attributes on LineRequest and assumes they run.

IList<T>, ICollection<T>, IReadOnlyList<T> and HashSet<T> all behave like List<T>. The array is the odd one out, and the reason is that the generator never looks inside an array type at all. An array that is the body itself can still come back validated: app.MapPost("/lines", (LineRequest[] lines) => ...) returns 400 with keys like lines[0].Sku, but only because LineRequest already has metadata from OrderRequest.Lines. Give that endpoint a type used nowhere else and it answers 200.

The generated resolver shows where it went. Build with -p:EmitCompilerGeneratedFiles=true and read ValidatableInfoResolver.g.cs: BasketWithList gets two members, Owner and Lines, while BasketWithArray gets one.

if (type == typeof(global::ValidationDemo.Api.BasketWithArray))
{
    validatableInfo = new GeneratedValidatableTypeInfo(
        type: typeof(global::ValidationDemo.Api.BasketWithArray),
        members: [
            new GeneratedValidatablePropertyInfo(
                containingType: typeof(global::ValidationDemo.Api.BasketWithArray),
                propertyType: typeof(string),
                name: "Owner",
                displayName: "Owner"
            ),
        ]
    );

Dictionary<string, LineRequest> values are skipped too, and that one fails further along: the generator does record the property, and even emits an entry for the dictionary type itself with a member called this[], yet nothing is checked at run time. The array half is a confirmed generator bug, dotnet/aspnetcore #67412: the code that looks inside List<T> and friends only handles named types, and an array isn't one. It's closed with a fix, and the fix isn't in 10.0.5. I couldn't find an official word on the dictionary case, so re-check both on whatever patch you're on before depending on either direction.

A parameter that bound to null

[Required] string? q on a query parameter looks like it will reject a missing q, and it only half does:

app.MapGet("/search", ([Required] string? q) => Results.Ok(q ?? "(none)"));

GET /search with no q at all returns 200. GET /search?q= returns 400 with The q field is required. The validator runs over bound argument values, and a null argument is skipped before any attribute is consulted. So the absent case, the one the attribute's name promises to catch, is the one it misses.

The same thing happens to [Range(1, 10)] int? n: ?n=99 is a 400, no n at all is a 200. That half is a known limitation with a tracking issue, fixed for .NET 11 rather than 10. If you want a query parameter to be mandatory today, make it non-nullable and let parameter binding refuse the request. That gives you a 400 too, from a different mechanism and with a different body.

While on the subject of attributes that cannot fire: [Required] int Quantity never produces an error, because 0 is not null. [Required] int? Batch does. That one is ordinary DataAnnotations behaviour and it predates all of this, but it is newly visible now that something actually runs the attributes.

Endpoints mapped from a class library

Splitting endpoints into modules is common enough to be a template:

// in a class library, referenced by the host
public static IEndpointRouteBuilder MapInvoices(this IEndpointRouteBuilder endpoints)
{
    endpoints.MapPost("/invoices", (InvoiceRequest invoice) => Results.Ok(invoice.Number));
    return endpoints;
}

InvoiceRequest has [Required] and [Range] on it. The host app calls AddValidation() and app.MapInvoices(). POST /invoices with an empty number and an absurd total returns 200.

The generator only runs in the assembly where AddValidation() is called, so it never sees that signature and never writes metadata for that type. The docs describe the fix: give each such assembly a thin extension method of its own so the generator runs there too.

// in the class library
public static IServiceCollection AddModuleValidation(this IServiceCollection services)
    => services.AddValidation();

// in the host
builder.Services.AddModuleValidation();
builder.Services.AddValidation();

With that in place the same request comes back 400 with Number and Total. A plain Microsoft.NET.Sdk library needs the Microsoft.Extensions.Validation package for this; a library that already has a framework reference to Microsoft.AspNetCore.App, which is how a library gets IEndpointRouteBuilder in the first place, already has both the API and the generator.

Nothing warns you when metadata is missing. The docs are blunt about it: missing metadata produces no exception and no log entry. The .NET 11 analyzers catch more of these cases, and on .NET 10 the check is to send a bad request and look at the status code.

The order the rules run in

IValidatableObject is the usual place to put a rule that spans two properties. It runs after the property-level attributes, and only if none of them failed:

public record BookingRequest : IValidatableObject
{
    [Required(AllowEmptyStrings = false)] public string Room { get; init; } = "";
    public DateOnly From { get; init; }
    public DateOnly To { get; init; }

    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (To < From)
            yield return new ValidationResult("To must be on or after From.", [nameof(To)]);
    }
}

Post an empty Room together with a To that precedes From and the response lists Room only. The date problem is real and unmentioned. Fix the room, re-post, and now To shows up. So a client fixing one field at a time can watch new errors appear on fields it never touched. That's documented behaviour and it matches how MVC has always worked, but it changes what a "the form is now valid" message on the client can truthfully claim.

Turning it off, and the depth ceiling

DisableValidation() on a route handler opts that one endpoint out while its neighbours keep validating, which is handy for an internal endpoint that takes a type it shares with a public one. [SkipValidation] does the same job on a single property, parameter or type.

ValidationOptions.MaxDepth defaults to 32. Past that the validator gives up and throws:

System.InvalidOperationException: Maximum validation depth of 32 exceeded at
'Next.Next.…Next' in 'ChainRequest'. This is likely caused by a circular
reference in the object graph. Consider increasing the MaxDepth in
ValidationOptions if deeper validation is required.

Which arrives at the client as a 500. Deeply nested request bodies are rare, but a self-referencing tree posted by an importer is not, and AddValidation(options => options.MaxDepth = 64) raises the ceiling.

Still marked experimental

AddValidation and DisableValidation carry no [Experimental] attribute in .NET 10. ValidatableTypeAttribute and SkipValidationAttribute do, with the diagnostic id ASP0029, so naming [ValidatableType] from a plain class library is a compile error until you suppress it (the docs call it a warning, but the build fails):

error ASP0029: 'Microsoft.Extensions.Validation.ValidatableTypeAttribute' is for
evaluation purposes only and is subject to change or removal in future updates.
Suppress this diagnostic to proceed.

A <NoWarn> entry, a #pragma, or an .editorconfig rule all clear it, and the attributes stop being experimental in .NET 11. You need [ValidatableType] for a root type the generator can't reach from any endpoint signature, which for a minimal API is mostly a type you validate yourself through the validation service. Web and Razor SDK projects containing .razor files get a generated internal copy of the attribute that isn't experimental, so the error depends on what kind of project you are in.

FluentValidation still has a place for rules that need composition, reuse across a type hierarchy, or async database lookups on .NET 10 (async validation attributes land in .NET 11). For a DTO whose rules are ranges, lengths and required fields, the one line is enough.

The demo

github.com/gdhami-net/minimal-api-validation-demo is one minimal API and 46 tests. ./check.sh or .\check.ps1 runs them:

Test Run Successful.
Total tests: 46
     Passed: 46

Tests/WhatIsCheckedTests.cs asserts the exact error keys from the table above, and Tests/WhatIsSkippedTests.cs asserts a 200 for every case here that isn't validated, including the pair of baskets that differ only by collection type. The response shape lives in Tests/ProblemShapeTests.cs. And if you want the .NET 9 behaviour to compare against, Tests/NoValidationTests.cs boots the same app with the AddValidation() call left out.