Data Validation in .NET 10 Minimal APIs – Built-In, Clean, and No Extra Libraries

Minimal APIs were introduced in .NET 6 with a clear goal — speed and simplicity. Less ceremony, fewer files, and endpoints that just work.

But there was always one gap that developers had to fill themselves: data validation.

Every time a request arrived, you either wrote manual checks inside the handler, wired up a third-party library, or accepted the risk of bad data slipping through. None of those options felt right for a framework that promised to stay out of your way.

.NET 10 finally closes that gap. Built-in validation for Minimal APIs is now part of the framework, and it works exactly the way you’d want — clean, automatic, and without any extra dependencies.

This article walks through everything: why this matters, how it works, and how to use it effectively whether you’re just getting started with Minimal APIs or building production-grade systems.

What Is Data Validation and Why It Matters

Before jumping into the feature itself, it’s worth being clear about what validation actually does.

When a client sends a request to an API — a form submission, a JSON payload, a query parameter — there is no guarantee that the data is correct. A required field might be missing. A number might be out of range. An email address might be malformed. Without validation, that bad data gets processed, stored, or used to make decisions.

The consequences range from minor bugs to serious security vulnerabilities. Good validation is the first line of defence in any API.

How Validation Worked Before .NET 10

Developers building Minimal APIs had three common approaches before .NET 10, and each one came with its own trade-offs.

Manual Validation Inside the Handler

The most straightforward approach was to check everything by hand:

C#
app.MapPost("/orders", (CreateOrderRequest request) =>
{
    if (string.IsNullOrWhiteSpace(request.ProductName))
    {
        return Results.BadRequest("Product name is required.");
    }

    if (request.Quantity <= 0)
    {
        return Results.BadRequest("Quantity must be greater than zero.");
    }

    if (string.IsNullOrWhiteSpace(request.CustomerEmail) ||
        !request.CustomerEmail.Contains("@"))
    {
        return Results.BadRequest("A valid email address is required.");
    }

    // actual business logic starts here...
    return Results.Ok(new { Message = "Order placed successfully." });
});

This works, but it has real problems. The validation logic lives inside the handler, mixing concerns that should be separate. Every endpoint repeats a similar pattern. And as the model grows, the handler becomes harder to read and maintain.

FluentValidation

For teams that needed more structure, FluentValidation was the go-to library:

C#
public sealed class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
    public CreateOrderValidator()
    {
        RuleFor(x => x.ProductName)
            .NotEmpty()
            .WithMessage("Product name is required.")
            .MaximumLength(100);

        RuleFor(x => x.Quantity)
            .GreaterThan(0)
            .WithMessage("Quantity must be greater than zero.");

        RuleFor(x => x.CustomerEmail)
            .NotEmpty()
            .EmailAddress()
            .WithMessage("A valid customer email is required.");
    }
}

FluentValidation is a well-designed library and it still has its place. But it requires an extra NuGet package, DI registration, and a filter or middleware to hook into the pipeline. For simpler APIs, that is a lot of overhead.

Custom Endpoint Filters

Some developers went further and wrote custom IEndpointFilter implementations to handle validation in a reusable way. This worked, but it required boilerplate that varied between teams and was difficult to standardize.

What Changed in .NET 10

Starting with .NET 10, Minimal APIs have first-class, built-in validation support using the same System.ComponentModel.DataAnnotations attributes that MVC controllers have always supported.

Two things happen when you enable it:

  1. A source generator runs at compile time and emits the validation logic for your endpoint models. This means there is no runtime reflection overhead — the validation code is generated once at build time.
  2. The validation pipeline runs before your handler executes. If a request fails validation, the endpoint handler never runs. The framework returns a 400 Bad Request with a structured error response automatically.
Project Setup

Only one line is needed in Program.cs to activate the entire validation pipeline:

C#
builder.Services.AddValidation();

That is it. The framework registers everything required and automatically validates all compatible endpoints.

Validating Request Bodies with Data Annotations

This is the most common use case — validating a JSON request body mapped to a model class or record.

Decorating the Model

Using an order placement scenario as an example:

C#
using System.ComponentModel.DataAnnotations;

public sealed record CreateOrderRequest(
    [Required(ErrorMessage = "Product name is required.")]
    [StringLength(100, MinimumLength = 2,
        ErrorMessage = "Product name must be between 2 and 100 characters.")]
    string ProductName,

    [Range(1, 500, ErrorMessage = "Quantity must be between 1 and 500.")]
    int Quantity,

    [Required(ErrorMessage = "Customer email is required.")]
    [EmailAddress(ErrorMessage = "Please provide a valid email address.")]
    string CustomerEmail,

    [Phone(ErrorMessage = "Please provide a valid phone number.")]
    string? PhoneNumber
);

Notice that PhoneNumber is nullable (string?). Nullable types are treated as optional — the [Phone] attribute only validates the format if a value is actually provided. This is the correct way to model optional fields.

Registering the Endpoint
C#
app.MapPost("/orders", (CreateOrderRequest request) =>
{
    // Reaching this point means the request passed validation 
    // No manual checks needed. Focus entirely on business logic.
    return Results.Created($"/orders/{Guid.NewGuid()}", new
    {
        Message = "Order placed successfully.",
        ProductName = request.ProductName,
        Quantity = request.Quantity
    });
});

The handler stays completely focused on what it is supposed to do — process the order. Validation is handled before it even gets here.

What the Error Response Looks Like

When validation fails, the framework automatically responds with HTTP 400 Bad Request and a structured ProblemDetails payload containing validation errors.

JSON
{
  "title": "One or more validation errors occurred.",  
  "errors": {
    "ProductName": [
      "Product name is required."
    ],
    "Quantity": [
      "Quantity must be between 1 and 500."
    ],
    "CustomerEmail": [
      "Please provide a valid email address."
    ]
  }
}

This is a consistent, machine-readable format that clients can reliably parse. No custom error shaping needed for the common case.

Validating Route Parameters and Query Strings

Validation is not limited to request bodies. It works just as well on route parameters and query strings.

Route Parameters
C#
app.MapGet("/orders/{id}", ([Range(1, int.MaxValue,
    ErrorMessage = "Order ID must be a positive number.")] int id) =>
{
    return Results.Ok($"Fetching order {id}");
});

If a request comes in for /orders/0 or /orders/-5, the handler never runs. The framework catches it before any business logic executes.

Query String Parameters
C#
app.MapGet("/products", (
    [StringLength(50, ErrorMessage = "Search term cannot exceed 50 characters.")] string? search,
    [Range(1, 100, ErrorMessage = "Page size must be between 1 and 100.")] int pageSize = 20) =>
{
    return Results.Ok(new { Search = search, PageSize = pageSize });
});

Here search is optional (nullable), so it is only validated if provided. pageSize has a default value of 20 and will be validated if the client passes a value.

Creating a Custom Validation Attribute

The built-in attributes cover most common scenarios, but real-world applications often have domain-specific rules. Custom validation attributes make those rules reusable and declarative.

As an example — validating that a discount code follows a specific format (three uppercase letters, a dash, four digits):

C#
public sealed class ValidDiscountCodeAttribute : ValidationAttribute
{
    private static readonly System.Text.RegularExpressions.Regex _pattern =
        new(@"^[A-Z]{3}-\d{4}$", System.Text.RegularExpressions.RegexOptions.Compiled);

    protected override ValidationResult? IsValid(
        object? value, ValidationContext validationContext)
    {
        // Null is allowed — use [Required] separately if the field is mandatory
        if (value is null)
        {
            return ValidationResult.Success;
        }

        if (value is string code && _pattern.IsMatch(code))
        {
            return ValidationResult.Success;
        }

        return new ValidationResult(
            "Discount code must follow the format: ABC-1234",
            new[] { validationContext.MemberName! });
    }
}

Apply it to the model:

C#
public sealed record ApplyDiscountRequest(
    [Required]
    [ValidDiscountCode]
    string DiscountCode,

    [Range(0.01, 100000, ErrorMessage = "Order total must be greater than zero.")]
    decimal OrderTotal
);

And use it in an endpoint:

C#
app.MapPost("/discounts/apply", (ApplyDiscountRequest request) =>
{
    return Results.Ok(new
    {
        Message = $"Discount {request.DiscountCode} applied to order total {request.OrderTotal:C}"
    });
});

This keeps domain rules centralized and reusable across multiple endpoints or models.

Cross-Property Validation with IValidatableObject

Sometimes a field’s validity depends on another field’s value. That kind of rule cannot be expressed with a single attribute — it requires access to the whole object.

IValidatableObject solves this cleanly. Here is a hotel booking scenario where the check-out date must come after check-in, and bookings cannot exceed 30 nights:

C#
using System.ComponentModel.DataAnnotations;

public sealed class BookingRequest : IValidatableObject
{
    [Required(ErrorMessage = "Check-in date is required.")]
    public DateTime CheckIn { get; set; }

    [Required(ErrorMessage = "Check-out date is required.")]
    public DateTime CheckOut { get; set; }

    [Range(1, 10, ErrorMessage = "Guest count must be between 1 and 10.")]
    public int Guests { get; set; }

    [StringLength(200)]
    public string? SpecialRequests { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        if (CheckOut <= CheckIn)
        {
            yield return new ValidationResult(
                "Check-out date must be after check-in date.",
                new[] { nameof(CheckOut) });
        }

        if ((CheckOut - CheckIn).TotalDays > 30)
        {
            yield return new ValidationResult(
                "Bookings cannot exceed 30 nights.",
                new[] { nameof(CheckOut) });
        }

        if (CheckIn < DateTime.UtcNow.Date)
        {
            yield return new ValidationResult(
                "Check-in date cannot be in the past.",
                new[] { nameof(CheckIn) });
        }
    }
}

The endpoint stays simple:

C#
app.MapPost("/bookings", (BookingRequest request) =>
{
    return Results.Created("/bookings/new", new
    {
        Message = "Booking confirmed.",
        Nights = (request.CheckOut - request.CheckIn).TotalDays,
        request.Guests
    });
});

When to use which approach:

  • A rule applies to one field independently – use an attribute ([Required], [Range], custom attribute)
  • A rule involves two or more fields – use IValidatableObject

Disabling Validation for Specific Endpoints

There are legitimate cases where you want to opt out of automatic validation for a particular endpoint. The .DisableValidation() method handles this:

C#
// Webhook endpoints often verify the payload using a signature,
// not by validating a schema. Automatic validation would reject
// valid payloads that don't match a model structure.
app.MapPost("/webhooks/stripe", async (HttpContext context) =>
{
    var payload = await new StreamReader(context.Request.Body).ReadToEndAsync();
    var signature = context.Request.Headers["Stripe-Signature"];

    // Verify signature and process raw payload
    return Results.Ok();
})
.DisableValidation();

Other good candidates for .DisableValidation() include health check endpoints, internal diagnostic endpoints, or any endpoint where the payload format is controlled by an external system rather than your own models.

Data Annotations vs FluentValidation — Which Should You Use?

.NET 10’s built-in validation does not eliminate the need for FluentValidation in every situation. The right tool depends on what the project actually needs.

ScenarioBest Approach
Simple field-level rules ([Required], [Range], [EmailAddress])✅ Data Annotations
Cross-property validationIValidatableObject or FluentValidation
Async validation (e.g. check if email already exists in database)✅ FluentValidation
Validation rules that change based on runtime conditions✅ FluentValidation
Keeping validation logic separate from the model entirely✅ FluentValidation
Minimal dependencies, straightforward rules✅ Data Annotations

For the majority of CRUD endpoints and simpler APIs, Data Annotations will cover everything needed. For domain-heavy systems with complex, conditional, or async validation rules, FluentValidation remains the stronger choice.

They can also coexist. Data Annotations for the simple stuff, FluentValidation registered alongside for the complex cases.

Summary

Data validation in Minimal APIs was one of the most frequently requested improvements in ASP.NET Core, and .NET 10 delivers it well.

The feature is opt-in, automatic once registered, and built on the same System.ComponentModel.DataAnnotations system that .NET developers have used for years. Request bodies, route parameters, and query strings are all validated before the handler runs. Invalid requests are rejected with a consistent ProblemDetails response. Handlers stay clean and focused on business logic.

For teams already using Minimal APIs, this removes the last major reason to reach for workarounds. For teams considering Minimal APIs for new projects, the validation gap is now closed

Takeaways

  • Call builder.Services.AddValidation() in Program.cs to enable built-in validation
  • Add InterceptorsNamespaces to the .csproj to activate the source generator
  • Decorate model properties with attributes like [Required], [Range], [EmailAddress], and [StringLength]
  • Validation runs before the endpoint handler — invalid requests never reach business logic
  • Use nullable types (string?) for optional fields
  • Create custom attributes by inheriting from ValidationAttribute for domain-specific rules
  • Use IValidatableObject for cross-property validation logic
  • Use .DisableValidation() on endpoints that should opt out (webhooks, raw payload handlers)
  • Data Annotations cover most use cases — FluentValidation is still the better fit for complex or async rules

Found this article useful? Share it with your network and spark a conversation.