Start Debugging

Como usar Native AOT com APIs mínimas do ASP.NET Core

Um passo a passo completo para .NET 11 que envia uma API mínima do ASP.NET Core com Native AOT: PublishAot, CreateSlimBuilder, JSON com gerador de código-fonte, a limitação do AddControllers, avisos IL2026 / IL3050 e EnableRequestDelegateGenerator para projetos de biblioteca.

Para enviar uma API mínima do ASP.NET Core com Native AOT no .NET 11, ponha <PublishAot>true</PublishAot> no .csproj, construa o host com WebApplication.CreateSlimBuilder em vez de CreateBuilder, e registre um gerador de código-fonte JsonSerializerContext via ConfigureHttpJsonOptions para que cada tipo de requisição e resposta seja alcançável sem reflexão. Qualquer coisa que não seja API mínima ou gRPC, incluindo AddControllers, Razor, hubs do SignalR e árvores de consulta do EF Core sobre grafos de POCO, vai produzir avisos IL2026 ou IL3050 ao publicar e se comportar de forma imprevisível em runtime. Este guia caminha pelo trajeto inteiro em Microsoft.NET.Sdk.Web com .NET 11 SDK e C# 14, incluindo as partes que o template do projeto novo esconde de você, e termina com um checklist para verificar se o binário publicado realmente não precisa do JIT.

As duas flags de projeto que mudam tudo

Uma API mínima Native AOT é um projeto regular do ASP.NET Core com duas propriedades MSBuild adicionadas. A primeira troca o caminho de publicação do CoreCLR para o ILC, o compilador AOT. A segunda diz ao analisador para falhar seu build no momento em que você toca uma API que precisa de geração de código em runtime.

<!-- .NET 11, C# 14 -->
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>

    <PublishAot>true</PublishAot>
    <InvariantGlobalization>true</InvariantGlobalization>
  </PropertyGroup>
</Project>

PublishAot faz o trabalho pesado. Habilita a compilação Native AOT durante o dotnet publish e, importante, também liga a análise de código dinâmico durante o build e a edição, para que avisos IL2026 (RequiresUnreferencedCode) e IL3050 (RequiresDynamicCode) acendam na IDE antes mesmo de você chegar a um publish. A Microsoft documenta isso na visão geral de deployment do Native AOT.

InvariantGlobalization não é estritamente necessário, mas eu o deixo ligado em projetos novos. O Native AOT não embute o arquivo de dados ICU por padrão no Linux, e uma comparação de string sensível a cultura sobre um payload de requisição vai lançar CultureNotFoundException em produção se você esquecer. Envie globalização explicitamente quando realmente precisar.

O template de projeto novo (dotnet new webapiaot) também adiciona <StripSymbols>true</StripSymbols> e <TrimMode>full</TrimMode> para você. TrimMode=full é implicado por PublishAot=true, então é redundante mas inofensivo manter.

CreateSlimBuilder não é CreateBuilder com nome menor

A maior mudança de comportamento entre uma API mínima regular e uma AOT é o host builder. WebApplication.CreateBuilder cabeia toda feature comum do ASP.NET Core: HTTPS, HTTP/3, filtros de hosting, ETW, provedores de configuração baseados em variáveis de ambiente, e um serializador JSON padrão que faz fallback baseado em reflexão. Boa parte dessa maquinária não é compatível com Native AOT, então o template AOT usa CreateSlimBuilder, documentado na referência de suporte do ASP.NET Core a Native AOT e inalterado no .NET 11.

// .NET 11, C# 14
// PackageReference: Microsoft.AspNetCore.OpenApi 11.0.0
using System.Text.Json.Serialization;

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

var app = builder.Build();

var todos = app.MapGroup("/todos");
todos.MapGet("/", () => Todo.Sample);
todos.MapGet("/{id:int}", (int id) =>
    Todo.Sample.FirstOrDefault(t => t.Id == id) is { } t
        ? Results.Ok(t)
        : Results.NotFound());

app.Run();

public record Todo(int Id, string Title, bool Done)
{
    public static readonly Todo[] Sample =
    [
        new(1, "Try Native AOT", true),
        new(2, "Profile cold start", false),
    ];
}

[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(Todo[]))]
internal partial class AppJsonContext : JsonSerializerContext;

Três coisas naquela amostra importam e são fáceis de perder:

  1. CreateSlimBuilder não registra HTTPS nem HTTP/3 por padrão. O slim builder inclui configuração via arquivo JSON para appsettings, user secrets, log de console e configuração de logging, mas deliberadamente deixa de lado protocolos tipicamente tratados por um proxy de terminação TLS. Se você roda isso sem um Nginx, Caddy ou YARP na frente, adicione configuração Kestrel.Endpoints explicitamente.
  2. MapGroup("/todos") está bem no mesmo arquivo que Program.cs. Mova-o para outro arquivo no mesmo projeto e você vai começar a ver IL3050 a menos que também ligue o gerador de delegate de requisição. Chegamos lá num instante.
  3. O context JSON insere no índice 0 na cadeia do resolver, então tem precedência sobre o resolver baseado em reflexão padrão. Sem Insert(0, ...), o writer de resposta do ASP.NET Core ainda pode cair para reflexão para tipos que você não registrou, o que produz uma NotSupportedException em runtime no modo AOT.

JSON: o único serializador é o que você gera

System.Text.Json tem dois modos. O modo de reflexão percorre cada propriedade em runtime, o que é incompatível tanto com trimming quanto com AOT. O modo de geração de código-fonte emite metadados em tempo de compilação para cada tipo registrado, o que é totalmente seguro para AOT. Native AOT exige geração de código-fonte para cada tipo que você coloca em ou tira de um corpo de requisição HTTP. Essa é a maior fonte de bugs do tipo “compila legal, lança em runtime”.

O JsonSerializerContext mínimo viável:

// .NET 11, C# 14
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
[JsonSerializable(typeof(Todo))]
[JsonSerializable(typeof(Todo[]))]
[JsonSerializable(typeof(List<Todo>))]
[JsonSerializable(typeof(ProblemDetails))]
internal partial class AppJsonContext : JsonSerializerContext;

Todo tipo que cruza o fio precisa estar nessa classe, incluindo as formas T[] e List<T> que você de fato retorna de endpoints de API mínima. O writer de resposta do ASP.NET Core não desembrulha IEnumerable<T> para você no modo AOT. Se você retorna Enumerable.Range(...).Select(...), registre IEnumerable<Todo> também ou materialize para um array antes.

Três armadilhas que mordem mesmo autores cuidadosos:

O tour de Andrew Lock pelo gerador de código-fonte da API mínima e o passo a passo de Martin Costello sobre usar geradores JSON com APIs mínimas cobrem o design original do .NET 8 que o .NET 11 herda inalterado.

Projetos de biblioteca precisam de EnableRequestDelegateGenerator

O gerador de código-fonte da API mínima transforma cada MapGet(...), MapPost(...) e por aí vai em um RequestDelegate fortemente tipado em tempo de compilação. Quando PublishAot=true, o SDK habilita esse gerador automaticamente para o projeto web. Ele não habilita para projetos de biblioteca que você referencia, mesmo que essas bibliotecas chamem MapGet por meio de métodos de extensão.

O sintoma são avisos IL3050 ao publicar apontando para sua biblioteca, reclamando que MapGet faz reflexão em um delegate. A correção é uma propriedade MSBuild na biblioteca:

<!-- Library project that defines endpoint extension methods -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net11.0</TargetFramework>
    <IsAotCompatible>true</IsAotCompatible>
    <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator>
  </PropertyGroup>
</Project>

IsAotCompatible=true liga os quatro analisadores de trim e AOT, e EnableRequestDelegateGenerator=true troca as chamadas Map* da biblioteca para o caminho gerado. Sem o segundo, a biblioteca pode ser marcada como compatível com AOT e ainda emitir IL3050 por causa de como o analisador enxerga call sites estilo Delegate.DynamicInvoke em RouteHandlerBuilder. O time do dotnet/aspnetcore acompanha as quinas em issue #58678.

Se a biblioteca precisa ser reusável em projetos AOT e não-AOT, deixe a propriedade. O gerador cai graciosamente para o caminho de runtime em builds CoreCLR regulares.

Do que você abre mão

Native AOT não é um interruptor que você ativa em um monolito MVC pronto. A lista de subsistemas não suportados é curta mas estruturante.

O time da Thinktecture publicou uma visão legível dos cenários suportados e não suportados à qual recorro ao fazer onboarding de um time em Native AOT.

Lendo IL2026 e IL3050 com profissionalismo

Os dois avisos com que você vai lutar são fáceis de confundir:

Os dois são detectados pelo analisador IsAotCompatible, mas só IL2026 é exibido pelo analisador de trimming sozinho. Eu sempre rodo um publish pontual para bin\publish da linha de comando durante o desenvolvimento para tirá-los todos à tona de uma vez:

dotnet publish -c Release -r linux-x64 -o ./publish

Uma segunda armadilha: dotnet/sdk discussion #51966 acompanha um problema recorrente em que o Visual Studio 2026 e dotnet build engolem IL2026 / IL3050 em algumas configurações, mas dotnet format os mostra. Se seu time usa Visual Studio, adicione um passo de CI que rode dotnet publish contra o runtime AOT para que um aviso perdido derrube a pipeline.

Quando você não conseguir evitar uma API que usa reflexão, pode suprimir o aviso no call site com os atributos [RequiresUnreferencedCode] e [RequiresDynamicCode] no método que envolve, o que propaga a exigência para cima. Faça isso somente quando você sabe que os caminhos de código consumidores não estão na superfície de publish do AOT. Suprimir dentro de um endpoint handler é quase sempre errado.

Verificando que o binário realmente funciona

Um publish limpo não prova que o app inicia sob AOT. Três checagens que rodo antes de cantar vitória:

# 1. The output is a single static binary, not a CoreCLR loader.
ls -lh ./publish
file ./publish/MyApi
# Expected on Linux: "ELF 64-bit LSB pie executable ... statically linked"

# 2. The runtime never loads the JIT.
LD_DEBUG=libs ./publish/MyApi 2>&1 | grep -E "libcoreclr|libclrjit"
# Expected: empty output. If libclrjit.so loads, you accidentally shipped a runtime fallback.

# 3. A real request round-trips with the source generator.
./publish/MyApi &
curl -s http://localhost:5000/todos | head -c 200

A terceira checagem é a importante. O modo de falha clássico é “compila, publica, inicia, retorna 500 na primeira requisição” porque um tipo de retorno está faltando do context JSON. Bata em cada endpoint pelo menos uma vez com um payload representativo antes de enviar.

Para deploys em container, build com --self-contained true é implícito sob PublishAot=true. A saída ./publish/MyApi mais o arquivo .dbg é a unidade de deploy inteira. Uma API mínima típica do .NET 11 aterrissa em 8-12 MB stripped, comparado aos 80-90 MB de um publish CoreCLR self-contained.

Guias relacionados no Start Debugging

Fontes

Comments

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

< Voltar