- Published on
How to Validate Incoming Requests in .NET 10 Minimal APIs
- Authors
- Name
- Ivan Gechev
Before .NET 10, Minimal APIs lacked a first-class, built-in request validation pipeline comparable to MVC, forcing developers to rely on filters, manual checks, or third-party libraries. And then, if we wanted nice and tidy endpoints, we had to create custom validation filters and so on.
.NET 10 changes this completely by introducing first-class, production-ready, validation support for Minimal APIs. Now, validation happens automatically before our endpoint handlers even execute. If a request doesn't meet our validation rules, the framework returns a standardized 400 Bad Request response with detailed error information. Our handlers stay clean and focused on business logic.
In this article, we'll explore how to leverage this new validation feature. We'll start simple with route and query parameters, move on to request body validation, and then dive into advanced scenarios with custom validation logic. Along the way, we'll use a cat-themed API to demonstrate each concept.
Let's dive in!
Why Minimal API Validation Matters in .NET 10
Validation is one of the things that are paramount when building APIs. Every endpoint that accepts data needs to verify that the data is correct before processing it. Without proper validation, we risk corrupting our database, exposing security vulnerabilities, or returning cryptic error messages to our users.
Before .NET 10, we had a few options for handling validation in Minimal APIs, none of them ideal:
- Write manual validation logic inside each endpoint handler with a bunch of
ifstatements - Use third-party libraries like
FluentValidationand wire them up manually - Create custom endpoint filters to centralize validation (as I covered in my Building Endpoint Validation Filters in ASP.NET Core Minimal APIs post)
All of these approaches work, but they come with trade-offs. Manual validation creates bloated handlers. Third-party libraries add dependencies. Custom filters require extra setup and boilerplate code.
.NET 10's built-in validation support helps us solve those problems by integrating validation directly into the request pipeline. When we decorate our models with data annotations from the System.ComponentModel.DataAnnotations namespace, the framework automatically validates incoming requests and returns consistent error responses.
Here's what makes this approach valuable:
- Clean handlers: Validation logic lives with the model, not scattered across endpoints
- Consistent errors: All validation failures return the same
ProblemDetailsformat - Early rejection: Invalid requests never reach our business logic
- No extra dependencies: Uses the same data annotations we're already familiar with
This can be very valuable in production APIs as we need predictable behavior and consistent error handling.
Validating Route and Query Parameters
Let's start with the simplest validation scenario: route and query parameters. These are the values we extract from the URL itself, like /api/cats/{id} or /api/cats?pageSize=20.
We'll build a cat-themed API with a few endpoints to demonstrate how validation works in practice. We'll keep things very simple and focus on validation alone and nothing else.
We need a .NET 10 project with the addition of a single line in our Program class:
builder.Services.AddValidation();
We call the new AddValidation() method. It will do some magic behind the scenes and generate validators for the types used in our endpoints based on the data annotations we've used.
Route Parameter Validation
Route parameters are the values we define in our URL. For example, in /api/cats/{id}, the id is our route parameter. We can validate these parameters directly in the endpoint signature using data annotation attributes:
var catRoutes = app.MapGroup("api/cats");
catRoutes.MapGet("{id:int}", (
[Range(1, 1000)] int id) =>
{
return TypedResults.Ok($"Here's your cat with ID: {id}");
});
Here, we use the [Range] attribute to ensure that the id parameter is between 1 and 1000.
Let's send a GET request with an invalid id:
curl --location 'https://localhost:7298/api/cats/1234'
When we do that, our API grants us with a nice error message:
{
"title": "One or more validation errors occurred.",
"errors": {
"id": [
"The field id must be between 1 and 1000."
]
}
}
Notice how our endpoint handler never executed? The validation happens before the request even reaches our business logic. This is a key benefit of the built-in validation — invalid requests are rejected early in the pipeline and with very little effort on our end.
Query Parameter Validation
Query parameters work the same way. These are the values that come after the ? in a URL, like /api/cats?pageSize=20&page=1. We can validate them using the same data annotation attributes:
catRoutes.MapGet("", (
[FromQuery][Range(1, 100)] int pageSize = 20,
[FromQuery][Range(1, int.MaxValue)] int page = 1) =>
{
return TypedResults.Ok(new
{
PageSize = pageSize,
Page = page,
Message = $"Fetching page {page} with {pageSize} cats per page"
});
});
Here, we're validating pagination parameters. The pageSize must be between 1 and 100, and the page must be at least 1. We also use default values (pageSize = 20 and page = 1) so these parameters become optional.
If someone requests /api/cats?pageSize=150&page=0, they'll get validation errors for both parameters:
{
"title": "One or more validation errors occurred.",
"errors": {
"pageSize": [
"The field pageSize must be between 1 and 100."
],
"page": [
"The field page must be between 1 and 2147483647."
]
}
}
The beauty of this approach is that our endpoint handler stays focused on the actual business logic and we no longer have to clutter it with if (pageSize < 1 || pageSize > 100) checks. The framework handles all of this for us.
If you are interested in having a deep dive into Minimal APIs, you can check my Minimal APIs in ASP.NET Core course here.
And this is just the tip of the iceberg. Next, we'll look at validating request bodies, which is where the real power of the new validation support really shines.
Validating Request Bodies with Data Annotations
Now, when we're dealing with complex objects sent as JSON in the request bodies to our APIs, we can define all our validation rules directly on the model using data annotations. This keeps our validation logic close to the data structure it validates and is where this new approach really shines.
Let's create a simple record for creating a cat in our system:
public record CreateCatRequest(
[Required, Length(2, 25)] string Name,
[Required, Length(2, 20)] string Breed,
[Required] int AgeInMonths);
Here, we're using the [Required] and [Length(min, max)] attributes to decorate our properties.
Now, let's create a POST endpoint that accepts this request object:
catRoutes.MapPost("", async (CreateCatRequest request) =>
{
return Results.Created($"/cats/{Random.Shared.Next(1, 999)}", request);
});
You can ignore the dummy response we return and just admire how clean our endpoint handler is. There's no validation logic whatsoever, no filters, no extra clutter.
Let's test this with a valid request:
curl --location 'https://localhost:7298/api/cats' \
--header 'Content-Type: application/json' \
--data '{
"name": "Cody",
"breed": "A very orange breed",
"ageInMonths": 40
}'
This request passes validation and we get a 201 Created response:
{
"name": "Cody",
"breed": "A very orange breed",
"ageInMonths": 40
}
Now, let's try sending a request with several invalid properties:
curl --location 'https://localhost:7298/api/cats' \
--header 'Content-Type: application/json' \
--data '{
"name": "",
"breed": "This is an extremely long and obscure cat breed",
"AgeInMonths": 40
}'
The validation catches all three problems and returns a detailed error response:
{
"title": "One or more validation errors occurred.",
"errors": {
"Name": [
"The Name field is required."
],
"Breed": [
"The field Breed must be a string or collection type with a minimum length of '2' and maximum length of '20'."
]
}
}
Each property that failed validation gets its own array of error messages. This makes it incredibly easy for frontend applications to display field-specific errors right next to the corresponding input fields.
Common Data Annotation Attributes
The System.ComponentModel.DataAnnotations namespace provides a rich set of validation attributes we can use. Here are the most common ones:
| Attribute | Purpose | Example |
|---|---|---|
[Required] | Ensures the field is not null or empty | [Required] string Name |
[Length(min, max)] | Validates string length within a range | [Length(2, 50)] string Name |
[MinLength(length)] | Validates minimum string length | [MinLength(3)] string Name |
[MaxLength(length)] | Validates maximum string length | [MaxLength(100)] string Name |
[Range(min, max)] | Validates numeric values within a range | [Range(1, 120)] int Age |
[EmailAddress] | Validates email format | [EmailAddress] string Email |
These attributes cover most common validation scenarios. For simple CRUD operations and request DTOs, data annotations are usually more than sufficient. But what happens when we need more complex validation logic that involves multiple properties or domain-specific rules?
That's where custom validation comes in, and we'll explore that next.
Advanced Validation Scenarios
Data annotations work great for simple field-level validation, but sometimes we need more complex rules. For example, what if we want to validate that a cat's age falls within a reasonable range? We could use [Range(1, 240)], but that puts a hard-coded business rule in an attribute. What if this rule needs to change based on the cat's breed or other factors?
This is where custom validation comes in.
Custom Validation with IValidatableObject
The IValidatableObject interface allows us to implement custom validation logic that goes beyond what data annotations can express. This is useful for:
- Multi-property validation (e.g., end date must be after start date)
- Domain-specific business rules (e.g., cat age limits)
- Conditional validation (e.g., field X is required only if field Y has a certain value)
- Complex validation that requires external dependencies
Let's update our CreateCatRequest to implement IValidatableObject:
public record CreateCatRequest : IValidatableObject
{
[Required, Length(2, 25)]
public required string Name { get; init; }
[Required, Length(2, 20, ErrorMessage = $"{nameof(Breed)} must be between 2 and 20 characters")]
public required string Breed { get; init; }
[Required]
public required int AgeInMonths { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (AgeInMonths < 1 || AgeInMonths > 240)
{
yield return new ValidationResult(
$"{nameof(AgeInMonths)} must be between 1 and 240, you provided: {AgeInMonths}",
[nameof(AgeInMonths)]);
}
}
}
We start by implementing the IValidatableObject interface, which requires us to add the Validate() method to our object.
This method receives a ValidationContext that provides access to the object being validated and the service provider.
We can also use the ValidationResult's second constructor parameter (an array of property names) to specify which properties the validation error relates to. This is useful when our custom validation rule spans multiple properties.
An important detail: the Validate() method runs after all data annotation validations pass. If any [Required], [Length], or other attribute validations fail, our custom validation won't execute.
Note that we can customize error messages in two ways. For data annotations, we can use the ErrorMessage parameter to provide a more user-friendly message. In our custom validation logic, we can construct dynamic messages that include the actual invalid value, making it easier for users to understand what went wrong.
Let's test this with an invalid age:
curl --location 'https://localhost:7298/api/cats' \
--header 'Content-Type: application/json' \
--data '{
"name": "Cody",
"breed": "A very orange breed",
"ageInMonths": 241
}'
We get a validation error from our custom logic:
{
"title": "One or more validation errors occurred.",
"errors": {
"AgeInMonths": [
"AgeInMonths must be between 1 and 240, you provided: 241"
]
}
}
The error format is identical to the data annotation errors we explored earlier. This consistency is one of the best parts of .NET 10's validation support.
Disabling Validation for Specific Endpoints
Sometimes we might have to skip validation for certain endpoints. This might seem counterintuitive, but there can be some legitimate use cases.
Let's see how we can achieve that:
catRoutes.MapPut("{id:int}/admin", async (
int id,
CreateCatRequest request) =>
{
// Admin endpoint - allows updating cats with relaxed validation
return TypedResults.Ok(new
{
Id = id,
Message = "Cat updated via admin endpoint (validation disabled)",
Cat = request
});
})
.DisableValidation();
We disable validation by using the DisableValidation() extension method. Simple as that.
Now, even if we send data that would normally fail validation, the endpoint accepts it:
curl --location --request PUT 'https://localhost:7298/api/cats/69/admin' \
--header 'Content-Type: application/json' \
--data '{
"name": "X",
"breed": "",
"ageInMonths": 500
}'
This request succeeds despite violating all of our validation rules:
{
"id": 69,
"message": "Cat updated via admin endpoint (validation disabled)",
"cat": {
"name": "X",
"breed": "",
"ageInMonths": 500
}
}
Important: You should use .DisableValidation() sparingly and with caution. In most cases, if you're disabling validation, you should ask yourself whether you need different validation rules rather than no validation at all. Consider creating a separate request model with relaxed constraints instead of disabling validation entirely.
For production APIs, I recommend:
- Document clearly that validation is disabled
- Add additional authorization checks to protect these endpoints
- Consider logging all uses of validation-disabled endpoints for audit purposes
Customizing Error Responses With the IProblemDetailsService Integration
By default, .NET 10's validation support returns a 400 Bad Request status code when validation fails. While this works, it's not semantically the most accurate choice. According to RFC specifications, 400 Bad Request indicates that the server cannot process the request due to malformed request syntax, invalid request message framing, or deceptive request routing.
Validation errors, on the other hand, mean the request is well-formed but contains data that doesn't meet our particular business rules.
This is where 422 Unprocessable Entity comes in. RFC 4918 defines it specifically for cases where the server understands the content type of the request entity, and the syntax of the request entity is correct, but was unable to process the contained instructions. This is exactly what validation errors represent—the JSON is valid, but the data inside doesn't meet our requirements.
I don't like using 400 Bad Request when there are validation errors, so I stick to 422 Unprocessable Entity. This is why I customize the problem details service:
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
{
if (context.ProblemDetails.Status == (int)HttpStatusCode.BadRequest &&
context.ProblemDetails is HttpValidationProblemDetails validationProblem)
{
context.ProblemDetails.Status = StatusCodes.Status422UnprocessableEntity;
context.ProblemDetails.Type = "https://tools.ietf.org/html/rfc4918#section-11.2";
}
};
});
In our Program class, we register a custom problem details handler using the AddProblemDetails() method. Inside the CustomizeProblemDetails callback, we check if the response is 400 Bad Request and use pattern matching to verify that the ProblemDetails object is of HttpValidationProblemDetails type. This is what indicates that we have at least one validation error. If both conditions are true, we change the status code to 422 Unprocessable Entity and update the type field to reference the appropriate RFC specification.
Let's send another request to our API:
curl --location 'https://localhost:7298/api/cats' \
--header 'Content-Type: application/json' \
--data '{
"name": "",
"breed": "This is an extremely long and obscure cat breed",
"ageInMonths": 241
}'
Now, when validation fails, our API returns:
{
"type": "https://tools.ietf.org/html/rfc4918#section-11.2",
"title": "One or more validation errors occurred.",
"status": 422,
"errors": {
"Name": [
"The Name field is required."
],
"Breed": [
"Breed must be between 2 and 20 characters"
]
},
"traceId": "00-0b2fb9125fc5a6ce1cef4f1cb683e1cb-b8bc575bda84c880-00"
}
Do you notice something wrong? Even though we send 241 as the value for ageInMonths, we don't see that error in the response. As mentioned earlier, the Validate() method only runs after all data annotation validation have passed. Because we have two properties with failed attribute validation, we don't see our custom validation error.
Let's send another request:
curl --location 'https://localhost:7298/api/cats' \
--header 'Content-Type: application/json' \
--data '{
"name": "Cody",
"breed": "An orange cat",
"ageInMonths": 241
}'
And inspect the response:
{
"type": "https://tools.ietf.org/html/rfc4918#section-11.2",
"title": "One or more validation errors occurred.",
"status": 422,
"errors": {
"AgeInMonths": [
"AgeInMonths must be between 1 and 240, you provided: 241"
]
},
"traceId": "00-faaf7ee2c8d83e33a331699e9b15b32a-6c83b8e0d4f14f90-00"
}
The IProblemDetailsService gives us complete control over error responses. Beyond just changing status codes, we can add custom fields, include custom trace identifiers for debugging, or integrate with logging and monitoring systems.
Conclusion
.NET 10's first-class validation support for Minimal APIs eliminates one of the biggest pain points we've had so far. We no longer need to choose between bloated endpoint handlers, external dependencies, or custom boilerplate filters just to validate incoming requests.
By leveraging data annotations for field-level rules and IValidatableObject for custom logic, we can cover the vast majority of validation scenarios in any typical API. Our endpoint handlers can stay clean and focused on business logic, validation errors return in a consistent format, and invalid requests never reach our application code.