En la primera parte cubrimos Data Annotations, el sistema de validación nativo de .NET. Funciona bien para reglas simples, pero cuando la lógica crece aparecen sus límites: no hay soporte async, las reglas cruzadas son complicadas y el modelo termina mezclando responsabilidades.
FluentValidation resuelve exactamente eso: valida tus DTOs con una API fluida, expresiva y completamente desacoplada del modelo.
¿Qué es FluentValidation?
FluentValidation es una librería open-source para .NET que te permite definir reglas de validación en clases dedicadas (AbstractValidator<T>) usando una interfaz fluida. Las reglas se leen casi como inglés natural, son fáciles de componer, fáciles de testear y se integran perfectamente con el pipeline de ASP.NET Core.
Instalación
# Para ASP.NET Core (MVC + Minimal APIs)
dotnet add package FluentValidation.AspNetCore
# Solo para proyectos de librería o consola
dotnet add package FluentValidation
Un validador en 8 líneas
using FluentValidation;
public sealed class CreateUserRequest
{
public string? Email { get; init; }
public string? Name { get; init; }
public int Age { get; init; }
}
public sealed class CreateUserRequestValidator : AbstractValidator<CreateUserRequest>
{
public CreateUserRequestValidator()
{
RuleFor(x => x.Email)
.NotEmpty()
.EmailAddress();
RuleFor(x => x.Name)
.NotEmpty()
.Length(2, 80);
RuleFor(x => x.Age)
.InclusiveBetween(13, 120);
}
}
Sin if-ladders, sin atributos mezclados en el modelo, sin magia implícita.
Registro en .NET 10 (Program.cs)
builder.Services.AddValidatorsFromAssemblyContaining<Program>();
builder.Services.AddProblemDetails();
Una sola línea escanea toda la assembly y registra todos los AbstractValidator<T> en el contenedor de DI.
Integración con Minimal APIs (.NET 10)
app.MapPost("/users", async (
CreateUserRequest req,
IValidator<CreateUserRequest> validator) =>
{
var result = await validator.ValidateAsync(req);
if (!result.IsValid)
return Results.ValidationProblem(
result.ToDictionary(),
statusCode: StatusCodes.Status400BadRequest);
// ... lógica de negocio
return Results.Created($"/users/{Guid.NewGuid():N}", req);
});
Integración con Controllers
[ApiController]
[Route("api/[controller]")]
public class UsersController : ControllerBase
{
private readonly IValidator<CreateUserRequest> _validator;
public UsersController(IValidator<CreateUserRequest> validator)
=> _validator = validator;
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateUserRequest request)
{
var result = await _validator.ValidateAsync(request);
if (!result.IsValid)
return ValidationProblem(result.ToDictionary());
return Created($"/api/users/{Guid.NewGuid()}", request);
}
}
Las reglas que más vas a usar
1. Reglas condicionales
RuleFor(x => x.Email)
.NotEmpty()
.When(x => string.IsNullOrWhiteSpace(x.Name));
2. Lógica cruzada con Must
RuleFor(x => x)
.Must(x => !(x.Age < 18 && x.Email?.EndsWith(".work") == true))
.WithMessage("Los menores no pueden registrarse con un email de trabajo.");
3. Validación de colecciones y validadores anidados
RuleForEach(x => x.Addresses).SetValidator(new AddressValidator());
4. RuleSets por contexto (crear vs actualizar)
RuleSet("Create", () =>
{
RuleFor(x => x.Email).NotEmpty().EmailAddress();
RuleFor(x => x.Password).NotEmpty().MinimumLength(12);
});
RuleSet("Update", () =>
{
RuleFor(x => x.Name).NotEmpty().Length(2, 80);
});
5. Validación async (consultas a la base de datos)
RuleFor(x => x.Email)
.MustAsync(async (email, ct) => !await repo.ExistsAsync(email, ct))
.WithMessage("Este email ya está registrado.");
6. Fail-fast con CascadeMode
RuleFor(x => x.Password)
.Cascade(CascadeMode.Stop)
.NotEmpty()
.MinimumLength(12)
.Matches("[A-Z]").WithMessage("Incluí al menos una mayúscula.")
.Matches("[0-9]").WithMessage("Incluí al menos un número.");
7. Extensiones personalizadas reutilizables
public static class CustomRules
{
public static IRuleBuilderOptions<T, string> NotDisposable<T>(
this IRuleBuilder<T, string> rule) =>
rule
.Must(v => v is not ("test@test.com" or "lorem@ipsum.com"))
.WithMessage("Usá un email real, no uno descartable.");
}
// Uso en el validador
RuleFor(x => x.Email).NotDisposable();
Testeando validadores
[Fact]
public async Task Validator_Falla_ConEmail_Trabajo_Menor()
{
var validator = new CreateUserRequestValidator();
var request = new CreateUserRequest { Age = 15, Email = "kid@company.work", Name = "Ana" };
var result = await validator.ValidateAsync(request);
Assert.False(result.IsValid);
Assert.Contains(result.Errors, e => e.ErrorMessage.Contains("menores"));
}
Los tests de validadores son baratos, rápidos y no necesitan ninguna dependencia externa.
Data Annotations vs FluentValidation: comparativa completa
| Característica | Data Annotations | FluentValidation |
|---|---|---|
| Dependencia externa | ❌ Ninguna (built-in) | ⚠️ NuGet package |
| Integración automática (controllers) | ✅ Automática con [ApiController] | ⚙️ Manual (IValidator<T>) |
| Integración Minimal APIs | ⚙️ Requiere filtro personalizado | ⚙️ Requiere IValidator<T> inyectado |
| Soporte async | ❌ No | ✅ Sí (MustAsync) |
| Reglas cruzadas (multi-campo) | ⚠️ Posible con IValidatableObject | ✅ Nativo con Must / When |
| Separación de responsabilidades | ⚠️ Reglas en el modelo | ✅ Clase validadora dedicada |
| RuleSets por contexto | ❌ No | ✅ Sí |
| Validadores anidados/colecciones | ⚠️ Limitado | ✅ SetValidator / RuleForEach |
| Fail-fast por campo | ❌ No | ✅ CascadeMode.Stop |
| Extensiones reutilizables | ⚠️ Atributos personalizados | ✅ Extension methods fluidos |
| Testabilidad | ⚠️ Indirecta (via framework) | ✅ Directa (instanciar validador) |
| Localización de mensajes | ⚠️ Posible con resources | ✅ Nativo |
| Curva de aprendizaje | ✅ Mínima | ⚠️ Baja pero requiere aprender la API |
| Verbosidad | ✅ Muy concisa (atributos) | ⚠️ Más código, más explícito |
| Ideal para | Proyectos simples, equipos pequeños | Proyectos medianos/grandes, APIs complejas |
¿Cuándo elegir cada uno?
Elegí Data Annotations cuando:
- Las reglas son simples y estáticas (required, length, range, email).
- Preferís cero dependencias externas.
- El proyecto es pequeño o el tiempo de desarrollo es corto.
- El equipo no está familiarizado con FluentValidation.
Elegí FluentValidation cuando:
- Necesitás validaciones async (verificar unicidad en DB, consultar servicios externos).
- Tenés reglas que involucran múltiples campos correlacionados.
- Querés validaciones distintas para crear vs actualizar el mismo modelo.
- La lógica de validación es suficientemente compleja como para merecer tests propios.
- Preferís mantener el modelo limpio, sin responsabilidades mixtas.
¿Se pueden usar juntos?
Sí. En proyectos grandes es común usar Data Annotations para validaciones básicas (como [Required] para que Swagger las detecte) y FluentValidation para las reglas de negocio complejas. No son mutuamente excluyentes.
Conclusión
No hay un ganador absoluto. Data Annotations brilla por su simplicidad y su integración nativa; FluentValidation brilla por su expresividad, su testabilidad y su capacidad para manejar lógica compleja.
La regla práctica es sencilla: empezá con Data Annotations, y cuando las reglas empiecen a crecer o necesites async, migrá a FluentValidation. El patrón de separación de validaciones en clases dedicadas escala mejor en proyectos de mediana y gran escala, y hace que el código sea más fácil de mantener y de entender para cualquier miembro del equipo.