Validación en Minimal APIs de .NET: por qué cambiamos DataAnnotations por FluentValidation
Cuando migramos varios endpoints de un proyecto de controllers tradicionales a Minimal API en .NET, la validación de modelos fue el primer punto donde la migración no fue mecánica. En un controller decorado con [ApiController], el framework valida el modelo contra sus DataAnnotations automáticamente y devuelve un 400 con un ProblemDetails antes de que el código del action se ejecute. Minimal API no trae ese comportamiento implícito: no hay [ApiController] de por medio, así que la validación, si existe, hay que engancharla a mano. Esa diferencia nos obligó a decidir con qué reemplazarla, y ahí fue donde DataAnnotations dejó de alcanzarnos.
Dónde DataAnnotations se queda corto
DataAnnotations funciona bien para reglas por propiedad que no dependen de nada más: [Required], [MaxLength(100)], [EmailAddress]. El problema aparece con dos tipos de regla que en nuestros endpoints eran la norma, no la excepción.
La primera es la regla condicional: un campo solo es obligatorio si otro campo tiene determinado valor. En un endpoint de alta de envíos, NumeroSeguimiento es obligatorio si Estado es Enviado, pero no tiene sentido exigirlo si el pedido todavía está Pendiente. DataAnnotations no expresa eso de forma nativa; la alternativa es implementar IValidatableObject y escribir la condición a mano dentro de Validate, lo que en la práctica termina siendo código imperativo disperso entre atributos declarativos y un método con lógica de validación, dos estilos mezclados en la misma clase.
La segunda es la regla cross-field: comparar dos propiedades entre sí. FechaFin tiene que ser posterior a FechaInicio, o MontoDescuento no puede superar a MontoTotal. De nuevo, IValidatableObject es la única salida razonable, y de nuevo el resultado es una mezcla de dos paradigmas de validación conviviendo en el mismo modelo, sin que ninguno de los dos termine siendo realmente legible.
public class SolicitudEnvio : IValidatableObject
{
public string Estado { get; set; } = string.Empty;
public string? NumeroSeguimiento { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext context)
{
if (Estado == "Enviado" && string.IsNullOrWhiteSpace(NumeroSeguimiento))
{
yield return new ValidationResult(
"El número de seguimiento es obligatorio cuando el estado es Enviado.",
new[] { nameof(NumeroSeguimiento) });
}
}
}
Esto funciona para un caso aislado. No escala cuando el modelo tiene seis u ocho reglas de este tipo: el método Validate crece hasta volverse el lugar donde vive toda la lógica de negocio de validación, sin estructura, sin posibilidad de testear una regla por separado, y sin reutilización entre modelos que comparten una misma condición.
FluentValidation como reemplazo
FluentValidation separa la regla de validación del modelo. En vez de decorar propiedades, se define una clase AbstractValidator<T> que describe las reglas con una API fluida:
public class SolicitudEnvioValidator : AbstractValidator<SolicitudEnvio>
{
public SolicitudEnvioValidator()
{
RuleFor(x => x.Estado)
.NotEmpty()
.Must(e => new[] { "Pendiente", "Enviado", "Entregado" }.Contains(e))
.WithMessage("El estado debe ser Pendiente, Enviado o Entregado.");
RuleFor(x => x.NumeroSeguimiento)
.NotEmpty()
.When(x => x.Estado == "Enviado")
.WithMessage("El número de seguimiento es obligatorio cuando el estado es Enviado.");
RuleFor(x => x.FechaEntregaEstimada)
.GreaterThan(x => x.FechaCreacion)
.WithMessage("La fecha estimada de entrega debe ser posterior a la fecha de creación.");
}
}
La regla condicional (.When) y la regla cross-field (GreaterThan comparando dos propiedades del mismo objeto) son ciudadanos de primera clase de la API, no un caso especial que requiere salirse del sistema de atributos. Cada RuleFor es una unidad independiente que se puede testear sin instanciar un ValidationContext, algo que con IValidatableObject es más incómodo porque el método mezcla varias reglas en un mismo bloque.
Enganchar el validador al pipeline de Minimal API
Tener el validador no alcanza: hay que ejecutarlo antes de que el handler del endpoint corra, y devolver un 400 consistente si falla. La forma que terminamos usando es un IEndpointFilter, que en Minimal API cumple un rol equivalente al de un filtro de acción en controllers.
public class ValidationFilter<T> : IEndpointFilter
{
private readonly IValidator<T> _validator;
public ValidationFilter(IValidator<T> validator)
{
_validator = validator;
}
public async ValueTask<object?> InvokeAsync(
EndpointFilterInvocationContext context,
EndpointFilterDelegate next)
{
var entidad = context.Arguments.OfType<T>().FirstOrDefault();
if (entidad is null)
{
return Results.Problem("No se encontró el modelo a validar.", statusCode: 500);
}
var resultado = await _validator.ValidateAsync(entidad);
if (!resultado.IsValid)
{
return Results.ValidationProblem(resultado.ToDictionary());
}
return await next(context);
}
}
Results.ValidationProblem genera el mismo formato de ProblemDetails que produce [ApiController] en controllers tradicionales, así que del lado del cliente no hay ningún cambio de contrato al migrar. El registro del endpoint queda así:
app.MapPost("/envios", (SolicitudEnvio solicitud, IEnvioService service) =>
{
var envio = service.Crear(solicitud);
return Results.Created($"/envios/{envio.Id}", envio);
})
.AddEndpointFilter<ValidationFilter<SolicitudEnvio>>();
Y el registro de los validadores en el contenedor de dependencias, usando el paquete FluentValidation.DependencyInjectionExtensions:
builder.Services.AddValidatorsFromAssemblyContaining<SolicitudEnvioValidator>();
Esto escanea el ensamblado y registra cada AbstractValidator<T> como IValidator<T>, sin tener que dar de alta cada validador a mano.
Lo que no funcionó: validar dentro del handler
La primera versión que probamos no usaba IEndpointFilter. Inyectábamos IValidator<T> directamente en el handler y llamábamos a ValidateAsync como primera línea de cada endpoint:
app.MapPost("/envios", async (SolicitudEnvio solicitud, IValidator<SolicitudEnvio> validator, IEnvioService service) =>
{
var resultado = await validator.ValidateAsync(solicitud);
if (!resultado.IsValid)
{
return Results.ValidationProblem(resultado.ToDictionary());
}
var envio = service.Crear(solicitud);
return Results.Created($"/envios/{envio.Id}", envio);
});
Funcionalmente esto es correcto, pero en la práctica se volvió un problema de consistencia: con quince endpoints escritos por distintas personas, algunos llamaban a ValidateAsync al principio, otros a mitad de la función después de alguna consulta previa, y al menos dos endpoints se publicaron sin la llamada de validación porque nadie la agregó y nada en el pipeline lo exigía. No había forma de garantizar en revisión de código, con una mirada rápida, que un endpoint nuevo tuviera validación: había que leer el cuerpo completo del handler para confirmarlo.
Moverlo a un IEndpointFilter resolvió eso de raíz: la validación queda declarada en la firma del endpoint, junto al resto de sus filtros, visible sin abrir el cuerpo del handler. Un endpoint que recibe SolicitudEnvio y no tiene .AddEndpointFilter<ValidationFilter<SolicitudEnvio>>() es una omisión que salta a la vista en la revisión, en vez de un detalle que hay que verificar leyendo la lógica interna.
Qué quedó pendiente
FluentValidation no reemplaza validación de negocio que depende de estado externo, como comprobar que un NumeroSeguimiento no esté ya asignado a otro envío en la base de datos. Ese tipo de regla puede vivir en un validador con MustAsync inyectando el repositorio correspondiente, pero preferimos mantenerla fuera del validador y como parte explícita del servicio de aplicación, porque mezclar validación de formato con una consulta a base de datos dentro del mismo AbstractValidator complica el testeo unitario del validador: de repente necesita un mock de repositorio para probar una regla que en realidad es sobre el formato del campo. Esa separación -formato y estructura en el validador, reglas que dependen del estado del sistema en el servicio- es el criterio que seguimos aplicando a medida que agregamos endpoints nuevos.