Remigiusz ZalewskiRemigiusz Zalewski

Minimal API Validation in .NET 10 — Built-In Support with Data Annotations

minimal-api-validation

Introduction

If you've ever built a Minimal API in .NET, you probably know the pain — there was no built-in validation out of the box. You had to rely on third-party libraries like FluentValidation or write manual checks inside your endpoint handlers.

That changes with .NET 10. Microsoft has introduced native validation support for Minimal APIs using Data Annotations — the same [Required], [MaxLength], and custom validation attributes you already know from MVC controllers.

In this article, I'll walk you through exactly how it works using a practical Library API example.


The Problem Before .NET 10

In earlier versions of .NET, Minimal APIs had no automatic model validation. If you defined a POST endpoint like this:

app.MapPost("/books", (CreateBookRequest request) =>
{
    // No validation happens automatically!
    // You had to manually check or use FluentValidation
});

The framework would happily accept any payload — even an empty one — and let your handler deal with it. This meant:

  • ❌ No automatic 400 Bad Request responses for invalid data
  • ❌ No support for [Required], [MaxLength], etc.
  • ❌ You had to install and configure external validation libraries

What's New in .NET 10

.NET 10 adds a single line that changes everything:

builder.Services.AddValidation();

That's it. By calling AddValidation() on your service collection, the framework will automatically validate any request model decorated with Data Annotation attributes before your endpoint handler is executed.

If validation fails, the API returns a 400 Bad Request with detailed error messages — no manual intervention required.


Full Example — Building a Library API

Let's build a simple Library API that validates book creation requests. Here's the complete project structure:

1. Project Setup

First, make sure you're targeting .NET 10:

<Project Sdk="Microsoft.NET.Sdk.Web">

  <PropertyGroup>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
    <PackageReference Include="Scalar.AspNetCore" Version="2.12.30" />
  </ItemGroup>

</Project>

2. Define the Request Model with Validation

Here's where the magic happens. Define a CreateBookRequest record with Data Annotation attributes:

using System.ComponentModel.DataAnnotations;

namespace LibraryApi;

public record CreateBookRequest(
    [Required]
    [MinLength(2)]
    [MaxLength(200)]
    string Title,
    [Required]
    string Author,
    string? Isbn,
    [ValidPublishedYear]
    int? PublishedYear
);

Notice a few things:

  • Title is required and must be between 2 and 200 characters.
  • Author is required.
  • Isbn is optional (nullable).
  • PublishedYear uses a custom validation attribute — we'll create that next.

3. Create a Custom Validation Attribute

For scenarios where built-in attributes aren't enough, you can create your own. Here's a custom [ValidPublishedYear] attribute that ensures the year is between 1450 (roughly when the printing press was invented) and the current year:

using System.ComponentModel.DataAnnotations;

namespace LibraryApi;

[AttributeUsage(AttributeTargets.Parameter)]
public sealed class ValidPublishedYearAttribute : ValidationAttribute
{
    private const int MinYear = 1450;

    protected override ValidationResult? IsValid(object? value,
        ValidationContext validationContext)
    {
        if (value is null)
            return ValidationResult.Success;

        if (value is not int year)
            return new ValidationResult("Published year must be a number.");

        var currentYear = DateTime.UtcNow.Year;

        if (year < MinYear || year > currentYear)
        {
            return new ValidationResult(
                $"Published year must be between {MinYear} and {currentYear}.");
        }

        return ValidationResult.Success;
    }
}

Key implementation details:

  • null is allowed — the field is optional, so null passes validation.
  • The attribute validates the year is within a sensible range.
  • It inherits from ValidationAttribute and overrides IsValid, just like any custom validation attribute in MVC.
  • Note the [AttributeUsage(AttributeTargets.Parameter)] — this is important because record constructor parameters are parameters, not properties.

4. Define the Response DTO

A simple record for the created book response:

namespace LibraryApi;

public record BookDto(
    Guid Id,
    string Title,
    string Author,
    string? Isbn,
    int? PublishedYear
);

5. Wire It All Up in Program.cs

Here's the complete Program.cs:

using LibraryApi;
using Scalar.AspNetCore;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenApi();
builder.Services.AddValidation();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
    app.MapScalarApiReference();
}

app.UseHttpsRedirection();

app.MapPost("/books", (CreateBookRequest request) =>
{
    var createdBook = new BookDto(
        Guid.NewGuid(),
        request.Title,
        request.Author,
        request.Isbn,
        request.PublishedYear
    );

    return Results.Created($"/books/{createdBook.Id}", createdBook);
})
.WithName("CreateBook");

app.Run();

The only new thing compared to a "classic" Minimal API setup is builder.Services.AddValidation(). Everything else is standard.


How It Works Under the Hood

When you call AddValidation(), .NET 10 registers a validation filter in the Minimal API pipeline. Here's the flow:

  1. Request comes in → the framework deserializes the JSON body into your record.
  2. Validation filter runs → it inspects all Data Annotation attributes on the model.
  3. If valid → your endpoint handler executes normally.
  4. If invalid → the framework short-circuits and returns a 400 Bad Request with a Problem Details response containing all validation errors.

You never have to write if (!ModelState.IsValid) or manually call a validator.


Testing the Validation

✅ Valid Request

POST /books
{
    "title": "Clean Code",
    "author": "Robert C. Martin",
    "isbn": "978-0132350884",
    "publishedYear": 2008
}

Response: 201 Created

{
  "id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
  "title": "Clean Code",
  "author": "Robert C. Martin",
  "isbn": "978-0132350884",
  "publishedYear": 2008
}

❌ Invalid Request — Missing Required Fields

POST /books
{
    "isbn": "978-0132350884"
}

Response: 400 Bad Request with validation errors for title and author.

❌ Invalid Request — Title Too Short

POST /books
{
    "title": "A",
    "author": "John Doe"
}

Response: 400 Bad Request — title must be at least 2 characters.

❌ Invalid Request — Future Published Year

POST /books
{
    "title": "Future Book",
    "author": "Time Traveler",
    "publishedYear": 2999
}

Response: 400 Bad Request — published year must be between 1450 and the current year.


Built-in vs. Custom Validation Attributes

Here's a quick reference of what you can use:

AttributePurposeExample
[Required]Field must be provided[Required] string Title
[MinLength(n)]Minimum string length[MinLength(2)] string Title
[MaxLength(n)]Maximum string length[MaxLength(200)] string Title
[Range(min, max)]Numeric range[Range(1, 100)] int Quantity
[EmailAddress]Valid email format[EmailAddress] string Email
[RegularExpression]Matches a regex pattern[RegularExpression(@"\d+")]
[StringLength(max)]Max string length (with optional min)[StringLength(100)]
Custom (ValidationAttribute)Any custom logic[ValidPublishedYear]

Key Takeaways

  • 🎯 .NET 10 adds built-in validation for Minimal APIs — just call builder.Services.AddValidation().
  • 🏷️ Use standard Data Annotation attributes ([Required], [MaxLength], etc.) on your request models.
  • 🛠️ Create custom validation attributes by extending ValidationAttribute for complex business rules.
  • 🔄 Validation runs automatically before your endpoint handler — no manual checks needed.
  • 📦 No third-party libraries required — it's all built into the framework.
  • ⚡ The framework returns a 400 Bad Request with structured error details when validation fails.

This is a huge quality-of-life improvement for anyone building Minimal APIs in .NET. What used to require FluentValidation or manual if checks now works out of the box with a single line of configuration.