Start Debugging

How to use Native AOT with ASP.NET Core minimal APIs

A complete .NET 11 walkthrough for shipping an ASP.NET Core minimal API with Native AOT: PublishAot, CreateSlimBuilder, source-generated JSON, the AddControllers limitation, IL2026 / IL3050 warnings, and EnableRequestDelegateGenerator for library projects.

To ship an ASP.NET Core minimal API with Native AOT on .NET 11, set <PublishAot>true</PublishAot> in the .csproj, build the host with WebApplication.CreateSlimBuilder instead of CreateBuilder, and register a JsonSerializerContext source generator through ConfigureHttpJsonOptions so every request and response type is reachable without reflection. Anything that is not minimal APIs or gRPC, including AddControllers, Razor, SignalR hubs, and EF Core query trees over POCO graphs, will produce IL2026 or IL3050 warnings at publish and behave unpredictably at runtime. This guide walks the full path on Microsoft.NET.Sdk.Web with .NET 11 SDK and C# 14, including the parts the new-project template hides from you, and ends with a checklist for verifying that the published binary actually does not need the JIT.

The two project flags that change everything

A Native AOT minimal API is a regular ASP.NET Core project with two MSBuild properties added. The first switches the publish path from CoreCLR to ILC, the AOT compiler. The second tells the analyzer to fail your build the moment you reach for an API that requires runtime code generation.

<!-- .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 does the heavy lifting. It enables Native AOT compilation during dotnet publish and, importantly, also turns on dynamic code analysis during build and editing, so IL2026 (RequiresUnreferencedCode) and IL3050 (RequiresDynamicCode) warnings light up in the IDE before you ever reach a publish. Microsoft documents this on the Native AOT deployment overview.

InvariantGlobalization is not strictly required, but I leave it on for new projects. Native AOT does not bundle the ICU data file by default on Linux, and a culture-aware string comparison over a request payload will throw CultureNotFoundException in production if you forget. Ship globalization explicitly when you actually need it.

The new-project template (dotnet new webapiaot) also adds <StripSymbols>true</StripSymbols> and <TrimMode>full</TrimMode> for you. TrimMode=full is implied by PublishAot=true, so it is redundant but harmless to keep.

CreateSlimBuilder is not CreateBuilder with a smaller name

The biggest behavioural change between a regular minimal API and an AOT one is the host builder. WebApplication.CreateBuilder wires up every common ASP.NET Core feature: HTTPS, HTTP/3, hosting filters, ETW, environment-variable based configuration providers, and a default JSON serializer that does reflection-based fallback. A lot of that machinery is not Native AOT compatible, so the AOT template uses CreateSlimBuilder, which is documented in the ASP.NET Core support for Native AOT reference and unchanged in .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;

Three things in that sample matter and are easy to miss:

  1. CreateSlimBuilder does not register HTTPS or HTTP/3 by default. The slim builder includes JSON file configuration for appsettings, user secrets, console logging, and logging configuration, but it intentionally drops protocols typically handled by a TLS termination proxy. If you run this thing without an Nginx, Caddy, or YARP in front, add Kestrel.Endpoints configuration explicitly.
  2. MapGroup("/todos") is fine in the same file as Program.cs. Move it to another file in the same project and you will start seeing IL3050 unless you also turn on the request delegate generator. We get to that in a moment.
  3. The JSON context inserts at index 0 in the resolver chain, so it takes precedence over the default reflection-based resolver. Without Insert(0, ...), ASP.NET Core’s response writer can still fall back to reflection for types you did not register, which produces a NotSupportedException at runtime in AOT mode.

JSON: the only serializer is the one you generate

System.Text.Json has two modes. Reflection mode walks every property at runtime, which is incompatible with both trimming and AOT. Source generation mode emits compile-time metadata for each registered type, which is fully AOT-safe. Native AOT requires source generation for every type you put in or pull out of an HTTP request body. This is the single biggest source of “compiles fine, throws at runtime” bugs.

The minimum viable JsonSerializerContext:

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

Every type that flows over the wire must be on this class, including the T[] and List<T> shapes you actually return from minimal API endpoints. ASP.NET Core’s response writer does not unwrap IEnumerable<T> for you in AOT mode. If you return Enumerable.Range(...).Select(...), register IEnumerable<Todo> as well or materialize it to an array first.

Three traps that bite even careful authors:

Andrew Lock’s tour of the minimal-API source generator and Martin Costello’s walkthrough on using JSON source generators with minimal APIs cover the original .NET 8 design that .NET 11 inherits unchanged.

Library projects need EnableRequestDelegateGenerator

The minimal API source generator turns each MapGet(...), MapPost(...), and so on into a strongly typed RequestDelegate at compile time. When PublishAot=true, the SDK enables this generator automatically for the web project. It does not enable it for library projects you reference, even if those libraries call MapGet themselves through extension methods.

The symptom is IL3050 warnings at publish that point at your library, complaining about MapGet doing reflection on a delegate. The fix is one MSBuild property in the library:

<!-- 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 turns on all four trim and AOT analyzers, and EnableRequestDelegateGenerator=true switches the library’s Map* calls to the generated path. Without the latter, the library can be marked AOT compatible and still emit IL3050 because of how the analyzer sees Delegate.DynamicInvoke style call sites in RouteHandlerBuilder. The dotnet/aspnetcore team tracks the rough edges in issue #58678.

If the library is supposed to be reusable on both AOT and non-AOT projects, leave the property in. The generator gracefully falls back to the runtime path on regular CoreCLR builds.

What you have to give up

Native AOT is not a switch you flip on a finished MVC monolith. The list of unsupported subsystems is short but load-bearing.

The Thinktecture team published a readable overview of supported and unsupported scenarios that I refer to when onboarding a team to Native AOT.

Reading IL2026 and IL3050 like a pro

The two warnings you will fight are easy to confuse:

Both are surfaced by the IsAotCompatible analyzer, but only IL2026 is shown by the trimming analyzer alone. I always run a one-shot publish to bin\publish from the command line during development to surface them all at once:

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

A second gotcha: dotnet/sdk discussion #51966 tracks a recurring issue where Visual Studio 2026 and dotnet build swallow IL2026 / IL3050 in some configurations, but dotnet format shows them. If your team uses Visual Studio, add a CI step that runs dotnet publish against the AOT runtime so a missed warning fails the pipeline.

When you cannot avoid a reflection-using API, you can suppress the warning at the call site with [RequiresUnreferencedCode] and [RequiresDynamicCode] attributes on the wrapping method, which propagates the requirement upwards. Do this only when you know the consuming code paths are not on the AOT publish surface. Suppressing inside an endpoint handler is almost always wrong.

Verifying the binary actually works

A clean publish does not prove the app starts under AOT. Three checks I run before declaring victory:

# 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

The third check is the important one. The classic failure mode is “compiles, publishes, starts, returns 500 on first request” because a return type is missing from the JSON context. Hit every endpoint at least once with a representative payload before you ship.

For container deployments, build with --self-contained true is implicit under PublishAot=true. The output ./publish/MyApi plus its .dbg file is the entire deploy unit. A typical .NET 11 minimal API lands at 8-12 MB stripped, compared to the 80-90 MB of a self-contained CoreCLR publish.

Sources

Comments

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

< Back