Built-In Validation (.NET 10)

Turn on minimal API validation with AddValidation so DataAnnotations on parameters and request records return a 400 ValidationProblem automatically.

Syntaxbuilder.Services.AddValidation(); app.MapPost("/orders", (CreateOrder order) => TypedResults.Created($"/orders/{order.Id}", order)); public record CreateOrder([Required, StringLength(20)] string CustomerId, [MinLength(1)] List<OrderLine> Lines);

Before .NET 10, minimal APIs ignored [Required] and [Range] unless you wrote a filter. Now one line enables it:

builder.Services.AddValidation();

How it works

A source generator that ships with the .NET 10 SDK inspects your handlers at compile time, finds parameters and types that carry validation attributes, and generates the metadata describing them; there is no runtime reflection scan. AddValidation() registers that metadata, and each endpoint with something to validate gets a validation step that runs after binding and before your own endpoint filters and the handler. If anything fails, the handler never runs and the client gets a 400 HttpValidationProblemDetails:

{"title":"One or more validation errors occurred.",
 "errors":{"Lines[0].Quantity":["The field Quantity must be between 1 and 1000."]}}

With AddProblemDetails() the body also gets type, status and traceId.

What gets validated

  • Attributes on handler parameters themselves: ([Range(1, 100)] int pageSize), route values and headers included.
  • Properties of complex types, and record primary constructor parameters.
  • Nested objects and collections, reported with paths such as Lines[0].Sku.
  • IValidatableObject.Validate for cross-field rules. It runs only when that object's attribute checks passed, so a client fixing attribute errors can then receive new cross-field errors on the next attempt.
  • Custom ValidationAttribute subclasses, exactly as in MVC.

.DisableValidation() opts one endpoint (or group) out, for example a bulk import that records invalid rows instead of rejecting them.

Choosing a validation approach

ApproachChoose it when
AddValidation() with DataAnnotationsDefault for new APIs: shape rules (required, length, range, format) that live next to the contract
FluentValidation in an endpoint filterThe team already uses it, or rules are conditional and long
Checks in the handler or domainRules that need the database, such as a unique purchase order number (return 409 or a ValidationProblem yourself)

Production traps

  • Only public types are validated. In .NET 10 the generator skips non-public request types: an internal record CreateOrder compiles, binds and is never validated, with no warning. Make request contracts public.
  • Error keys are C# member names (ContactEmail, Lines[0].Sku), not the camelCase JSON names, and [JsonPropertyName] does not change them. Tell front-end teams, or they will map errors to the wrong fields.
  • [Required] on a non-nullable value type does nothing: a missing int is 0, which is not null. Use int? with [Required], or a [Range] that excludes 0.
  • Attributes are synchronous. Never block on a database call inside IsValid.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Reject POST /orders with an empty line list or a quantity of 0 with a 400 ValidationProblem before the handler opens a database connection.
  • Validate ?pageSize=500 against [Range(1, 100)] on a query parameter without writing any filter code.
  • Let /admin/orders/import accept rows with invalid emails by calling DisableValidation and reporting bad rows in the import result.

More examples

Pitfall: an internal request type is never validated

Both endpoints compile and both bind JSON the same way, which is what makes this dangerous: the only difference is the accessibility of the record. In .NET 10 the validation source generator only emits metadata for types it can reference publicly, so the second endpoint silently accepts <code>{"email":""}</code>. Keep request contracts public, and keep at least one HTTP test per endpoint that sends an invalid body and asserts 400.

Example · csharp
using System.ComponentModel.DataAnnotations;

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

// Validated: public record.
app.MapPost("/customers", (CreateCustomer customer) => TypedResults.Created($"/customers/{customer.Email}", customer));

// NOT validated: the generator skips the internal record, so an empty Email reaches the handler.
app.MapPost("/legacy/customers", (LegacyCreateCustomer customer) => TypedResults.Created($"/customers/{customer.Email}", customer));

app.Run();

public record CreateCustomer([Required, EmailAddress] string Email, [Required, StringLength(100)] string Name);

internal record LegacyCreateCustomer([Required, EmailAddress] string Email, [Required, StringLength(100)] string Name);

An integration test that pins the error shape

The test sends camelCase JSON but asserts PascalCase error keys, which documents the behaviour front-end developers trip over. Pinning the content type and the key paths means a change in the validation pipeline, or someone making the request type internal, fails the build instead of silently changing the error contract that clients parse.

Example · csharp
using System.Net;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Testing;
using Xunit;

public class OrderValidationTests(WebApplicationFactory<Program> factory) : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Invalid_order_returns_400_with_member_name_keys()
    {
        var client = factory.CreateClient();
        var ct = TestContext.Current.CancellationToken;

        var response = await client.PostAsJsonAsync("/orders", new
        {
            customerId = "C-1001",
            contactEmail = "not-an-email",
            lines = new[] { new { sku = "KET-001", quantity = 0 } },
            deliverFrom = "2026-11-02",
            deliverTo = "2026-11-06"
        }, ct);

        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
        Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType);

        using var body = JsonDocument.Parse(await response.Content.ReadAsStringAsync(ct));
        var errors = body.RootElement.GetProperty("errors");
        Assert.True(errors.TryGetProperty("ContactEmail", out _));
        Assert.True(errors.TryGetProperty("Lines[0].Quantity", out _));
    }
}

public partial class Program;

Cross-field and database rules in the right places

Three layers, each doing what it is good at: attributes for single-field shape, <code>IValidatableObject</code> for rules across fields of one request, and the handler for rules that need I/O. The handler reuses the same <code>ValidationProblem</code> shape and member-name key, so clients handle a duplicate number exactly like any other field error. A unique index in the database still has to back the check, because two requests can pass it at the same time.

Example · csharp
using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Http.HttpResults;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();
builder.Services.AddSingleton<InvoiceNumbers>();
var app = builder.Build();

app.MapPost("/invoices", async Task<Results<Created<CreateInvoice>, ValidationProblem>> (CreateInvoice invoice, InvoiceNumbers numbers, CancellationToken ct) =>
{
    // Shape rules already ran; this rule needs storage, so it lives in the handler.
    if (await numbers.ExistsAsync(invoice.Number, ct))
    {
        return TypedResults.ValidationProblem(new Dictionary<string, string[]>
        {
            [nameof(CreateInvoice.Number)] = [$"Invoice {invoice.Number} already exists."]
        });
    }
    return TypedResults.Created($"/invoices/{invoice.Number}", invoice);
});

app.Run();

public record CreateInvoice(
    [Required, RegularExpression(@"^INV-\d{4}-\d{4}$")] string Number,
    [Range(typeof(decimal), "0.01", "1000000")] decimal Amount,
    DateOnly IssuedOn,
    DateOnly DueOn) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (DueOn < IssuedOn)
            yield return new ValidationResult("DueOn cannot be before IssuedOn.", [nameof(DueOn)]);
        if (DueOn > IssuedOn.AddDays(120))
            yield return new ValidationResult("Payment terms longer than 120 days need approval.", [nameof(DueOn)]);
    }
}

public sealed class InvoiceNumbers
{
    public Task<bool> ExistsAsync(string number, CancellationToken ct) => Task.FromResult(number == "INV-2026-0001");
}

Discussion

  • Be the first to comment on this lesson.