Model Validation in ASP.NET Core: Data Annotations and FluentValidation
ASP.NET Core model validation is the first line of defense against bad input data. Before your action method runs, the framework examines incoming request bodies, query strings, and route values, checking them against rules you define. Bad data that passes validation is infinitely harder to deal with than bad data caught at the boundary. Getting asp.net core model validation right means your domain logic never has to handle null names, negative quantities, or email addresses that are actually just the word "email."
This guide covers everything from built-in data annotations to custom attributes, cross-property validation, and the expressive power of FluentValidation (third-party, not part of the framework) -- all targeting .NET 10.
Data Annotations Overview
Data annotations live in the System.ComponentModel.DataAnnotations namespace and have been part of .NET since the early Entity Framework days. They are attributes you apply directly to model properties to declare validation rules. Simple, declarative, and zero friction for common cases.
The most frequently used annotations cover the common validation needs. [Required] marks a field as non-optional -- if the value is missing or null, validation fails. [StringLength(max, MinimumLength = min)] constrains string length. [Range(min, max)] works for numeric types and dates. [EmailAddress] validates email format. [Url] validates URL format. [RegularExpression(pattern)] gives you full regex control. [MinLength] and [MaxLength] apply to collections and strings. [Compare("OtherProperty")] is useful for password confirmation fields.
Here is a realistic request DTO showing several annotations working together:
public sealed record CreateUserRequest
{
[Required(ErrorMessage = "Username is required")]
[StringLength(50, MinimumLength = 3, ErrorMessage = "Username must be 3-50 characters")]
[RegularExpression(@"^[a-zA-Z0-9_]+$", ErrorMessage = "Username may only contain letters, numbers, and underscores")]
public string Username { get; init; } = string.Empty;
[Required(ErrorMessage = "Email is required")]
[EmailAddress(ErrorMessage = "A valid email address is required")]
[StringLength(256)]
public string Email { get; init; } = string.Empty;
[Required]
[StringLength(100, MinimumLength = 8, ErrorMessage = "Password must be at least 8 characters")]
public string Password { get; init; } = string.Empty;
[Required]
[Compare(nameof(Password), ErrorMessage = "Passwords do not match")]
public string ConfirmPassword { get; init; } = string.Empty;
[Range(13, 120, ErrorMessage = "Age must be between 13 and 120")]
public int? Age { get; init; }
[Url(ErrorMessage = "Profile URL must be a valid URL")]
[StringLength(500)]
public string? ProfileUrl { get; init; }
}
The annotations are self-documenting. Any developer reading this model immediately understands the constraints without reading documentation or hunting for validation logic elsewhere. The downside is that annotations are tightly coupled to the model class, which matters when models are shared across layers.
How ModelState Works
ModelState is ASP.NET Core's in-memory dictionary of validation results for the current request. After model binding completes and before your action executes, the framework runs all validation rules -- annotations, IValidatableObject, and any registered validators -- and populates ModelState with the results.
ModelState.IsValid returns true if all bound properties passed validation. ModelState is a ModelStateDictionary where each key is a property name and each value contains the bound value and any errors. Without [ApiController], you check it manually:
[HttpPost]
public IActionResult CreateUser([FromBody] CreateUserRequest request)
{
if (!ModelState.IsValid)
{
// Collect errors as a dictionary for a structured response
var errors = ModelState
.Where(kvp => kvp.Value?.Errors.Count > 0)
.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value!.Errors
.Select(e => e.ErrorMessage)
.ToArray()
);
return BadRequest(new { errors });
}
// Safe to proceed -- validation passed
return Ok();
}
This manual check is verbose and error-prone. You might forget it. Someone might remove it during a refactor. One common approach -- and the reason [ApiController] exists -- is to make it automatic.
Automatic 400 with [ApiController]
The [ApiController] attribute is a strong default for controller-based APIs. When it is applied to a controller, ASP.NET Core automatically returns a 400 Bad Request with a ValidationProblemDetails body if ModelState is invalid, before your action method even runs. This removes the need for explicit ModelState.IsValid checks in each action method -- though if you need custom validation behavior, you can opt out of specific behaviors via ApiBehaviorOptions.
This behavior is one of the strongest arguments for using [ApiController] on controller-based Web API projects.The automatic validation response includes the property names and error messages in a structured errors dictionary, which is exactly what frontend and mobile clients need to display field-level error messages.
The returned body matches the Problem Details format from RFC 9457 (covered in the Error Handling in ASP.NET Core guide):
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is not a valid e-mail address."],
"Username": ["Username must be 3-50 characters"]
}
}
You can customize this response using InvalidModelStateResponseFactory on ApiBehaviorOptions, which is covered in the error handling guide. For most applications, the default format is perfectly adequate.
Custom Validation Attributes
Data annotations cover the common cases well. When you need a business-specific rule that does not map to a built-in annotation, create a custom ValidationAttribute. You inherit from ValidationAttribute and override IsValid(object? value, ValidationContext validationContext). The ValidationContext gives you access to the DI container, the model instance, and the property name.
Here is a practical example -- a [FutureDate] attribute that ensures a date is in the future:
[AttributeUsage(AttributeTargets.Property, AllowMultiple = false)]
public sealed class FutureDateAttribute : ValidationAttribute
{
public FutureDateAttribute()
: base("The {0} field must be a future date.")
{
}
protected override ValidationResult? IsValid(
object? value,
ValidationContext validationContext)
{
if (value is null)
{
// Let [Required] handle null -- not our concern
return ValidationResult.Success;
}
if (value is not DateTimeOffset dateValue)
{
return new ValidationResult(
$"The {validationContext.DisplayName} field must be a date.");
}
return dateValue > DateTimeOffset.UtcNow
? ValidationResult.Success
: new ValidationResult(
FormatErrorMessage(validationContext.DisplayName));
}
}
Usage is simple:
public sealed record ScheduleEventRequest
{
[Required]
public string Title { get; init; } = string.Empty;
[Required]
[FutureDate]
public DateTimeOffset ScheduledAt { get; init; }
}
Custom attributes are powerful for reusable rules. They work with asp.net core model validation at the framework level, so they participate in automatic 400 responses with [ApiController] just like built-in annotations. The limitation is that they are synchronous -- if your validation rule requires an async operation (like checking a database), you need a different approach.
IValidatableObject: Cross-Property Validation
Sometimes a validation rule spans multiple properties. The [Compare] attribute handles the simple password confirmation case, but complex rules -- "if PaymentMethod is CreditCard, then CardNumber is required" -- do not fit neatly into per-property attributes. This is where IValidatableObject shines.
Implementing IValidatableObject on your request model adds a Validate(ValidationContext validationContext) method. ASP.NET Core calls this method after all property-level annotations have passed, giving you access to the full model state.
public sealed class CreateOrderRequest : IValidatableObject
{
[Required]
public string ProductId { get; init; } = string.Empty;
[Range(1, 10000)]
public int Quantity { get; init; }
[Required]
public string PaymentMethod { get; init; } = string.Empty;
public string? CardNumber { get; init; }
public DateTimeOffset? RequestedDeliveryDate { get; init; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (PaymentMethod == "CreditCard" && string.IsNullOrWhiteSpace(CardNumber))
{
yield return new ValidationResult(
"Card number is required when payment method is CreditCard.",
new[] { nameof(CardNumber) });
}
if (RequestedDeliveryDate.HasValue &&
RequestedDeliveryDate.Value < DateTimeOffset.UtcNow.AddHours(2))
{
yield return new ValidationResult(
"Requested delivery date must be at least 2 hours in the future.",
new[] { nameof(RequestedDeliveryDate) });
}
}
}
The yield return pattern makes it easy to return multiple errors. Associating each error with specific property names (the second argument to ValidationResult) ensures the errors appear under the correct field names in the ValidationProblemDetails response. IValidatableObject is often the better fit when cross-property rules are few and the model owns the validation concern naturally.
FluentValidation: Expressive, Testable Validation
Data annotations are convenient. But they have real limitations: they are synchronous, they couple validation rules to the model class, and complex rules produce ugly, long attribute chains that are hard to read and harder to test. FluentValidation solves all three problems.
FluentValidation is a popular .NET library that lets you define validation rules in dedicated validator classes using a fluent DSL. Each model gets its own AbstractValidator<T> subclass. Rules are defined with RuleFor(x => x.Property) and chained conditions like .NotEmpty(), .MaximumLength(), .EmailAddress(), .WithMessage(), and the powerful .Must(predicate) for custom logic.
Install the NuGet packages:
dotnet add package FluentValidation.AspNetCore
Here is a complete validator for the CreateUserRequest model from earlier:
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Username)
.NotEmpty().WithMessage("Username is required")
.Length(3, 50).WithMessage("Username must be 3-50 characters")
.Matches(@"^[a-zA-Z0-9_]+$")
.WithMessage("Username may only contain letters, numbers, and underscores");
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required")
.EmailAddress().WithMessage("A valid email address is required")
.MaximumLength(256);
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8).WithMessage("Password must be at least 8 characters");
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("Passwords do not match");
RuleFor(x => x.Age)
.InclusiveBetween(13, 120)
.When(x => x.Age.HasValue)
.WithMessage("Age must be between 13 and 120");
}
}
Register validators and integrate with asp.net core model validation in Program.cs:
builder.Services.AddControllers();
// Register all validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<CreateUserRequestValidator>();
// Integrate with ModelState so [ApiController] automatic 400 responses work with FluentValidation
builder.Services.AddFluentValidationAutoValidation();
With AddFluentValidationAutoValidation, FluentValidation hooks into the ModelState pipeline. When validation fails, errors populate ModelState just like data annotation failures, and [ApiController] returns the automatic 400 response. From the client's perspective, it looks identical -- the field names and error messages are in the same errors dictionary.
The real advantages become clear when you start writing tests. A CreateUserRequestValidator can be unit tested directly -- just instantiate it, call Validate(model), and assert specific errors are present or absent. No HTTP context, no controller, no middleware. This kind of isolation is precisely what the dependency injection patterns covered here make possible.
Async Validation with FluentValidation
One of FluentValidation's killer features for asp.net core model validation is async rule support. Data annotations are synchronous -- you cannot check whether a username is already taken in a database inside an annotation. FluentValidation provides MustAsync and CustomAsync for exactly this.
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
private readonly IUserRepository _userRepository;
public CreateUserRequestValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
RuleFor(x => x.Username)
.NotEmpty()
.Length(3, 50)
.MustAsync(async (username, cancellationToken) =>
{
var exists = await _userRepository.ExistsAsync(username, cancellationToken);
return !exists;
})
.WithMessage("This username is already taken");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, cancellationToken) =>
!await _userRepository.EmailExistsAsync(email, cancellationToken))
.WithMessage("An account with this email already exists");
}
}
FluentValidation validators with async rules are automatically resolved from DI -- your IUserRepository is injected at construction time. Async rules are supported and run asynchronously through the validation pipeline, and the result still flows through ModelState normally.
Note that async validation only works when the validator is invoked asynchronously. The AddFluentValidationAutoValidation integration calls validators asynchronously when async rules are present, so this works out of the box in the standard ASP.NET Core validation pipeline.
Validation in Minimal APIs
ASP.NET Core model validation in minimal APIs changed significantly in .NET 10. Prior to .NET 10, there was no [ApiController] attribute and no automatic ModelState check -- if you bound a request body with [FromBody], the body was deserialized but validation attributes were not automatically evaluated. .NET 10 introduced built-in minimal API validation support, enabling automatic evaluation of data annotation attributes on bound parameters.
For simpler models with straightforward annotation-based rules, this built-in support often covers what you need. Manual validation or FluentValidation-based validation still makes sense when you have:
- Complex cross-field rules that annotations cannot express (for example, "if PaymentMethod is CreditCard, then CardNumber is required")
- Custom error messages with dynamic context or localization
- Async validation that checks external state like database uniqueness
- Third-party integration patterns where FluentValidation's
IValidator<T>abstraction fits your overall architecture
When you need explicit validation control, endpoint filters (introduced in .NET 7 and still relevant in .NET 10) provide a clean hook:
app.MapPost("/users", async (
[FromBody] CreateUserRequest request,
IValidator<CreateUserRequest> validator,
IUserService userService) =>
{
var validationResult = await validator.ValidateAsync(request);
if (!validationResult.IsValid)
{
var errors = validationResult.Errors
.GroupBy(e => e.PropertyName)
.ToDictionary(
g => g.Key,
g => g.Select(e => e.ErrorMessage).ToArray());
return Results.ValidationProblem(errors);
}
var user = await userService.CreateAsync(request);
return Results.Created($"/users/{user.Id}", user);
});
Results.ValidationProblem(errors) returns a 400 response with ValidationProblemDetails matching the same format that [ApiController] produces. For reusable validation across many endpoints, create an endpoint filter that runs the FluentValidation validator automatically, eliminating the repetition.
Frequently Asked Questions
When should I use data annotations vs FluentValidation?
Data annotations are often the better fit for simple, self-contained validation rules that belong logically to the model. Required fields, length constraints, format validation (email, URL), range constraints -- these are all excellent annotation candidates. They are zero-boilerplate and immediately readable.
FluentValidation is often the better fit when rules are complex, conditional, require async operations, or need to be tested independently. It also makes more sense when your team writes tests for validation logic, because testing a AbstractValidator<T> directly is far simpler than testing model binding through a full HTTP stack. If you start with annotations and find yourself writing long custom attribute chains, FluentValidation is probably the better tool.
In practice, many teams use both. Data annotations for structural constraints that belong on the DTO, FluentValidation for business rules that belong in the application layer. The two work together without conflict.
Does FluentValidation replace ModelState in ASP.NET Core?
Not exactly -- FluentValidation integrates with ModelState rather than replacing it. When you use AddFluentValidationAutoValidation, FluentValidation runs its validators during model binding, translates failures into ModelStateEntry errors, and populates ModelState exactly as data annotations would. From the controller's perspective and from [ApiController]'s perspective, validation is validation regardless of the source.
This integration means you can mix annotations and FluentValidation validators on the same model. Annotations handle the structural constraints, FluentValidation handles the business rules, and both feed into the same ModelState dictionary. The automatic 400 response from [ApiController] includes errors from both sources.
How do I return validation errors from minimal API endpoints?
Use Results.ValidationProblem(errors) where errors is a Dictionary<string, string[]> mapping property names to error message arrays. This produces the same ValidationProblemDetails response format as [ApiController] controllers -- status 400, content type application/problem+json, errors in the errors field.
For validation with FluentValidation, inject IValidator<T> into your endpoint, call ValidateAsync, and map the ValidationResult.Errors collection to the dictionary format. For data annotations, use Validator.TryValidateObject from System.ComponentModel.DataAnnotations. Creating a reusable endpoint filter that does this for every endpoint in your application eliminates boilerplate across dozens of routes.
Can I use IValidatableObject and FluentValidation on the same model?
Yes, and ASP.NET Core will run both. First it evaluates data annotations on each property. If any fail, it may stop there (depending on AllowMultiple behavior). If annotations pass, it calls IValidatableObject.Validate. FluentValidation runs as part of the model binding pipeline when AddFluentValidationAutoValidation is registered.
In practice, mixing all three on the same model often indicates the model is doing too much. Consider whether the validation belongs on the request DTO at all, or whether it belongs in a FluentValidation validator or a dedicated domain service. Keeping concerns separate -- request parsing, input validation, business rule enforcement -- makes each layer easier to test and evolve.
How do I write unit tests for FluentValidation validators?
FluentValidation validators are plain C# classes -- just instantiate them and call Validate or ValidateAsync. No test doubles for HttpContext, no controller infrastructure needed.
var validator = new CreateUserRequestValidator(userRepository);
var request = new CreateUserRequest { Username = "x" }; // Too short
var result = await validator.ValidateAsync(request);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e =>
e.PropertyName == nameof(CreateUserRequest.Username) &&
e.ErrorMessage.Contains("3-50 characters"));
Test the happy path (valid model passes), the sad path (invalid model fails with correct errors), and boundary conditions (exactly at minimum length, one below minimum). This test pattern is fast, readable, and gives you full confidence that validation rules work correctly before the first HTTP request hits your application.
How does asp.net core model validation handle nested objects?
For nested complex types, validation runs recursively. If your request DTO has a property of type Address, all annotations on Address properties are validated too. The error keys in ModelState use dot notation -- Address.Street, Address.City, and so on -- which maps correctly to the errors dictionary in the ValidationProblemDetails response.
For collections, each element is validated individually and errors are keyed with the index -- Items[0].Quantity, Items[1].ProductId. FluentValidation handles nested validation with RuleForEach for collections and by calling SetValidator(new AddressValidator()) on nested object properties. Both approaches produce the same dot-notation keys in ModelState.
What is the best way to validate business rules that require database access?
FluentValidation with MustAsync is the cleanest approach. The validator is registered in DI, so it can declare dependencies on repositories and services. MustAsync accepts an async predicate that can make database calls, and the result flows through ModelState normally.
The key architectural consideration is where this validation belongs. Simple uniqueness checks ("is this username taken?") fit naturally in a validator. Complex business rules that involve multiple aggregates or side effects belong in the domain layer -- not in input validation. Reserve validation for "can this request be processed" and leave domain logic for "should this operation succeed given current state." This boundary keeps your validators fast, focused, and independently testable, which is a core principle visible throughout patterns like LINQ in C# for composing data queries and transformations.

