Start Debugging

Migrar una minimal API de comprobaciones de validación manuales a la validación integrada en ASP.NET Core 11

Guía paso a paso para reemplazar las comprobaciones manuales (if) en los handlers de una minimal API de ASP.NET Core 11 por el validador integrado basado en DataAnnotations y generación de código fuente: qué se rompe, qué reglas manuales se pueden portar y cuáles no, y cómo verificar que el contrato 400 ProblemDetails no cambia.

Si tu minimal API de ASP.NET Core es anterior a .NET 10, es probable que cada handler empiece con un bloque de comprobaciones if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...). Esta migración reemplaza esas comprobaciones hechas a mano por la validación integrada que llegó en .NET 10 y se mantiene sin cambios en .NET 11: llama a builder.Services.AddValidation(), mueve las reglas a tus records de solicitud como atributos DataAnnotations y elimina las guardas manuales. Para un servicio típico de 15 a 40 endpoints, cuenta con medio día o un día de trabajo mecánico. Lo que se rompe no es el framework, son tus suposiciones: algunas de tus reglas manuales hacían cosas que los atributos no pueden expresar (consultas asíncronas, comprobaciones entre servicios), y la forma de tu respuesta de error puede cambiar si construías payloads BadRequest a mano en lugar de devolver ProblemDetails. La migración vale la pena porque elimina código, hace la validación declarativa y reutilizable, y se mantiene segura para el recorte (trim) bajo Native AOT. Además es reversible endpoint por endpoint, así que puedes hacerla de forma incremental. Todo lo de abajo apunta a .NET 11 con Microsoft.NET.Sdk.Web y C# 14; la característica es idéntica en .NET 10, donde apareció por primera vez.

Por qué abandonar las comprobaciones manuales

El patrón manual funciona, así que el argumento para migrar es sobre mantenibilidad y corrección, no “la forma antigua está rota”:

Qué se rompe

El framework en sí no se rompe, pero estas cinco cosas cambian, y necesitas saber cuáles de tus reglas manuales sobreviven al porte:

ÁreaCambioSeveridad
Cuerpo de respuesta de errorLos cuerpos de cadena simple o BadRequest(...) personalizados pasan a ProblemDetails (RFC 9457) con clave por propiedadalta
Reglas asíncronas / de BDif (await repo.SkuExists(...)) no puede convertirse en atributo; necesita otro lugaralta
Accesibilidad del tipo de solicitudEl record debe ser public o el generador no emite nada y la validación silenciosamente no se ejecutaalta
Reglas entre camposLos bloques if de varias propiedades pasan a IValidatableObject, no a atributosmedia
Tests que verifican el texto del errorLos tests que verifican tus antiguas cadenas de mensaje personalizadas fallarán contra el nuevo ProblemDetailsmedia
Tipo de retorno del handlerLos handlers pueden eliminar su rama BadRequest; el filtro produce el 400 antes de que el handler se ejecutebaja

Las dos filas de severidad alta que deciden la forma de tu migración son el cuerpo del error y las reglas asíncronas. Si los clientes analizan el cuerpo 400 actual, planifica un cambio de contrato o una capa de compatibilidad. Si alguna comprobación manual espera E/S, esa regla no se convierte en atributo en absoluto, y pretender lo contrario es la forma más común en que esta migración sale mal.

Lista de verificación previa

Antes de tocar un handler:

Pasos de migración

1. Activa la validación integrada

Registra los servicios antes de construir la app. Este es el único cableado global que necesita la migración.

// .NET 11, C# 14 -- Program.cs
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddValidation();   // registers the validation services and endpoint filter
var app = builder.Build();

Con el web SDK actual de .NET 10 u 11 el generador de código fuente se activa automáticamente en cuanto está presente AddValidation(). Si copiaste un .csproj recortado o tu build es anterior a la GA, añade el espacio de nombres del interceptor explícitamente:

<!-- only needed if the generator's interceptors are not picked up automatically -->
<PropertyGroup>
  <InterceptorsNamespaces>$(InterceptorsNamespaces);Microsoft.AspNetCore.Http.Validation.Generated</InterceptorsNamespaces>
</PropertyGroup>

Verificar: la app sigue compilando y arrancando. dotnet build reporta cero errores. Todavía no ha cambiado ningún comportamiento porque ninguno de tus tipos lleva atributos.

2. Mueve las reglas por campo al record de solicitud

Toma un endpoint. Lee sus comprobaciones manuales y traduce cada regla expresable como atributo a un atributo DataAnnotations sobre el tipo de solicitud. Aquí está el antes, un handler que lleva su validación en línea:

// .NET 11, C# 14 -- BEFORE: manual checks
app.MapPost("/products", (CreateProduct product) =>
{
    if (string.IsNullOrWhiteSpace(product.Sku) || product.Sku.Length is < 3 or > 20)
        return Results.BadRequest("Sku must be 3 to 20 characters.");
    if (string.IsNullOrWhiteSpace(product.Name) || product.Name.Length < 2)
        return Results.BadRequest("Name is required and must be at least 2 characters.");
    if (product.Quantity is < 1 or > 10_000)
        return Results.BadRequest("Quantity must be between 1 and 10000.");

    return Results.Created($"/products/{product.Sku}", product);
});

public record CreateProduct(string Sku, string Name, int Quantity);

Y el después: las reglas se mueven al record como atributos, el handler pierde su bloque de guarda, y el tipo se hace public para que el generador pueda verlo.

// .NET 11, C# 14 -- AFTER: declarative validation
using System.ComponentModel.DataAnnotations;

app.MapPost("/products", (CreateProduct product) =>
    TypedResults.Created($"/products/{product.Sku}", product));

public record CreateProduct(
    [Required, Length(3, 20)] string Sku,
    [Required, MinLength(2)] string Name,
    [Range(1, 10_000)] int Quantity);

Fíjate en que el tipo pasó a ser public. Esta es la razón más común de que un endpoint migrado deje de validar silenciosamente: el generador solo puede emitir código para tipos que puede nombrar, así que un record dejado con la accesibilidad interna al archivo por defecto no obtiene validación ni error.

Verificar: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' devuelve 400 con un cuerpo ProblemDetails que lista Sku, Name y Quantity. Un cuerpo válido sigue devolviendo 201.

3. Mueve las reglas entre campos a IValidatableObject

Los atributos validan un miembro a la vez. Cualquier comprobación manual que compare dos campos (“fin después de inicio”, “descuento requiere una razón”) no se convierte en atributo. Implementa IValidatableObject en el record de solicitud en su lugar:

// .NET 11, C# 14 -- cross-field rule ported from a manual if-block
using System.ComponentModel.DataAnnotations;

public record DateRange(
    [Required] DateOnly Start,
    [Required] DateOnly End) : IValidatableObject
{
    public IEnumerable<ValidationResult> Validate(ValidationContext context)
    {
        if (End <= Start)
        {
            yield return new ValidationResult(
                "End must be after Start.",
                [nameof(End)]);   // attaches the error to the End member
        }
    }
}

El arreglo de nombres de miembro que pasas como segundo argumento controla bajo qué clave cae el error, así que pasa el campo que tu UI debería resaltar. Validate se ejecuta después de las comprobaciones de atributo, así que un Start en null ya lo reporta [Required] antes de que se ejecute tu lógica entre campos.

Verificar: envía un rango donde End <= Start y confirma que la clave del error es End, no una clave vacía a nivel de modelo.

4. Reubica las comprobaciones asíncronas y entre servicios

Este es el paso al que alimenta el backlog de migración de tu grep de await previo. Una regla como “rechazar si el SKU ya existe en el catálogo” golpea la base de datos, y ni DataAnnotations ni IValidatableObject pueden esperar. Tienes dos opciones honestas:

Mantén esa comprobación específica como una llamada explícita dentro del handler, después de que el validador integrado ya haya garantizado que la forma es válida:

// .NET 11, C# 14 -- async rule stays explicit, but shape is pre-validated
app.MapPost("/products", async (CreateProduct product, IProductRepository repo, CancellationToken ct) =>
{
    // Built-in validation already ran: Sku is non-null, 3-20 chars, Quantity in range.
    if (await repo.SkuExistsAsync(product.Sku, ct))
        return Results.Conflict($"A product with SKU {product.Sku} already exists.");

    await repo.AddAsync(product, ct);
    return TypedResults.Created($"/products/{product.Sku}", product);
});

O, si tienes muchas reglas asíncronas, mantén FluentValidation para exactamente esos tipos de solicitud y deja que la característica integrada se encargue de los simples. Las ventajas y desventajas están detalladas en validación de minimal API vs FluentValidation; la versión corta es que las reglas asíncronas y la lógica condicional rica son las dos razones para mantener una biblioteca de validación. No intentes forzar una llamada a DbContext dentro de un IValidatableObject; es síncrono y o bien bloqueará un hilo o te empujará al territorio del interbloqueo con .Result.

Verificar: la comprobación de unicidad sigue rechazando un SKU duplicado (ahora como un 409, o el estado que elijas), y un SKU nuevo sigue teniendo éxito.

5. Reutiliza una regla con un ValidationAttribute personalizado

Si la misma comprobación manual aparecía en tres handlers (“esta fecha no puede estar en el pasado”), no copies un IValidatableObject en tres records. Escribe un ValidationAttribute y el generador lo recoge como cualquier atributo integrado:

// .NET 11, C# 14
using System.ComponentModel.DataAnnotations;

public sealed class NotInPastAttribute : ValidationAttribute
{
    protected override ValidationResult? IsValid(object? value, ValidationContext context)
    {
        if (value is DateOnly date && date < DateOnly.FromDateTime(DateTime.UtcNow.Date))
        {
            return new ValidationResult(
                ErrorMessage ?? "Date cannot be in the past.",
                [context.MemberName!]);
        }
        return ValidationResult.Success;
    }
}

public record Booking([Required, NotInPast] DateOnly When);

Esta es la respuesta basada en atributos a los bloques if duplicados: la regla vive una vez y se aplica dondequiera que se use el tipo.

Verificar: un endpoint que antes repetía la misma comprobación de fecha ahora rechaza una fecha pasada con el atributo, y los demás endpoints que comparten el tipo heredan la regla.

6. Elimina el código de guarda muerto y simplifica los tipos de retorno

Una vez que las reglas de un endpoint son declarativas, elimina el bloque if manual por completo, y simplifica el tipo de retorno declarado del handler. Como el 400 lo produce el filtro de endpoint antes de que se ejecute el cuerpo del handler, un handler ya no necesita una rama BadRequest en su unión Results<...>:

// .NET 11, C# 14 -- return type no longer needs BadRequest
app.MapPost("/products", (CreateProduct product)
    => TypedResults.Created($"/products/{product.Sku}", product))
   .Produces<CreateProduct>(StatusCodes.Status201Created)
   .ProducesValidationProblem();   // documents the 400 the filter produces

Verificar: OpenAPI sigue anunciando el 400 (vía ProducesValidationProblem), y el endpoint compila sin la rama de unión eliminada.

Verificación

Después de migrar un lote de endpoints, repasa esta lista antes de darla por terminada:

Plan de reversión

Esta migración es reversible y, mejor aún, es incremental, así que rara vez necesitas una reversión completa. Dos niveles:

Problemas que nos encontramos

Problemas reales que costaron tiempo, y sus soluciones:

El modelo mental con el que quedarte: esta migración no es adoptar un framework nuevo, es eliminar código de guarda imperativo y volver a expresar las mismas reglas de forma declarativa sobre tus tipos de solicitud. Las reglas expresables como atributo pasan a DataAnnotations, las de entre campos a IValidatableObject, las reutilizables a un ValidationAttribute personalizado, y las asíncronas se quedan explícitas en el handler después de que la forma ya esté garantizada como válida. Hazla un endpoint a la vez, verifica cada uno con la llamada de solicitud inválida, y el código repetitivo de bloques if que abría cada handler simplemente desaparece.

Relacionados

Fuentes

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Volver