Articles › ASP.NET Core
Input Validation in ASP.NET Core: Data Annotations vs FluentValidation
Validate API input properly in ASP.NET Core: data annotations, IValidatableObject, FluentValidation rules, async validation, minimal API filters and consistent ProblemDetails errors.
Every API receives data it can't trust. Users make typos, clients have bugs, and attackers send malicious payloads. Validation is your first line of defense: it rejects bad input early, with clear messages, before it reaches business logic or the database. ASP.NET Core gives you several ways to validate — this guide shows when to use each.
Where validation belongs
Think in layers:
- Input validation (API layer) — shape and format: required fields, lengths, ranges, email format.
- Business rules (application/domain layer) — "an order can't be cancelled after shipping", "the coupon is expired".
- Database constraints — the last safety net: unique indexes, foreign keys,
NOT NULL.
Input validation should be fast and declarative; business rules often need the database and belong in services.
Data annotations
The built-in approach uses attributes:
public class RegisterRequest
{
[Required, StringLength(50, MinimumLength = 3)]
public string UserName { get; set; } = "";
[Required, EmailAddress]
public string Email { get; set; } = "";
[Required, MinLength(8)]
public string Password { get; set; } = "";
[Compare(nameof(Password))]
public string ConfirmPassword { get; set; } = "";
[Range(13, 120)]
public int Age { get; set; }
[RegularExpression(@"^09\d{9}$", ErrorMessage = "Enter a valid Iranian mobile number.")]
public string? Mobile { get; set; }
}
With [ApiController], ASP.NET Core validates the model automatically and returns 400 Bad Request with a ValidationProblemDetails body before your action even runs:
{
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Email": ["The Email field is not a valid e-mail address."],
"Password": ["The field Password must be a string or array type with a minimum length of '8'."]
}
}
Cross-field rules with IValidatableObject
public class BookingRequest : IValidatableObject
{
public DateTime CheckIn { get; set; }
public DateTime CheckOut { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (CheckOut <= CheckIn)
yield return new ValidationResult(
"Check-out must be after check-in.", [nameof(CheckOut)]);
}
}
Data annotations are great for simple DTOs. They get awkward when rules are conditional, need services or should be reused.
FluentValidation
FluentValidation expresses rules in code with a fluent API, keeping DTOs clean:
dotnet add package FluentValidation.DependencyInjectionExtensions
public record CreateProductRequest(string Name, string? Sku, decimal Price, int Stock, string Category);
public class CreateProductValidator : AbstractValidator<CreateProductRequest>
{
private static readonly string[] Categories = ["laptop", "phone", "accessory"];
public CreateProductValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Product name is required.")
.MaximumLength(150);
RuleFor(x => x.Price)
.GreaterThan(0)
.PrecisionScale(18, 2, ignoreTrailingZeros: true);
RuleFor(x => x.Stock).GreaterThanOrEqualTo(0);
RuleFor(x => x.Category)
.Must(c => Categories.Contains(c))
.WithMessage("Category must be one of: laptop, phone, accessory.");
RuleFor(x => x.Sku)
.Matches("^[A-Z]{3}-\\d{4}$")
.When(x => !string.IsNullOrEmpty(x.Sku));
}
}
Register all validators from the assembly:
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductValidator>();
Validating in a controller or service
The old automatic MVC integration (FluentValidation.AspNetCore) is deprecated; the recommended approach is explicit validation:
[HttpPost]
public async Task<IActionResult> Create(
CreateProductRequest request,
IValidator<CreateProductRequest> validator,
CancellationToken ct)
{
var result = await validator.ValidateAsync(request, ct);
if (!result.IsValid)
return ValidationProblem(result.ToModelState()); // helper below
// ... create the product
return Created();
}
A small helper keeps controllers tidy:
public static class ValidationExtensions
{
public static ModelStateDictionary ToModelState(this ValidationResult result)
{
var state = new ModelStateDictionary();
foreach (var error in result.Errors)
state.AddModelError(error.PropertyName, error.ErrorMessage);
return state;
}
}
Async rules that need services
public class RegisterValidator : AbstractValidator<RegisterRequest>
{
public RegisterValidator(AppDbContext db)
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, ct) => !await db.Users.AnyAsync(u => u.Email == email, ct))
.WithMessage("This email is already registered.");
}
}
Tip: Even with an async uniqueness check, keep a unique index in the database. Two requests can pass validation at the same moment — only the database constraint is race-free.
Reusable and nested rules
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(a => a.City).NotEmpty();
RuleFor(a => a.PostalCode).Matches(@"^\d{10}$");
}
}
public class OrderValidator : AbstractValidator<CreateOrder>
{
public OrderValidator()
{
RuleFor(o => o.ShippingAddress).SetValidator(new AddressValidator());
RuleForEach(o => o.Items).ChildRules(item =>
{
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 100);
});
RuleFor(o => o.Items).NotEmpty().WithMessage("An order needs at least one item.");
}
}
Validation in minimal APIs
Use an endpoint filter to validate any request type automatically:
public class ValidationFilter<T> : IEndpointFilter
{
public async ValueTask<object?> InvokeAsync(EndpointFilterInvocationContext ctx, EndpointFilterDelegate next)
{
var validator = ctx.HttpContext.RequestServices.GetService<IValidator<T>>();
var arg = ctx.Arguments.OfType<T>().FirstOrDefault();
if (validator is not null && arg is not null)
{
var result = await validator.ValidateAsync(arg);
if (!result.IsValid)
return Results.ValidationProblem(result.ToDictionary());
}
return await next(ctx);
}
}
app.MapPost("/products", (CreateProductRequest req) => Results.Ok())
.AddEndpointFilter<ValidationFilter<CreateProductRequest>>();
.NET 10 also adds built-in data-annotation validation for minimal APIs via builder.Services.AddValidation().
Data annotations or FluentValidation?
| Data annotations | FluentValidation | |
|---|---|---|
| Setup | built in | NuGet package |
| Simple rules | ✅ concise | ✅ |
| Conditional rules | awkward | ✅ When, Unless |
| Rules needing services/DB | not supported | ✅ MustAsync |
| Keeps DTOs clean | ❌ attributes everywhere | ✅ |
| Unit testing rules | harder | ✅ TestValidate |
A pragmatic choice: annotations for tiny projects; FluentValidation as soon as rules become non-trivial.
Testing validators
[Fact]
public void Price_must_be_positive()
{
var validator = new CreateProductValidator();
var result = validator.TestValidate(new CreateProductRequest("Mouse", null, 0, 5, "accessory"));
result.ShouldHaveValidationErrorFor(x => x.Price);
}
Good error messages
- Say what's wrong and how to fix it: "Password must be at least 8 characters."
- Use field names the client recognizes (camelCase in JSON).
- Localize messages when you serve multiple languages.
- Never echo raw input back without encoding.
Summary
Validate input at the edge of your API, business rules in your application layer, and keep database constraints as the final safety net. Data annotations are perfect for simple DTOs; FluentValidation shines for conditional, reusable and async rules. Whatever you choose, return one consistent ValidationProblemDetails format so clients can display errors next to the right fields.
FAQ
Should I validate on the client too?
Yes, for user experience — but never rely on it. Client validation can be bypassed; server validation is mandatory.
Where do I check "the user owns this resource"?
That's authorization, not validation. Handle it with authorization policies or in the application service.
Can validation replace database constraints?
No. Validation gives friendly messages; constraints guarantee integrity even under concurrency.