Validation is one of the first real decisions you make when building an ASP.NET Core API, and the answer is almost always one of two options: Data Annotations or FluentValidation. Both plug into the ASP.NET Core model binding pipeline, both return 400 responses with structured error details when validation fails, and both have years of production use behind them. What differs is where the rules live, how complex they can get, and how easily you can test them in isolation.
Data Annotations are attributes placed directly on your request DTO properties. FluentValidation is a separate library where validation rules live in dedicated validator classes. This post compares both approaches using the same request model, walks through five tradeoffs, and gives you a clear framework for choosing the right tool for your project.
By the end you will have working code for both approaches, an understanding of where each breaks down, and a decision you can commit to before you write your first endpoint.
Data Annotations ship with the .NET base class library under System.ComponentModel.DataAnnotations. They predate ASP.NET Core by over a decade, originating in WPF and WCF validation scenarios. In ASP.NET Core, the [ApiController] attribute triggers automatic model state validation before the action method is called, so invalid requests are rejected without any filter or explicit check in the action body.
FluentValidation is a popular open-source library by Jeremy Skinner, available on NuGet as FluentValidation.AspNetCore. It integrates with ASP.NET Core's model validation pipeline through an adapter, so it participates in the same automatic validation behavior that Data Annotations use. Rules are expressed in C# via a fluent builder API inside a dedicated validator class, keeping the DTO free of attributes.
Both libraries ultimately populate ModelState and produce the same 400 response shape when integrated correctly. The difference is entirely in developer ergonomics and long-term maintainability.
Consider a CreateProductRequest that requires a non-empty name, a positive price, a stock count between zero and ten thousand, and an optional description capped at 500 characters.
Data Annotations approach
// Models/Requests/CreateProductRequest.cs
public class CreateProductRequest
{
[Required(ErrorMessage = "Name is required.")]
[MaxLength(200, ErrorMessage = "Name cannot exceed 200 characters.")]
public string Name { get; set; } = string.Empty;
[Range(0.01, 999999.99, ErrorMessage = "Price must be between 0.01 and 999,999.99.")]
public decimal Price { get; set; }
[Range(0, 10000, ErrorMessage = "Stock must be between 0 and 10,000.")]
public int Stock { get; set; }
[MaxLength(500, ErrorMessage = "Description cannot exceed 500 characters.")]
public string? Description { get; set; }
}
FluentValidation approach
// Models/Requests/CreateProductRequest.cs — no attributes
public class CreateProductRequest
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
public string? Description { get; set; }
}
// Validators/CreateProductRequestValidator.cs
public class CreateProductRequestValidator
: AbstractValidator<CreateProductRequest>
{
public CreateProductRequestValidator()
{
RuleFor(x => x.Name)
.NotEmpty().WithMessage("Name is required.")
.MaximumLength(200).WithMessage("Name cannot exceed 200 characters.");
RuleFor(x => x.Price)
.InclusiveBetween(0.01m, 999_999.99m)
.WithMessage("Price must be between 0.01 and 999,999.99.");
RuleFor(x => x.Stock)
.InclusiveBetween(0, 10_000)
.WithMessage("Stock must be between 0 and 10,000.");
RuleFor(x => x.Description)
.MaximumLength(500)
.WithMessage("Description cannot exceed 500 characters.")
.When(x => x.Description is not null);
}
}
Register FluentValidation in Program.cs so it participates in automatic model validation alongside Data Annotations.
// Program.cs — FluentValidation registration
builder.Services.AddControllers();
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();
// If you want FluentValidation to replace Data Annotations in ModelState:
builder.Services.AddFluentValidationAutoValidation()
.AddFluentValidationClientsideAdapters();
Both approaches cause ASP.NET Core to return a 400 with a structured error body when the incoming request fails validation. The controller action is never reached for invalid input.
1. Separation of concerns
Data Annotations embed validation rules directly on the model class as attributes. This is convenient for simple cases but couples the model's shape to its validation policy. If the same DTO is used in different contexts where different rules should apply (create vs. update, admin vs. regular user), attributes cannot express that distinction without branching into custom attribute implementations.
FluentValidation keeps each set of rules in its own class. You can have CreateProductRequestValidator and AdminCreateProductRequestValidator with different rule sets for the same DTO, registered conditionally. The model stays a plain data carrier.
2. Complex and conditional validation
This is where Data Annotations reach their practical ceiling. Rules that depend on the values of multiple properties, on data retrieved from a database, or on conditional logic require custom attribute classes that become non-trivial to write and test.
// FluentValidation handles multi-property and async rules cleanly
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
private readonly IProductRepository _products;
public CreateOrderRequestValidator(IProductRepository products)
=> _products = products;
public CreateOrderRequestValidator()
{
RuleFor(x => x.DeliveryDate)
.GreaterThan(x => x.OrderDate)
.WithMessage("Delivery date must be after the order date.");
RuleFor(x => x.ProductId)
.MustAsync(async (id, ct) => await _products.ExistsAsync(id, ct))
.WithMessage("The specified product does not exist.");
}
}
Data Annotations cannot express either of those rules without a custom attribute that takes constructor parameters, which quickly becomes awkward to maintain and impossible to inject services into cleanly.
3. Reusability and composition
FluentValidation supports reusing rule sets via SetValidator and the ChildRules pattern. If a dozen request models all share an address sub-object, one AddressValidator class can be composed into each parent validator. Data Annotations on nested objects require the [ValidateNever] and [ValidateAlways] markers plus explicit recursion to achieve the same effect.
// FluentValidation composition example
public class AddressValidator : AbstractValidator<AddressDto>
{
public AddressValidator()
{
RuleFor(x => x.Street).NotEmpty().MaximumLength(200);
RuleFor(x => x.PostCode).NotEmpty().Matches(@"^\d{4,10}$");
RuleFor(x => x.CountryCode).NotEmpty().Length(2);
}
}
public class CreateCustomerRequestValidator
: AbstractValidator<CreateCustomerRequest>
{
public CreateCustomerRequestValidator()
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.ShippingAddress).SetValidator(new AddressValidator());
RuleFor(x => x.BillingAddress).SetValidator(new AddressValidator());
}
}
4. Error messages and localization
Data Annotations support ErrorMessage string properties and resource-file-based localization through ErrorMessageResourceType and ErrorMessageResourceName. It works, but the setup is verbose and the pattern is unfamiliar to developers who have not worked with .NET resource files before. FluentValidation has a built-in localization pipeline with language packs available as separate NuGet packages, and adding a custom message store is straightforward.
5. Testability
FluentValidation validators are plain classes with no framework dependency. You can instantiate a validator and call ValidateAsync on a test object in a regular xUnit test without any mocking, middleware, or HTTP context.
// Direct unit test of a FluentValidation validator
[Fact]
public async Task Price_BelowMinimum_FailsValidation()
{
var validator = new CreateProductRequestValidator();
var request = new CreateProductRequest { Name = "Widget", Price = -1m, Stock = 10 };
var result = await validator.ValidateAsync(request);
Assert.False(result.IsValid);
Assert.Contains(result.Errors,
e => e.PropertyName == nameof(CreateProductRequest.Price));
}
[Fact]
public async Task ValidRequest_PassesValidation()
{
var validator = new CreateProductRequestValidator();
var request = new CreateProductRequest
{
Name = "Widget",
Price = 9.99m,
Stock = 100
};
var result = await validator.ValidateAsync(request);
Assert.True(result.IsValid);
}
Testing Data Annotations rules in unit tests requires calling Validator.TryValidateObject from System.ComponentModel.DataAnnotations, which is workable but less clean. More commonly, Data Annotations are tested indirectly through integration tests that exercise the full HTTP pipeline.
The right choice depends on the complexity of your validation logic and your team's appetite for an additional dependency.
Choose Data Annotations when:
- Your validation rules are simple: required fields, length limits, range checks, and regex patterns
- You want zero additional NuGet dependencies and are comfortable keeping rules on the model
- The project is small or a proof of concept that does not need complex conditional rules
- Your team is new to .NET and you want to minimize the number of patterns they need to learn at once
Choose FluentValidation when:
- Any validation rule depends on values from multiple properties or on external data (database lookups, HTTP calls)
- The same DTO needs different rule sets in different contexts (create vs. update, admin vs. standard user)
- You want to unit-test validators in isolation without spinning up an HTTP pipeline
- You are building a multi-team or enterprise API where consistency and composability of validation logic matter
- You have nested or shared sub-object validation that needs composable validators
A common middle-ground approach is to use Data Annotations for simple structural constraints (non-null, length limits) and FluentValidation for business rule validation (cross-field checks, database existence checks). Both validators run in sequence, and ModelState accumulates all errors from both.
| Concern | Data Annotations | FluentValidation |
|---|---|---|
| Location of rules | On the model class as attributes | Separate validator class |
| Simple rules | Excellent; terse and familiar | Good; more verbose for trivial rules |
| Multi-property rules | Requires custom attributes | First-class; built-in cross-property support |
| Async validation | Not supported | First-class via MustAsync |
| Composability | Limited; nested object recursion only | Strong; SetValidator and child rules |
| Unit testability | Requires Validator.TryValidateObject | Instantiate and call directly |
| External dependencies | None; ships with .NET | FluentValidation NuGet package required |
| Localization | Resource file-based; verbose setup | Built-in pipeline; language packs available |
For any API that will run in production with real business logic, FluentValidation is the stronger default. The separation of validation rules from model definitions pays for itself the first time you encounter a conditional rule or a cross-field dependency. Data Annotations remain the right choice for small projects and prototypes where adding a NuGet package and learning a new library is unnecessary overhead.
If your current project uses Data Annotations and you are hitting their limits, you can introduce FluentValidation incrementally: register it alongside existing annotations and migrate validators one DTO at a time.
- Error handling: The next post in this series covers how to use ASP.NET Core's Problem Details standard to return consistent, spec-compliant error responses across your entire API.
- FluentValidation docs: The official FluentValidation documentation at docs.fluentvalidation.net covers every built-in rule, extension points, and async scenarios with clear examples.
- Custom validators: Once you are comfortable with the basics, look into
AbstractValidatorconstructor injection to build validators that query your database or call external services during validation.
No comments: