Start Debugging

Migrar uma minimal API de verificações de validação manuais para a validação integrada no ASP.NET Core 11

Guia passo a passo para substituir verificações manuais (if) nos handlers de uma minimal API do ASP.NET Core 11 pelo validador integrado baseado em DataAnnotations e geração de código-fonte: o que quebra, quais regras manuais dá para portar e quais não, e como verificar que o contrato 400 ProblemDetails continua idêntico.

Se a sua minimal API do ASP.NET Core é anterior ao .NET 10, provavelmente cada handler começa com um bloco de verificações if (string.IsNullOrWhiteSpace(...)) return Results.BadRequest(...). Esta migração substitui essas verificações feitas à mão pela validação integrada que chegou no .NET 10 e permanece inalterada no .NET 11: chame builder.Services.AddValidation(), mova as regras para os seus records de requisição como atributos DataAnnotations e apague as guardas manuais. Para um serviço típico de 15 a 40 endpoints, conte com meio dia a um dia de trabalho mecânico. O que quebra não é o framework, são as suas suposições: algumas das suas regras manuais faziam coisas que os atributos não conseguem expressar (consultas assíncronas, verificações entre serviços), e o formato da sua resposta de erro pode mudar se você montava payloads BadRequest à mão em vez de retornar ProblemDetails. A migração vale a pena porque apaga código, torna a validação declarativa e reutilizável, e permanece segura para o recorte (trim) sob Native AOT. Ela também é reversível endpoint por endpoint, então você pode fazê-la de forma incremental. Tudo abaixo tem como alvo o .NET 11 com Microsoft.NET.Sdk.Web e C# 14; o recurso é idêntico no .NET 10, onde apareceu pela primeira vez.

Por que abandonar as verificações manuais

O padrão manual funciona, então o argumento para migrar é sobre manutenibilidade e correção, não “o jeito antigo está quebrado”:

O que quebra

O framework em si não quebra, mas estas cinco coisas mudam, e você precisa saber quais das suas regras manuais sobrevivem ao porte:

ÁreaMudançaSeveridade
Corpo da resposta de erroCorpos de string simples ou BadRequest(...) personalizados viram ProblemDetails (RFC 9457) com chave por propriedadealta
Regras assíncronas / de BDif (await repo.SkuExists(...)) não pode virar atributo; precisa de outro lugaralta
Acessibilidade do tipo de requisiçãoO record deve ser public ou o gerador não emite nada e a validação silenciosamente não rodaalta
Regras entre camposBlocos if de várias propriedades vão para IValidatableObject, não para atributosmédia
Testes que verificam o texto do erroTestes que verificam suas antigas strings de mensagem personalizadas vão falhar contra o novo ProblemDetailsmédia
Tipo de retorno do handlerOs handlers podem remover seu braço BadRequest; o filtro produz o 400 antes de o handler rodarbaixa

As duas linhas de severidade alta que decidem o formato da sua migração são o corpo do erro e as regras assíncronas. Se os clientes analisam o corpo 400 atual, planeje uma mudança de contrato ou uma camada de compatibilidade. Se alguma verificação manual aguarda E/S, essa regra não vira atributo de forma alguma, e fingir o contrário é o jeito mais comum de essa migração dar errado.

Lista de verificação prévia

Antes de tocar em um handler:

Passos da migração

1. Ative a validação integrada

Registre os serviços antes de construir a app. Este é o único encanamento global que a migração precisa.

// .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();

Com o web SDK atual do .NET 10 ou 11 o gerador de código-fonte fica ativo automaticamente assim que AddValidation() está presente. Se você copiou um .csproj recortado ou o seu build é anterior à GA, adicione o namespace do interceptor explicitamente:

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

Verificar: a app ainda compila e inicia. dotnet build reporta zero erros. Nenhum comportamento mudou ainda porque nenhum dos seus tipos carrega atributos.

2. Mova as regras por campo para o record de requisição

Pegue um endpoint. Leia suas verificações manuais e traduza cada regra expressável como atributo para um atributo DataAnnotations no tipo de requisição. Aqui está o antes, um handler carregando sua validação inline:

// .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);

E o depois: as regras vão para o record como atributos, o handler perde seu bloco de guarda, e o tipo vira public para que o gerador possa vê-lo.

// .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);

Repare que o tipo virou public. Essa é a razão mais comum de um endpoint migrado parar de validar silenciosamente: o gerador só pode emitir código para tipos que ele consegue nomear, então um record deixado com a acessibilidade interna ao arquivo padrão não recebe validação nem erro.

Verificar: curl -i -X POST .../products -d '{"sku":"x","name":"","quantity":0}' retorna 400 com um corpo ProblemDetails listando Sku, Name e Quantity. Um corpo válido ainda retorna 201.

3. Mova as regras entre campos para IValidatableObject

Os atributos validam um membro por vez. Qualquer verificação manual que comparava dois campos (“fim depois do início”, “desconto exige um motivo”) não vira atributo. Implemente IValidatableObject no record de requisição no 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
        }
    }
}

O array de nomes de membro que você passa como segundo argumento controla sob qual chave o erro cai, então passe o campo que a sua UI deve destacar. Validate roda depois das verificações de atributo, então um Start em null já é reportado por [Required] antes de a sua lógica entre campos rodar.

Verificar: poste um intervalo onde End <= Start e confirme que a chave do erro é End, não uma chave vazia no nível do modelo.

4. Realoque as verificações assíncronas e entre serviços

Este é o passo que o backlog de migração do seu grep de await alimenta. Uma regra como “rejeitar se o SKU já existe no catálogo” bate no banco de dados, e nem DataAnnotations nem IValidatableObject conseguem aguardar. Você tem duas opções honestas:

Mantenha essa verificação específica como uma chamada explícita dentro do handler, depois de o validador integrado já ter garantido que o formato é válido:

// .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);
});

Ou, se você tem muitas regras assíncronas, mantenha o FluentValidation exatamente para esses tipos de requisição e deixe o recurso integrado cuidar dos simples. As vantagens e desvantagens estão detalhadas em validação de minimal API vs FluentValidation; a versão curta é que regras assíncronas e lógica condicional rica são as duas razões para manter uma biblioteca de validação. Não tente forçar uma chamada a DbContext dentro de um IValidatableObject; ela é síncrona e vai ou bloquear uma thread ou empurrar você para o território de deadlock com .Result.

Verificar: a verificação de unicidade ainda rejeita um SKU duplicado (agora como um 409, ou o status que você escolher), e um SKU inédito ainda tem sucesso.

5. Reutilize uma regra com um ValidationAttribute personalizado

Se a mesma verificação manual aparecia em três handlers (“esta data não pode estar no passado”), não copie um IValidatableObject em três records. Escreva um ValidationAttribute e o gerador o captura como qualquer 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 é a resposta baseada em atributos aos blocos if duplicados: a regra vive uma vez e se aplica onde quer que o tipo seja usado.

Verificar: um endpoint que antes repetia a mesma verificação de data agora rejeita uma data passada com o atributo, e os demais endpoints que compartilham o tipo herdam a regra.

6. Apague o código de guarda morto e simplifique os tipos de retorno

Uma vez que as regras de um endpoint são declarativas, remova o bloco if manual por completo, e simplifique o tipo de retorno declarado do handler. Como o 400 é produzido pelo filtro de endpoint antes de o corpo do handler rodar, um handler não precisa mais de um braço BadRequest na sua união 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: o OpenAPI ainda anuncia o 400 (via ProducesValidationProblem), e o endpoint compila sem o braço de união removido.

Verificação

Depois de migrar um lote de endpoints, passe por esta lista antes de dar por concluído:

Plano de rollback

Esta migração é reversível e, melhor ainda, é incremental, então você raramente precisa de um rollback completo. Dois níveis:

Pegadinhas que enfrentamos

Problemas reais que custaram tempo, e suas soluções:

O modelo mental para levar embora: esta migração não é adotar um framework novo, é apagar código de guarda imperativo e reexpressar as mesmas regras de forma declarativa sobre os seus tipos de requisição. As regras expressáveis como atributo vão para DataAnnotations, as entre campos para IValidatableObject, as reutilizáveis para um ValidationAttribute personalizado, e as assíncronas ficam explícitas no handler depois de o formato já estar garantido como válido. Faça uma de cada vez, verifique cada uma com a chamada de requisição inválida, e o código repetitivo de blocos if que abria cada handler simplesmente some.

Relacionados

Fontes

Comments

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

< Voltar