Uma Minimal API .NET publicada com Native AOT inicia em menos de 10 milissegundos e ocupa menos de 15 MB em disco. A mesma API com o runtime JIT tradicional leva 200-400ms para iniciar e carrega um runtime de 60-80 MB. Em ambientes serverless, Kubernetes com autoescaling agressivo, ou containers com cold start frequente, essa diferença é real e impacta custo e latência.
Native AOT não é magia — é uma troca deliberada: você abre mão de algumas funcionalidades que dependem de reflection dinâmica em troca de startup ultra-rápido, menor uso de memória e binário autocontido sem dependência de runtime instalado.
JIT vs AOT: a diferença fundamental
O modelo tradicional do .NET compila C# para IL (Intermediate Language) no build, e o runtime JIT (Just-In-Time) compila IL para código nativo da máquina quando o código é executado pela primeira vez. Isso permite otimizações dinâmicas baseadas no hardware real e suporte a reflection completo, mas significa que cada processo começa com um período de aquecimento onde o JIT ainda está compilando.
O Native AOT compila tudo para código nativo no momento do build — no ambiente de CI/CD, não na máquina do usuário. O resultado é um binário executável que não depende do runtime .NET instalado, não tem JIT warm-up, e tem uso de memória significativamente menor. A desvantagem é que algumas funcionalidades que dependem de inspeção de tipos em runtime — reflection não anotada, serialização dinâmica, carregamento de assemblies — não funcionam ou exigem anotações extras.
Habilitando Native AOT na Minimal API
<!-- MyApi.csproj -->
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<!-- Habilita Native AOT na publicação -->
<PublishAot>true</PublishAot>
<!-- Remove debugging info para binário menor em produção -->
<StripSymbols>true</StripSymbols>
<!-- Habilita invariant globalization (menor footprint, sem dados de cultura) -->
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
</Project>
// Program.cs — Minimal API compatível com AOT
// Regra principal: sem reflection dinâmica. Tudo deve ser conhecido em compile time.
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args); // CreateSlimBuilder: versão AOT-friendly
// System.Text.Json com Source Generation — necessário para AOT
// Gera código de serialização em compile time, sem reflection em runtime
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
builder.Services.AddScoped<IOrderRepository, OrderRepository>();
var app = builder.Build();
var ordersGroup = app.MapGroup("/api/orders");
ordersGroup.MapGet("/{id:guid}", async (Guid id, IOrderRepository repo) =>
{
var order = await repo.GetByIdAsync(id);
return order is null ? Results.NotFound() : Results.Ok(order);
});
ordersGroup.MapPost("/", async (CreateOrderRequest request, IOrderRepository repo) =>
{
var order = await repo.CreateAsync(request);
return Results.Created($"/api/orders/{order.Id}", order);
});
ordersGroup.MapGet("/", async (IOrderRepository repo) =>
Results.Ok(await repo.GetAllAsync()));
app.Run();
// JsonSerializerContext com Source Generation — OBRIGATÓRIO para AOT
// Lista explicitamente todos os tipos que serão serializados/desserializados
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(CreateOrderRequest))]
[JsonSerializable(typeof(List<Order>))]
[JsonSerializable(typeof(IEnumerable<Order>))]
internal partial class AppJsonContext : JsonSerializerContext { }
# Publicação AOT para Linux x64 (típico para containers)
dotnet publish -r linux-x64 -c Release
# Resultado em publish/MyApi:
# Binário único, autocontido, ~12-18 MB
# Não requer .NET runtime instalado no container
# Dockerfile para imagem mínima
# Dockerfile AOT — imagem final sem runtime .NET
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
# Instala ferramentas necessárias para cross-compilation
RUN apt-get update && apt-get install -y clang zlib1g-dev
COPY ["MyApi.csproj", "."]
RUN dotnet restore
COPY . .
RUN dotnet publish -r linux-x64 -c Release -o /app/publish
# Imagem final: apenas o binário, sem runtime .NET
FROM mcr.microsoft.com/dotnet/runtime-deps:9.0
WORKDIR /app
COPY --from=build /app/publish .
EXPOSE 8080
ENTRYPOINT ["./MyApi"]
Limitações e como contorná-las
Reflection dinâmica: qualquer código que use Type.GetMethod(), Activator.CreateInstance() ou similar sem anotação AOT vai falhar em runtime. A solução é o atributo [DynamicallyAccessedMembers] que informa ao compilador AOT quais membros serão acessados, ou reescrever o código para evitar reflection.
System.Text.Json sem Source Generation: o JsonSerializer usa reflection para descobrir propriedades. Com AOT, você precisa do JsonSerializerContext como mostrado acima — o compilador gera código de serialização em build time.
AutoMapper e bibliotecas similares: AutoMapper usa reflection extensivamente e não é compatível com AOT sem configuração especial. A alternativa é Mapperly (usa Source Generators) ou mapeamento manual.
Entity Framework Core: EF Core tem suporte experimental a AOT no .NET 9, mas com restrições. Para APIs com AOT, Dapper é frequentemente mais prático — é mais simples e tem menos dependência de reflection.
// Exemplo com Dapper em API AOT — simples e sem friction
public class OrderRepository : IOrderRepository
{
private readonly string _connectionString;
public OrderRepository(IConfiguration config)
=> _connectionString = config.GetConnectionString("Default")!;
public async Task<Order?> GetByIdAsync(Guid id)
{
await using var conn = new NpgsqlConnection(_connectionString);
// Dapper usa reflection para mapeamento, mas de forma compatível com AOT
// em .NET 9 quando os tipos são conhecidos
return await conn.QueryFirstOrDefaultAsync<Order>(
"SELECT * FROM orders WHERE id = @id AND is_deleted = false",
new { id });
}
}
Benchmark: JIT vs AOT em números reais
| Métrica | JIT (.NET 9) | Native AOT (.NET 9) | Diferença |
|---|---|---|---|
| Startup time | ~250ms | ~8ms | 30x mais rápido |
| Tamanho do binário | ~80MB (com runtime) | ~14MB | 6x menor |
| Uso de memória (idle) | ~60MB RSS | ~25MB RSS | 2.4x menor |
| Throughput (req/s) | Baseline (após warm-up) | ~95-98% do JIT | Praticamente igual |
| Latência p99 (estável) | Baseline | ~105% do JIT | Levemente pior |
O throughput e latência em estado estacionário são praticamente equivalentes entre JIT e AOT. O JIT tem vantagem em workloads de longa duração porque pode fazer otimizações adaptativas baseadas em padrões de execução reais. O AOT tem vantagem clara em cold start e footprint de memória.
Quando Native AOT compensa
Compensa:** serverless functions (AWS Lambda, Azure Functions) onde cold start impacta diretamente a latência do usuário; microsserviços com autoescaling frequente que passam por muitos cold starts; CLIs e ferramentas que precisam iniciar rapidamente; ambientes com memória muito restrita (IoT, edge computing).
Geralmente não compensa: APIs monolíticas com instâncias sempre ativas (o JIT eventualmente supera o AOT em throughput após warm-up); projetos que dependem fortemente de reflection (ORMs com proxies, containers de DI com convenções dinâmicas, serialização polimórfica complexa); quando a equipe não tem familiaridade com as limitações AOT e o custo de migração supera o benefício.
O .NET Aspire suporta Native AOT — se você usa o AppHost para orquestrar seus serviços, a migração de um serviço para AOT é transparente para os outros serviços na topologia.
Decidir entre JIT e AOT depende do perfil de workload do seu produto. Se você quer uma análise técnica dos trade-offs para o seu contexto específico, a Neryx pode ajudar.