Minimal API Validation in .NET 10: Built-In Support That Finally Works

TL;DR AddValidation() brings automatic data annotation validation to Minimal APIs. No more manual ModelState checks or third-party libraries required.

Minimal APIs shipped without built-in validation. For years, you needed FluentValidation or manual checks. .NET 10 finally adds native validation support with AddValidation().

Common questions this answers

  • How do I enable validation in Minimal APIs?
  • What validation attributes are supported?
  • How do I create custom validators?
  • How do I customize error responses?
  • How does this compare to FluentValidation?

Definition (what this means in practice)

AddValidation() registers validation services that automatically validate request models in Minimal API endpoints. When a request fails validation, the runtime returns a 400 Bad Request with ProblemDetails containing the errors.

In practice, this means you can add [Required], [Range], and other data annotations to your models, and validation happens automatically without manual checks in every endpoint.

Terms used

  • Data annotations: attributes from System.ComponentModel.DataAnnotations for declarative validation.
  • ProblemDetails: RFC 7807 standard format for HTTP API error responses.
  • Interceptors: compile-time source generators that add validation logic without runtime reflection.
  • IValidatableObject: interface for custom validation logic with access to the entire object.

Reader contract

This article is for:

  • Engineers adding validation to Minimal API endpoints.
  • Teams migrating from manual validation or FluentValidation.

You will leave with:

  • Working AddValidation() configuration.
  • Custom validator patterns.
  • Error response customization.
  • Decision guidance for built-in vs FluentValidation.

This is not for:

  • MVC controller validation (different system, already built-in).
  • Client-side validation.
  • Blazor validation.

Quick start (10 minutes)

If you want automatic validation in Minimal APIs:

Verified on: ASP.NET Core (.NET 10).

1. Add the project file configuration:

<!-- In your .csproj file -->
<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

2. Register validation services:

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

3. Add validation attributes to your model:

public record CreateProductRequest(
    [Required] string Name,
    [StringLength(500)] string Description,
    [Range(0.01, 10000)] decimal Price);

4. Use the model in an endpoint:

app.MapPost("/products", (CreateProductRequest request) =>
{
    // Validation happens automatically before this code runs
    return TypedResults.Created($"/products/{Guid.NewGuid()}", request);
});

Invalid requests now return 400 Bad Request with error details automatically.

How AddValidation works

When you call AddValidation(), ASP.NET Core:

  1. Discovers types used in Minimal API handlers at compile time.
  2. Generates interceptors that add validation logic.
  3. Validates models during request binding.
  4. Returns ProblemDetails on validation failure.

This approach uses source generators instead of runtime reflection, improving startup performance.

Supported validation attributes

All attributes from System.ComponentModel.DataAnnotations work:

Attribute Purpose
[Required] Field must have a value
[StringLength] Maximum (and optional minimum) string length
[MinLength], [MaxLength] Collection or string length bounds
[Range] Numeric value bounds
[RegularExpression] Pattern matching
[EmailAddress] Email format validation
[Phone] Phone number format
[Url] URL format
[Compare] Cross-property comparison
[CreditCard] Credit card number format

Example with multiple attributes

public record RegisterUserRequest(
    [Required]
    [EmailAddress]
    string Email,

    [Required]
    [StringLength(100, MinimumLength = 8)]
    string Password,

    [Required]
    [Compare(nameof(Password))]
    string ConfirmPassword,

    [Phone]
    string? PhoneNumber);

Validation on different binding sources

Validation applies to all binding sources:

Request body

app.MapPost("/products", (CreateProductRequest request) => ...);

Query parameters

app.MapGet("/search", ([Required] string query, [Range(1, 100)] int limit = 10) => ...);

Route parameters

app.MapGet("/products/{id}", ([Range(1, int.MaxValue)] int id) => ...);

Headers

app.MapGet("/secure", ([Required][FromHeader(Name = "X-Api-Key")] string apiKey) => ...);

Record types

Records work identically to classes. Apply attributes in the primary constructor:

public record OrderRequest(
    [Required] string CustomerId,
    [Required] [MinLength(1)] List<OrderItem> Items,
    [Range(0, 1000)] decimal Discount);

public record OrderItem(
    [Required] string ProductId,
    [Range(1, 100)] int Quantity);

Nested types are validated recursively.

Custom validation attributes

Create custom attributes by inheriting from ValidationAttribute:

public class FutureDateAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is DateOnly date && date <= DateOnly.FromDateTime(DateTime.Today))
        {
            return new ValidationResult(
                ErrorMessage ?? "Date must be in the future.",
                [context.MemberName!]);
        }

        return ValidationResult.Success;
    }
}

Use it like any other attribute:

public record CreateEventRequest(
    [Required] string Title,
    [FutureDate] DateOnly EventDate);

IValidatableObject for complex validation

When validation depends on multiple properties, implement IValidatableObject:

public record DateRangeRequest(
    DateOnly StartDate,
    DateOnly EndDate) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (EndDate < StartDate)
        {
            yield return new ValidationResult(
                "End date must be after start date.",
                [nameof(EndDate)]);
        }

        if ((EndDate.ToDateTime(TimeOnly.MinValue) - StartDate.ToDateTime(TimeOnly.MinValue)).Days > 365)
        {
            yield return new ValidationResult(
                "Date range cannot exceed one year.",
                [nameof(StartDate), nameof(EndDate)]);
        }
    }
}

IValidatableObject runs after attribute validation passes.

Error response format

Failed validation returns a 400 Bad Request with ProblemDetails:

{
  "type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
  "title": "One or more validation errors occurred.",
  "status": 400,
  "errors": {
    "Name": ["The Name field is required."],
    "Price": ["The field Price must be between 0.01 and 10000."]
  }
}

This format follows RFC 7807 and is consistent with ASP.NET Core's standard error responses.

Customizing error responses

Use IProblemDetailsService to customize validation error responses:

builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Instance = context.HttpContext.Request.Path;
        context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
    };
});

For more control, implement a custom IProblemDetailsService:

public class CustomProblemDetailsService : IProblemDetailsService
{
    public ValueTask WriteAsync(ProblemDetailsContext context)
    {
        var problemDetails = context.ProblemDetails;

        // Customize error format
        problemDetails.Title = "Validation Failed";
        problemDetails.Extensions["timestamp"] = DateTime.UtcNow;

        context.HttpContext.Response.StatusCode = problemDetails.Status ?? 400;
        return new ValueTask(
            context.HttpContext.Response.WriteAsJsonAsync(problemDetails));
    }
}

builder.Services.AddSingleton<IProblemDetailsService, CustomProblemDetailsService>();

Disabling validation for specific endpoints

Use DisableValidation() when you intentionally accept incomplete data:

// Partial update endpoint - validation handled manually
app.MapPatch("/products/{id}", (int id, JsonPatchDocument<Product> patch) =>
{
    // Manual validation logic for patches
    return TypedResults.Ok();
}).DisableValidation();

// Internal endpoint with trusted input
app.MapPost("/internal/sync", (SyncRequest request) =>
{
    return TypedResults.Ok();
}).DisableValidation();

Nullable value types in forms

Empty form fields map to null for nullable types instead of causing parse failures:

public record UpdateProductRequest(
    string? Name,
    decimal? Price,      // Empty string becomes null
    DateOnly? ExpiryDate // Empty string becomes null
);

This prevents validation errors when optional fields are left empty in HTML forms.

Built-in validation vs FluentValidation

Criterion AddValidation() FluentValidation
Setup complexity Minimal (one line) NuGet + registration
Validation location Attributes on model Separate validator classes
Complex rules IValidatableObject Fluent syntax
Async validation Not supported Supported
Conditional rules Manual in IValidatableObject Built-in When/Unless
Testability Test the model Test the validator
Learning curve Familiar if using MVC New API to learn

When to use AddValidation()

  • Simple to moderate validation requirements.
  • Team familiar with data annotations.
  • Validation rules are straightforward.
  • You want minimal dependencies.

When to consider FluentValidation

  • Complex conditional validation logic.
  • Async validation (database checks).
  • Validation rules change frequently.
  • You prefer validation logic separate from models.

Copy/paste artifact: validation configuration

// Program.cs - Complete validation setup

var builder = WebApplication.CreateBuilder(args);

// Register validation services
builder.Services.AddValidation();

// Optional: customize error responses
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
    };
});

var app = builder.Build();

// Endpoint with automatic validation
app.MapPost("/products", (CreateProductRequest request) =>
{
    return TypedResults.Created($"/products/{Guid.NewGuid()}", request);
});

// Endpoint with validation disabled
app.MapPatch("/products/{id}", (int id, UpdateProductRequest request) =>
{
    return TypedResults.Ok();
}).DisableValidation();

app.Run();

// Models with validation
public record CreateProductRequest(
    [Required]
    [StringLength(200)]
    string Name,

    [StringLength(2000)]
    string? Description,

    [Required]
    [Range(0.01, 100000)]
    decimal Price);

public record UpdateProductRequest(
    string? Name,
    string? Description,
    decimal? Price);

Required .csproj addition:

<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

Common failure modes

  1. Missing InterceptorsNamespaces: validation silently does not run. Add the PropertyGroup to your .csproj file.

  2. Forgetting AddValidation(): models are not validated. Ensure the service is registered.

  3. Nullable reference warnings: use nullable types for optional fields to avoid false validation errors.

  4. Nested type validation not running: ensure nested types also have validation attributes; validation is recursive but only for annotated properties.

  5. Custom attribute not working: ensure your attribute inherits from ValidationAttribute and implements IsValid correctly.

Checklist

  • InterceptorsNamespaces added to .csproj.
  • AddValidation() called in Program.cs.
  • Models use data annotation attributes.
  • Nullable types used for optional fields.
  • Custom validators inherit from ValidationAttribute.
  • IValidatableObject used for cross-property rules.
  • DisableValidation() used intentionally where needed.

FAQ

Does validation work with dependency injection?

Custom validators via IValidatableObject can access services through ValidationContext.GetService(). Custom attributes do not have direct DI access.

Can I validate async (like checking database uniqueness)?

No. Built-in validation is synchronous. For async validation, use FluentValidation or validate manually in the endpoint.

What happens if validation and binding both fail?

Binding errors take precedence. If a value cannot be bound (wrong type), validation does not run for that property.

Is the validation filter order configurable?

The validation filter runs during model binding before your endpoint code. The order is not configurable.

Do I need to check ModelState in Minimal APIs?

No. Unlike MVC controllers, Minimal APIs with AddValidation() automatically return 400 on validation failure. Your endpoint code only runs if validation passes.

What to do next

If you have existing Minimal APIs with manual validation, try AddValidation() on a new endpoint. Compare the code reduction and error response consistency.

For more on Minimal APIs architecture decisions, read Minimal APIs vs Controllers: A Decision Framework.

If you want help implementing validation patterns in your application, reach out via Contact.

References

Author notes

Decisions:

  • Focus on built-in validation rather than FluentValidation tutorial. Rationale: built-in is the new default; FluentValidation is well-documented elsewhere.
  • Include IValidatableObject for complex scenarios. Rationale: fills the gap for cross-property validation without external dependencies.
  • Note async validation limitation. Rationale: important for teams evaluating whether built-in validation meets their needs.

Observations:

  • Teams often add FluentValidation first, then discover AddValidation() is sufficient for most cases.
  • Missing InterceptorsNamespaces is the most common setup issue.
  • IValidatableObject is underutilized; many teams do not know it works with Minimal APIs.