FluentValidation — what it is, why, and how I use it
Why I use FluentValidation over default DTO attribute validation, and how I actually wire it in — controller-level and service-level, with manual validation instead of auto-validation.
What is it
A .NET library for defining validation rules in code, separate from the model itself. Instead of decorating a DTO's properties with attributes, you write a dedicated validator class with fluent rule chains.
Packages to install
dotnet add package FluentValidation
dotnet add package FluentValidation.DependencyInjectionExtensions
I'm specifically using DependencyInjectionExtensions, notFluentValidation.AspNetCore. The AspNetCore package wires up automatic validation on model binding — validators run before my action method even executes. I don't want that; I call ValidateAsync() myself, so DependencyInjectionExtensions is the leaner, correct package for how I'm actually using this — it only gives me AddValidatorsFromAssembly() for DI registration.
Note: Install it in your application layer or whichever layer contains your DTOs.
Wire it up in Program.cs
Registers every validator in the given assembly automatically — no need to manually register each one:
builder.Services.AddValidatorsFromAssembly(typeof(RegisterDTO).Assembly);Why I use it over default DTO attribute validation
Data annotations ([Required], [MaxLength(50)], [EmailAddress]) work fine for simple cases, but they fall apart once rules get more specific:
- Cross-field rules are awkward. Something like "confirm_password must equal password" needs a custom
IValidatableObjector a custom attribute — FluentValidation just doesEqual(x => x.password)directly. - Rules live on the model itself. Attributes couple validation logic to the DTO class — FluentValidation keeps the DTO clean and puts all the rules in one dedicated validator class, easier to read and test in isolation.
- Custom regex + multiple chained rules per field are verbose with attributes, but read naturally as a fluent chain.
- Testability — a validator class can be unit tested directly (
new RegistrationDTOValidator().Validate(dto)) without spinning up the DTO through a controller pipeline.
The trade-off: it's another package, another concept to learn, and for a DTO with two simple required fields, plain attributes are genuinely less code. I reach for FluentValidation once a DTO has real business rules, not just "field must exist."
Real example — registration validator
using FluentValidation;
using EventFlow.Application.Common.RegexValidations;
namespace EventFlow.Application.DTO.App.Auth.ValidationHelper
{
public sealed class RegistrationDTOValidator : AbstractValidator<RegisterDTO>
{
public RegistrationDTOValidator()
{
RuleFor(x => x.email)
.NotEmpty().WithMessage("Email is required.")
.MaximumLength(50).WithMessage("Email must not exceed 50 characters.")
.EmailAddress().WithMessage("Invalid email format.")
.Matches(@"^[^@\s]+@[^@\s]+\.[^@\s]+$").WithMessage("Invalid email format.");
RuleFor(x => x.password)
.NotEmpty().WithMessage("Password is required.")
.MinimumLength(6).WithMessage("Password must be at least 6 characters long.")
.MaximumLength(50).WithMessage("Password must not exceed 50 characters.")
.Matches(RegexValidations.PasswordRegex)
.WithMessage("Password must contain at least one uppercase letter, one lowercase letter, one digit, and one special character.");
RuleFor(x => x.confirm_password)
.Equal(x => x.password).WithMessage("Passwords do not match.");
RuleFor(x => x.first_name)
.NotEmpty().WithMessage("First name is required.")
.MaximumLength(50).WithMessage("First name must not exceed 50 characters.");
RuleFor(x => x.last_name)
.NotEmpty().WithMessage("Last name is required.")
.MaximumLength(50).WithMessage("Last name must not exceed 50 characters.");
}
}
}Two ways I use it — controller vs service
In the controller, injected directly via [FromServices]:
[HttpPost("register")]
public async Task<IActionResult> Register(
[FromBody] RegisterDTO register_dto,
CancellationToken cancellationToken,
[FromServices] IValidator<RegisterDTO> validator)
{
var result = await validator.ValidateAsync(register_dto);
if (!result.IsValid)
{
throw new AppException(result.Errors.FirstOrDefault()?.ErrorMessage);
}
var request_context = HttpContext.GetRequestContext();
var response = await _auth_service.RegisterAsync(register_dto, request_context, cancellationToken);
return StatusCode(response.StatusCode, response);
}In the service, injected through the constructor instead:
// validating DTO with fluent validation
var result = await registration_validator.ValidateAsync(registration_dto);
if (!result.IsValid)
{
throw new AppException(result.Errors.FirstOrDefault()?.ErrorMessage);
}Which one I actually use: controller-level validation for the "reject bad input immediately" case, so the service layer can assume it's always receiving valid data. Validating again in the service is redundant if the controller already gatekeeps it — I'd only validate in both places if the service can also be called from somewhere that bypasses the controller (a background job, a different entry point).
Why manual validation instead of auto-validation
FluentValidation.AspNetCore's automatic pipeline integration would validate on model binding and short-circuit before my action method runs. I'm using FluentValidation.DependencyInjectionExtensions instead — just DI registration, no auto-validation — because I want control over the exact failure shape: throwing AppException with a
specific message, not whatever the automatic pipeline would return.
That's a deliberate choice, not something I stumbled into.