← maurobernal.com.ar

FluentValidation en .NET 10 vs Data Annotations: Cual Elegir y Por Que

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.