FluentValidation — The Right Way to Validate in .NET
Data annotations scatter validation rules across DTOs and break down for complex scenarios. FluentValidation centralises rules in dedicated validator classes, supports async DB checks, and integrates cleanly with ASP.NET Core.
Why Data Annotations Don't Scale
// Data annotations: rules mixed with the model, no reuse, no async
public class CreateOrderRequest
{
[Required]
[StringLength(200, MinimumLength = 1)]
public string ProductName { get; set; } = string.Empty;
[Range(1, 10000)]
public int Quantity { get; set; }
// How do you validate that the CustomerId exists in the DB?
// How do you express "if PaymentMethod is Card, CardNumber is required"?
// You can't — you write it in the controller instead.
}FluentValidation puts rules in their own class, keeps DTOs clean, and handles anything from regex to async DB lookups.
Setup
dotnet add package FluentValidation.DependencyInjectionExtensions// Program.cs — registers all validators in the assembly
builder.Services.AddValidatorsFromAssemblyContaining<Program>();Basic Validator
using FluentValidation;
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.ProductName)
.NotEmpty().WithMessage("Product name is required.")
.Length(1, 200).WithMessage("Product name must be 1–200 characters.");
RuleFor(x => x.Quantity)
.GreaterThan(0).WithMessage("Quantity must be at least 1.")
.LessThanOrEqualTo(10_000).WithMessage("Quantity cannot exceed 10,000.");
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress().WithMessage("A valid email address is required.");
RuleFor(x => x.ShippingPostalCode)
.NotEmpty()
.Matches(@"^\d{5}(-\d{4})?$").WithMessage("Postal code must be 5 or 9 digits.");
}
}Conditional Rules
public class PaymentRequestValidator : AbstractValidator<PaymentRequest>
{
public PaymentRequestValidator()
{
RuleFor(x => x.PaymentMethod)
.NotEmpty()
.IsInEnum();
// CardNumber only required when method is Card
RuleFor(x => x.CardNumber)
.NotEmpty()
.CreditCard()
.When(x => x.PaymentMethod == PaymentMethod.Card);
// BankAccountNumber only required for BankTransfer
RuleFor(x => x.BankAccountNumber)
.NotEmpty()
.Matches(@"^\d{8,12}$")
.When(x => x.PaymentMethod == PaymentMethod.BankTransfer);
}
}Nested Object Validation (ChildRules)
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.ProductName).NotEmpty();
// Validate a nested object inline
RuleFor(x => x.ShippingAddress)
.NotNull().WithMessage("Shipping address is required.")
.ChildRules(address =>
{
address.RuleFor(a => a.Street).NotEmpty();
address.RuleFor(a => a.City).NotEmpty();
address.RuleFor(a => a.PostalCode).NotEmpty().Matches(@"^\d{5}$");
address.RuleFor(a => a.Country)
.NotEmpty()
.Length(2, 2).WithMessage("Country must be a 2-letter ISO code.");
});
}
}Or create a separate validator for the nested type and set it as a child:
public class AddressValidator : AbstractValidator<Address>
{
public AddressValidator()
{
RuleFor(x => x.Street).NotEmpty();
RuleFor(x => x.City).NotEmpty();
RuleFor(x => x.PostalCode).NotEmpty();
}
}
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator(AddressValidator addressValidator)
{
RuleFor(x => x.ShippingAddress)
.NotNull()
.SetValidator(addressValidator);
}
}Collection Validation (RuleForEach)
public class CreateOrderRequestValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderRequestValidator()
{
RuleFor(x => x.LineItems)
.NotEmpty().WithMessage("Order must have at least one item.");
RuleForEach(x => x.LineItems).ChildRules(item =>
{
item.RuleFor(i => i.ProductId).GreaterThan(0);
item.RuleFor(i => i.Quantity).InclusiveBetween(1, 999);
item.RuleFor(i => i.UnitPrice).GreaterThan(0m);
});
}
}Custom Validators With Must
RuleFor(x => x.DeliveryDate)
.Must(date => date > DateOnly.FromDateTime(DateTime.UtcNow))
.WithMessage("Delivery date must be in the future.");
// Access the whole object in Must
RuleFor(x => x.EndDate)
.Must((request, endDate) => endDate > request.StartDate)
.WithMessage("End date must be after start date.");Async Validation for DB Uniqueness
public class RegisterUserRequestValidator : AbstractValidator<RegisterUserRequest>
{
private readonly IUserRepository _users;
public RegisterUserRequestValidator(IUserRepository users)
{
_users = users;
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(BeUniqueEmailAsync)
.WithMessage("This email address is already registered.");
}
private async Task<bool> BeUniqueEmailAsync(
string email, CancellationToken ct)
{
return !await _users.ExistsAsync(email, ct);
}
}Register the validator with scoped lifetime when it depends on repositories:
// AddValidatorsFromAssemblyContaining registers as Scoped by default — this is correct
builder.Services.AddValidatorsFromAssemblyContaining<Program>();Auto-Validation in ASP.NET Core
Manually calling validator.ValidateAsync in every controller is noisy. FluentValidation can hook into the model binding pipeline:
// dotnet add package FluentValidation.AspNetCore
builder.Services.AddFluentValidationAutoValidation();
builder.Services.AddValidatorsFromAssemblyContaining<Program>();Now invalid requests automatically return 400 Bad Request with the full error list — before your action method runs.
If you prefer explicit control (recommended for non-trivial APIs):
[HttpPost("orders")]
public async Task<IActionResult> Create(
[FromBody] CreateOrderRequest request,
[FromServices] IValidator<CreateOrderRequest> validator)
{
var validation = await validator.ValidateAsync(request);
if (!validation.IsValid)
return BadRequest(validation.ToDictionary()); // groups errors by property name
var result = await _orderService.CreateAsync(request);
return result.Match(
order => CreatedAtAction(nameof(Get), new { id = order.Id }, order),
errors => errors.ToActionResult(this));
}Testing Validators
public class CreateOrderRequestValidatorTests
{
private readonly CreateOrderRequestValidator _validator = new();
[Fact]
public async Task Fails_WhenProductNameIsEmpty()
{
var request = new CreateOrderRequest { ProductName = "", Quantity = 1 };
var result = await _validator.TestValidateAsync(request);
result.ShouldHaveValidationErrorFor(x => x.ProductName);
}
[Fact]
public async Task Passes_WithValidRequest()
{
var request = new CreateOrderRequest
{
ProductName = "Widget",
Quantity = 5,
Email = "user@example.com"
};
var result = await _validator.TestValidateAsync(request);
result.ShouldNotHaveAnyValidationErrors();
}
}TestValidate / TestValidateAsync are helpers from the FluentValidation.TestHelper package.
Key Takeaways
- Data annotations are fine for simple DTOs; FluentValidation scales to complex rules
- Keep validators in their own classes — they're testable, injectable, and composable
- Use
MustAsyncfor DB-backed uniqueness checks; register validators as Scoped to support repository injection RuleForEachhandles collections;ChildRules/SetValidatorhandle nested objects- Auto-validation keeps controllers thin; explicit validation gives you more control over error responses
Enjoyed this article?
Explore the Backend Systems learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.