Data Annotations (like [Required] and [StringLength]) are great for simple apps, but they pollute your domain models and crumble under complex business logic. FluentValidation is the industry standard for .NET enterprise applications, separating validation rules from classes using a clean, fluent interface with full Dependency Injection support.
Validator classes.Install the required NuGet packages. Note that you no longer need the bulky AutoValidation package; modern architecture prefers explicit manual validation or API Endpoint Filters.
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
public class UserRegistrationDto
{
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public string Password { get; set; }
public string ConfirmPassword { get; set; }
public DateTime DateOfBirth { get; set; }
public AddressDto Address { get; set; } // Nested object
}
// Validators/UserRegistrationValidator.cs
using FluentValidation;
public class UserRegistrationValidator : AbstractValidator<UserRegistrationDto>
{
private readonly IUserRepository _userRepository;
// We can use Dependency Injection inside validators!
public UserRegistrationValidator(IUserRepository userRepository)
{
_userRepository = userRepository;
// Base rules
RuleFor(x => x.FirstName)
.NotEmpty().WithMessage("First Name is required")
.MaximumLength(50).WithMessage("Maximum 50 characters allowed");
// Chained complex rules
RuleFor(x => x.Password)
.NotEmpty()
.MinimumLength(8)
.Matches("[A-Z]").WithMessage("Password must contain 1 uppercase letter")
.Matches("[0-9]").WithMessage("Password must contain 1 number");
// Cross-property validation
RuleFor(x => x.ConfirmPassword)
.Equal(x => x.Password).WithMessage("Passwords do not match");
// Complex business rules
RuleFor(x => x.DateOfBirth)
.LessThan(DateTime.Now.AddYears(-18))
.WithMessage("You must be at least 18 years old to register.");
// Async Database Validation via DI
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress()
.MustAsync(async (email, cancellation) => {
bool exists = await _userRepository.EmailExistsAsync(email);
return !exists; // Must return true to pass
})
.WithMessage("This email is already registered.");
// Validating nested objects
RuleFor(x => x.Address).SetValidator(new AddressValidator());
}
}
// Using DependencyInjectionExtensions makes this a one-liner.
// It scans the assembly, finds all classes inheriting AbstractValidator,
// and registers them in the DI container natively.
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
public class AccountController : Controller
{
private readonly IValidator<UserRegistrationDto> _validator;
public AccountController(IValidator<UserRegistrationDto> validator)
{
_validator = validator;
}
[HttpPost]
public async Task<IActionResult> Register(UserRegistrationDto model)
{
// 1. Execute Validation
var validationResult = await _validator.ValidateAsync(model);
// 2. Check Result
if (!validationResult.IsValid)
{
// 3. Map FluentValidation errors back to standard MVC ModelState
// This ensures standard Tag Helpers () still work!
foreach (var error in validationResult.Errors)
{
ModelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
return View(model);
}
// Proceed with registration...
return RedirectToAction("Success");
}
}
Writing that foreach loop in every controller violates DRY. Let's create an extension method to bridge FluentValidation and ASP.NET Core MVC instantly.
// Extensions/ValidationExtensions.cs
public static class ValidationExtensions
{
public static void AddToModelState(this FluentValidation.Results.ValidationResult result,
ModelStateDictionary modelState)
{
foreach (var error in result.Errors)
{
modelState.AddModelError(error.PropertyName, error.ErrorMessage);
}
}
}
// Controller usage shrinks to:
if (!validationResult.IsValid)
{
validationResult.AddToModelState(ModelState);
return View(model);
}
IValidator) rather than auto-validation packages, which obscure flow.CascadeMode = CascadeMode.Stop if you want validation to halt at the first failure on a specific property, saving database calls (e.g. don't check DB uniqueness if email is empty).MustAsync. Remember that validators block request execution.Q: "Why has the community moved away from FluentValidation's AutoValidation package in modern .NET?"
Architect Answer: "AutoValidation hooks deep into the ASP.NET Core MVC input formatters to validate models before they even hit the controller. While this sounds convenient, it causes two critical problems in enterprise apps. First, it makes it impossible to use asynchronous validation rules (like database lookups via MustAsync) because the MVC binding pipeline is fundamentally synchronous. Second, it hides the control flow. Modern .NET architecture prefers Explicit Validation — injecting IValidator<T> into a controller, service, or API Endpoint Filter — allowing full asynchronous context, better testability, and clear execution flow."