Every POST handler I've written in a minimal API for the last four years opens with the same liturgy: null-check the name, range-check the price, build a dictionary of errors, bail with a 400. Controllers got automatic model validation back when [ApiController] shipped, and then minimal APIs arrived without it and we all quietly went back to writing if-blocks like it was 2010. I counted the validation preamble in the demo handler I wrote for this post: eleven lines before the first line of actual work.
.NET 10 finally closes that gap, and it's one line. I spent an afternoon poking at it, and the most interesting thing I found wasn't the feature working. It was my own validation code silently becoming unreachable.
The before picture
Here's the handler shape I mean. One DTO, three rules, all enforced by hand:
app.MapPost("/manual/products", (CreateProduct dto) =>
{
var errors = new Dictionary<string, string[]>();
if (string.IsNullOrWhiteSpace(dto.Name))
errors["Name"] = ["Name is required."];
else if (dto.Name.Length > 80)
errors["Name"] = ["Name must be 80 characters or fewer."];
if (dto.Price is < 0.01m or > 10_000m)
errors["Price"] = ["Price must be between 0.01 and 10000."];
if (dto.SalePrice is { } sale && sale >= dto.Price)
errors["SalePrice"] = ["SalePrice must be lower than Price."];
if (errors.Count > 0)
return Results.ValidationProblem(errors);
return Results.Created($"/products/1", dto);
});
It works. It also gets copy-pasted into every endpoint that binds a body, drifts a little each time, and keeps the rules as far away from the type as possible. The DTO says nothing about what a valid product is; the truth lives in some handler.
One line and a record
The .NET 10 version. Rules move onto the type as plain DataAnnotations:
builder.Services.AddValidation();
app.MapPost("/products", (CreateProduct dto) =>
Results.Created($"/products/1", dto));
public record CreateProduct(
[property: Required, StringLength(80)] string Name,
[property: Range(0.01, 10_000)] decimal Price,
decimal? SalePrice);
No package reference — it's in the shared framework. I didn't register CreateProduct anywhere either; binding a parameter of that type is enough. My demo app probes itself with HttpClient (this is behavior, not benchmarks — .NET 10 in a small Linux container), and here's the real exchange when I feed it garbage:
POST /products [garbage in: {"name":"","price":-5,"salePrice":3}]
-> 400 BadRequest
{"title":"One or more validation errors occurred.","errors":{"Name":["The Name field is required."],"Price":["The field Price must be between 0.01 and 10000."]}}
The handler never ran. Validation happens at binding time, before your code, and the response is the same ValidationProblemDetails shape controllers produce, so clients can't tell which flavor of endpoint rejected them. Query parameters get the same treatment — an attribute directly on the parameter is enough:
app.MapGet("/search", ([Range(1, 100)] int pageSize = 20) =>
Results.Ok(new { pageSize }));
GET /search?pageSize=0
-> 400 BadRequest
{"errors":{"pageSize":["The field pageSize must be between 1 and 100."]}}
The part that surprised me
First version of my demo kept the manual endpoint exactly as written above, so I could compare the two worlds side by side. Then I ran it, sent the garbage payload to the manual endpoint, and got back error messages I never wrote: "The Name field is required." That's the framework's wording, not mine.
Because the DTO now carries attributes, AddValidation was rejecting the request before my eleven lines ever executed. My checks weren't wrong. They were dead. If your codebase already has attributes on DTOs from an earlier FluentValidation-plus-annotations era, turning this on may start enforcing rules you forgot were declared. To make the manual endpoint behave like the old world for the demo, I had to opt it out explicitly:
app.MapPost("/legacy/products", (CreateProduct dto) => ...)
.DisableValidation();
And the receipt, from the same run:
POST /legacy/products [garbage in, validation off]
-> 201 Created
{"name":"","price":-5,"salePrice":3}
A product with no name and a price of minus five, welcomed into the system. That's the world we're leaving behind.
Cross-field rules work through the IValidatableObject you already know: my SalePrice < Price rule lives there, and an invalid pair comes back as a clean 400. One nuance the run exposed: my manual endpoint reported all three errors at once, but the framework reported only the two attribute failures — Validate doesn't run until attribute validation passes, so cross-field errors arrive in a second wave. Users fix the name and the price, resubmit, and only then learn the sale price is wrong. Mildly annoying, worth knowing.
Where I'd still say no
If your rules are genuinely conditional — this field is required only for that tenant, prices validate against a table in the database — attributes will fight you, and FluentValidation still earns its keep. The stock messages are also flatly English; there are hooks for customizing, but if you need localized error text today, check that story before migrating anything user-facing. And this is minimal-API binding only. Controllers already had it, so there's nothing to migrate there.
My opinion, stated as one: for the boring 80% of validation — required, range, length, email — attributes on the type beat checks in the handler, because the contract travels with the DTO instead of hiding in endpoint code. This should have shipped in .NET 6. I'll take it in 10.
Full runnable sample: https://github.com/ssukhpinder/dev-to-code-samples/tree/main/010-minimal-api-validation
Are you moving these checks onto attributes, or does FluentValidation own your validation layer for good reasons I've skipped? Tell me in the comments.
— still deleting code nobody asked me to.
Top comments (0)