While standard Data Annotations are simple, they have severe limitations. They clutter your clean DTO classes, they cannot easily validate parent-child object structures, and writing complex conditional logic (e.g., "CreditCardNumber is required ONLY IF PaymentMethod is 'Card'") is agonizing. FluentValidation is the enterprise standard that completely replaces DataAnnotations.
FluentValidation removes validation rules from the DTO entirely, placing them into dedicated "Validator" classes using a clean, readable lambda-expression syntax.
dotnet add package FluentValidation.AspNetCore
public class RegistrationDto
{
// No messy attributes here!
public string Email { get; set; }
public string Password { get; set; }
public string StateProvince { get; set; }
public string ZipCode { get; set; }
}
using FluentValidation;
public class RegistrationValidator : AbstractValidator<RegistrationDto>
{
public RegistrationValidator()
{
// Chainable, beautiful syntax
RuleFor(x => x.Email)
.NotEmpty().WithMessage("Email is required.")
.EmailAddress().WithMessage("Must be a valid email.");
RuleFor(x => x.Password)
.MinimumLength(8).WithMessage("Password too short.");
// Complex Conditional Logic (Impossible with standard DataAnnotations)
// Require ZipCode ONLY IF the State is 'CA' or 'NY'
RuleFor(x => x.ZipCode)
.NotEmpty()
.When(x => x.StateProvince == "CA" || x.StateProvince == "NY")
.WithMessage("Zip code is mandatory for California and New York residents.");
}
}
To make the [ApiController] automatic 400 Bad Request feature seamlessly trigger these new Fluent rules instead of standard annotations, we register FluentValidation in Program.cs.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
// Automatically scans the assembly for all AbstractValidator classes and registers them
builder.Services.AddValidatorsFromAssemblyContaining<RegistrationValidator>();
builder.Services.AddFluentValidationAutoValidation();
One of FluentValidation's greatest strengths is its ability to inject Services (like a DbContext) directly into the validator to check database constraints asynchronously.
public class UserValidator : AbstractValidator<CreateUserDto>
{
private readonly ApplicationDbContext _db;
// Use DI in the Validator!
public UserValidator(ApplicationDbContext db)
{
_db = db;
RuleFor(x => x.Email)
.NotEmpty()
// MustAsync enables asynchronous database checks
.MustAsync(BeUniqueEmail).WithMessage("This email is already registered.");
}
private async Task<bool> BeUniqueEmail(string email, CancellationToken token)
{
// Returns true if NO user exists with this email
return !await _db.Users.AnyAsync(u => u.Email == email, token);
}
}
Q: "Why do enterprise applications universally prefer FluentValidation over DataAnnotations?"
Architect Answer: "The primary driver is the Single Responsibility Principle. A DTO's ONLY job is to declare physical data layout so the JSON parser can hydrate it. It should not be burdened with business rules. Furthermore, FluentValidation allows for Contextual Validation: I can create two different Validators for the exact same `UserDto`—one for Administrators (who can leave the password blank when resetting it) and one for standard users (who must provide the old password). DataAnnotations are hardcoded onto the class, making situational validation nearly impossible without writing duplicate DTOs."