The Bonus Round: Publishing Tiles & Tokens with Native AOT

October 29, 2026The Bonus Round: Publishing Tiles & Tokens with Native AOT
Sarah Dutkiewicz

Sarah Dutkiewicz, Senior Trainer

This post is part 6 of a 6-part series on domain modeling with C# 15 and .NET 11, told through a small board game.

We thought part 5 was the end of this series. Then the demo asked one more question.

In part 4, we went out of our way to keep saving and loading friendly to trimming: a source-generated JsonSerializerContext, a contract modifier built on the same API the source generator emits, and a library marked IsAotCompatible. A trimmed publish came back with zero warnings. The obvious next question was: does it go all the way to Native AOT?

That’s a fair test for the C# 15 features in this series. Unions and closed hierarchies are compile-time features, so in theory they should be fine. But “in theory” is how this whole series started, so let’s check.

This post is a little different from the others. The companion repo’s native-aot tag doesn’t add any code - the code is exactly what shipped with part 5. What it adds is the record of the publish: what failed, what worked, and what wasn’t verified. It’s all in docs/feature-status.md, and it was run on the .NET 11 RC1 SDK (11.0.100-rc.1.26425.128) on October 2, 2026.

A Quick Refresher on Native AOT

Native AOT compiles your app ahead of time into a single native executable. There’s no JIT at runtime, which means faster startup and no need for the .NET runtime on the target machine. In exchange:

  • The app is always trimmed. Code the compiler can’t prove you use is removed.
  • No runtime code generation. Anything that builds types or code on the fly at runtime won’t work.
  • Reflection needs to be knowable at compile time. Reflection over types the compiler can see is fine. Reflection over “whatever shows up at runtime” isn’t.

Two kinds of warnings tell you when you’ve crossed those lines, and you’ll see both in this post:

  • IL2026 - the code calls something marked [RequiresUnreferencedCode], so it may break when trimmed.
  • IL3050 - the code calls something marked [RequiresDynamicCode], so it may break without a JIT.

Attempt 1: Windows

The first try was the obvious one, on the Windows dev machine:

dotnet publish src/TilesAndTokens.Demo -c Release -r win-x64 -p:PublishAot=true

It stopped at the very last step, linking:

vswhere.exe failed to locate Visual Studio with Microsoft.VisualStudio.Component.VC.Tools.x86.x64

That’s not a code problem. Native AOT hands its output to the platform’s native linker, and on Windows that means the Visual Studio C++ build tools (the Desktop development with C++ workload). The dev machine didn’t have them. If you hit this, install the workload - or do what we did next.

Attempt 2: The AOT SDK Container

Microsoft publishes .NET SDK container images with the native toolchain already installed, tagged -aot. The publish moved to Linux, in the official RC1 image:

mcr.microsoft.com/dotnet/sdk:11.0.100-rc.1-aot

The source was a git archive of the post-5 commit, so the container saw exactly what’s in the repo. The command:

dotnet publish src/TilesAndTokens.Demo -c Release -r linux-x64 -p:PublishAot=true

As committed, the publish fails

Eight errors: 4 × IL2026 and 4 × IL3050.

Here’s why they’re errors and not warnings. Setting PublishAot turns on the AOT analyzers in every project the app references, not just the app. The repo has TreatWarningsAsErrors turned on everywhere - on purpose, so a missing switch case fails the build during the talk. So every AOT warning in any referenced project fails the publish too.

The headline, though: none of the eight are in the part 4 serialization code, and none are in the domain code with the unions and closed hierarchies. They come from three places:

1. The legacy “old way” converter. This is the hand-written JsonConverter from part 4, kept in the repo on purpose to show what we were moving away from. It calls the reflection-based JsonSerializer overloads:

writer.WritePropertyName("card");
JsonSerializer.Serialize(writer, card, options);
action.Card = root.GetProperty("card").Deserialize<Card>(options);

Those overloads can’t promise the metadata for Card will survive trimming, so they’re marked with both attributes. That’s a fitting result: the part 4 “before” code fails AOT, and the “after” code doesn’t.

2. EF Core itself. TilesAndTokensDbContext gets flagged because EF Core declares that it isn’t fully compatible with Native AOT. That’s EF Core being upfront, not a bug in our model.

3. One anonymous type in a query. In part 5, the Postgres GIN query builds its JSON fragments like this:

private static string EdgeTile(PlayerId player, string axis, int value) =>
    JsonSerializer.Serialize(new { Tiles = new[] { new { OwnerId = player.Value, Position = new Dictionary<string, int> { [axis] = value } } } });

An anonymous type can’t be listed in a JsonSerializerContext, so this uses reflection-based serialization. It’s a small, easy fix - a couple of named types in a source-generated context - but it’s a good reminder that one convenient line can be the thing that blocks a publish.

All three came in with parts 4 and 5: the legacy converter as a “before” example, and the EF Core demo.

Diagnostic run: what does the whole-program compiler think?

To see past the analyzer errors, the publish was run once more with warnings allowed through. This is for diagnosis only - it’s not how you’d want to ship:

dotnet publish src/TilesAndTokens.Demo -c Release -r linux-x64 -p:PublishAot=true -p:TreatWarningsAsErrors=false

It published a 35.8 MB native linux-x64 executable.

The interesting part is what the AOT compiler (ILC) reported, because it’s different from what the analyzers reported:

SourceAnalyzers (per project)ILC (whole program)
Domain: unions, closed hierarchies, turn engineNothingNothing
Serialization: source-generated context, union workaroundNothingNothing
Legacy JSON converterIL2026, IL3050Nothing - it’s unreachable
TilesAndTokensDbContext, SavedGameQueriesIL2026, IL3050Warnings
EfCoreDemo-Warnings
EF Core and Npgsql packagesNot analyzed (they’re packages, not projects in the repo)IL2104, IL3053 - “this assembly produced trim/AOT warnings”

Why did the legacy converter disappear? The analyzers look at each project on its own and flag every risky call they find. ILC looks at the whole app and only analyzes code that’s actually reachable from Main. The demo never uses the legacy converter - only the legacy tests do - so ILC trims it away and has nothing to warn about.

That’s a useful distinction for your own projects: analyzer warnings tell you what could break in a library; ILC warnings tell you what will be in this app. For a library, fix the analyzer warnings. For an app, ILC’s list is the one that matters at runtime.

Does the Native Binary Actually Work?

Warnings are predictions. The real test is running it.

  • The json demo works natively. It plays, saves, prints the JSON, loads it back - every GameAction case and the VictoryCondition restored - and resumes play. Exit code 0, and output identical to the JIT run.
  • The save file is 3,976 bytes instead of 4,236. The only difference is line endings: indented JSON written on Linux uses \n, and on Windows it uses \r\n.
  • all --no-pause runs parts 1 through 4 natively. The efcore demo skips cleanly, because there’s no database in the container.

So everything from part 1 through part 4 - the legacy anti-patterns, the unions, the closed hierarchies, and the union-aware save and load - runs as a native binary.

What Wasn’t Verified: EF Core

The EF Core path from part 5 was not run under Native AOT. EF Core says it isn’t fully AOT compatible, and getting there would mean a compiled model and precompiled queries, generated at build time with the EF tooling. That’s a project of its own, and it was out of scope here. If your app needs EF Core under Native AOT, start with the EF Core documentation on that topic and expect to test it carefully against your provider.

Why the C# 15 Features Passed

It’s worth stepping back and asking why the domain code came through without a single warning.

  • Unions are lowered to plain structs. As we saw in part 2, a union is a struct with one constructor per case and an object Value property. Pattern matching over it is a series of type checks. There’s no reflection and no runtime code generation involved.
  • Closed hierarchies are ordinary abstract classes. closed is enforced by the compiler. At runtime, a closed record is an abstract type with protected constructors and an attribute.
  • Exhaustiveness is a compile-time check. CS8509 happens before any IL exists. Nothing about it shows up at runtime.
  • The serialization layer was built for this. The part 4 code uses a source-generated context in metadata mode, registers its union classifier through [JsonSourceGenerationOptions], and builds its "kind" property with JsonMetadataServices.CreatePropertyInfo<T> instead of the reflection-based JsonTypeInfo.CreateJsonPropertyInfo. Each of those choices was made for trimming in part 4, and each one paid off here.

In other words: the new language features are AOT-friendly by design, and System.Text.Json is AOT-friendly if you stay on the source-generated path. The places that broke were the places that reached for reflection.

If You Wanted a Clean Publish

The repo doesn’t ship a clean Native AOT publish of the whole demo, and it doesn’t need to - the demo’s job is to show the series, including the “before” code and the EF Core experiments. But if this were a real app, here’s where the findings point:

  1. Keep “before” code out of the AOT app. The legacy converter only exists for comparison. Unreachable code doesn’t hurt ILC, but it still trips the per-project analyzers when TreatWarningsAsErrors is on.
  2. Replace the anonymous type with named types in a source-generated JsonSerializerContext.
  3. Treat EF Core under AOT as its own decision. Either do the compiled-model and precompiled-query work and test it, or keep data access in a JIT-hosted service and make the AOT piece something smaller, like a CLI or a worker that doesn’t touch the database.
  4. Get the native toolchain into CI. The Windows failure was a missing workload, not a code problem. An -aot SDK container in a CI job makes the publish repeatable for everyone on the team.

Those are recommendations from the findings, not changes that were made in the repo.

Takeaway

C# 15 unions and closed hierarchies, plus System.Text.Json on the source-generated path, compiled to a working native binary on .NET 11 RC1 with no warnings from the domain or serialization code. What failed the build was the reflection-based “before” converter, an anonymous type, and EF Core - which tells you it isn’t fully AOT compatible up front. And the gap between the analyzers and ILC is worth knowing: the analyzers flag what might break, and ILC flags what’s actually in your app.

Thanks for Playing

Now that’s the series! Over six posts, Tiles & Tokens went from string flags and null-heavy wrappers to unions, closed hierarchies, union-aware save files, nested complex types in Postgres, and a native binary. The pattern that held up the whole way: put the rules in the type system, and let the edges of your app adapt to it.

Here’s where to go from here:

  • The code: NimblePros/tiles-and-tokens-net-11, with a tag for every post. docs/feature-status.md records exactly what was verified on which SDK. When .NET 11 ships, the repo will be re-verified against the GA bits.
  • The talk: this series is based on my 30-minute session on C# 15 and .NET 11 domain modeling. If you’d like it at your company or event, let us know.
  • Training and consulting: if your team is planning a move to .NET 11, wants to adopt unions and closed hierarchies, or is weighing Native AOT for a real app, NimblePros can help. Contact us today!

No board game experience required.