Data annotations work for simple cases, but they fall apart once validation rules involve conditional logic, cross-property checks, or async operations like database lookups. FluentValidation gives you a fluent API to define validation rules in dedicated classes, keeping your models clean and your rules testable.
Getting Started
Install the package:
dotnet add package FluentValidation.DependencyInjectionExtensions
Define a validator by inheriting from AbstractValidator<T>:
public class CreateOrderRequest
{
public string CustomerEmail { get; set; } = string.Empty;
public decimal Total { get; set; }
public List<OrderItem> Items { get; set; } = [];
}
public class CreateOrderValidator : AbstractValidator<CreateOrderRequest>
{
public CreateOrderValidator()
{
RuleFor(x => x.CustomerEmail)
.NotEmpty()
.EmailAddress()
.WithMessage("A valid email address is required.");
RuleFor(x => x.Total)
.GreaterThan(0)
.WithMessage("Order total must be greater than zero.");
RuleFor(x => x.Items)
.NotEmpty()
.WithMessage("An order must contain at least one item.");
}
}
Registration with DI
Register all validators from an assembly in one call:
builder.Services.AddValidatorsFromAssemblyContaining<CreateOrderValidator>();
This scans the assembly and registers every class that inherits from AbstractValidator<T> as a scoped service.
ASP.NET Core Integration
For automatic validation in minimal APIs or controllers, you can wire FluentValidation into the request pipeline. With minimal APIs, create a validation filter:
public class ValidationFilter<T> : IEndpointFilter where T : class
{
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var validator = context.HttpContext
.RequestServices.GetService<IValidator<T>>();
if (validator is null)
return await next(context);
var argument = context.Arguments
.OfType<T>()
.FirstOrDefault();
if (argument is null)
return await next(context);
var result = await validator.ValidateAsync(argument);
if (!result.IsValid)
{
return Results.ValidationProblem(
result.ToDictionary());
}
return await next(context);
}
}
Apply it to an endpoint:
app.MapPost("/orders", (CreateOrderRequest request) =>
{
// Handler logic — only reached if validation passes
return Results.Created($"/orders/{Guid.NewGuid()}", request);
})
.AddEndpointFilter<ValidationFilter<CreateOrderRequest>>();
Complex Rules
FluentValidation handles conditional and cross-property rules naturally:
public class PaymentValidator : AbstractValidator<PaymentRequest>
{
public PaymentValidator()
{
RuleFor(x => x.CardNumber)
.CreditCard()
.When(x => x.PaymentMethod == PaymentMethod.Card);
RuleFor(x => x.BankAccount)
.NotEmpty()
.When(x => x.PaymentMethod == PaymentMethod.BankTransfer);
RuleFor(x => x.ExpiryDate)
.GreaterThan(DateTime.UtcNow)
.When(x => x.PaymentMethod == PaymentMethod.Card)
.WithMessage("Card has expired.");
}
}
The When method applies a rule conditionally. There's also Unless for the inverse.
Async Validation
Need to check a database? Use MustAsync:
public class RegisterUserValidator : AbstractValidator<RegisterUserRequest>
{
public RegisterUserValidator(IUserRepository userRepository)
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, cancellation) =>
{
var exists = await userRepository.ExistsAsync(email, cancellation);
return !exists;
})
.WithMessage("Email is already registered.");
}
}
Because validators are resolved from DI, you can inject any service you need.
Collection Validation
Validate each item in a collection with RuleForEach:
RuleForEach(x => x.Items).ChildRules(item =>
{
item.RuleFor(i => i.ProductId)
.NotEmpty();
item.RuleFor(i => i.Quantity)
.GreaterThan(0)
.LessThanOrEqualTo(100);
});
Testing Validators
One of FluentValidation's strongest advantages is testability. The library includes a test extensions package:
[Fact]
public void Should_fail_when_email_is_empty()
{
var validator = new CreateOrderValidator();
var request = new CreateOrderRequest { CustomerEmail = "", Total = 10, Items = [new()] };
var result = validator.Validate(request);
result.ShouldHaveValidationErrorFor(x => x.CustomerEmail);
}
[Fact]
public void Should_pass_with_valid_request()
{
var validator = new CreateOrderValidator();
var request = new CreateOrderRequest
{
CustomerEmail = "[email protected]",
Total = 50,
Items = [new()]
};
var result = validator.Validate(request);
result.IsValid.Should().BeTrue();
}
FluentValidation keeps validation logic out of your models, makes it easy to test, and scales from simple property checks to complex business rules involving database lookups and conditional logic. It pairs particularly well with MediatR pipeline behaviours for automatic request validation.