Skip to main content

11. NativeAOT and trimming

This note covers the NativeAOT opt-in path for the C# shim assembly. NativeAOT compiles the shim to a native shared library with no CLR runtime dependency, eliminating the CLR startup overhead at the cost of requiring the dep graph to be AOT-compatible.

What NativeAOT provides

NativeAOT (dotnet publish -r <rid> -p:PublishAot=true) is a .NET publishing mode that:

  1. Runs the IL trimmer to remove all unreachable code (dead code elimination).
  2. Compiles the surviving IL to native machine code via LLVM (on Linux/macOS) or MSVC (on Windows).
  3. Produces a self-contained native shared library: libshim.so (Linux), libshim.dylib (macOS), shim.dll (Windows).
  4. Does not require a CLR or .NET SDK installation on the target machine.

For the bridge, NativeAOT produces a shim that is loadable via dlopen / LoadLibrary without any CLR initialisation step. The [UnmanagedCallersOnly] entry points are standard native exports in the shared library.

AOT-compatibility constraints

NativeAOT imposes constraints that break some .NET programming patterns:

  • No reflection over dynamically loaded types. Type.GetType("Fully.Qualified.Name") fails at AOT time if the type is not statically reachable. Many configuration and dependency injection frameworks use runtime reflection.
  • No System.Reflection.Emit. Dynamic IL generation (used by some ORM and serialisation libraries to generate fast accessor code) is not supported.
  • No runtime code generation. Expression trees compiled via Expression.Compile() are not supported by default; the trimmer removes the compile path.
  • No dynamic keyword. The dynamic CLR type uses System.Dynamic.ExpandoObject and reflection-based dispatch.
  • Limited Assembly.Load. Loading assemblies at runtime from arbitrary paths is not supported.

The .NET ecosystem has been adopting AOT-compatible patterns since .NET 7, but progress varies by package.

AOT compatibility analysis of the 20-package corpus

Analysis from the April 2026 fixture corpus (tested with dotnet publish -r linux-x64 -p:PublishAot=true -p:TrimmerRootDescriptor=AllPublicTypes.xml):

PackageVersionAOT-compatibleIssues
System.Text.Json8.0.0YesThe System.Text.Json source generator path is AOT-compatible; the reflection path is not.
Serilog3.1.1PartialCore is AOT-compatible; most sinks use reflection for configuration.
Polly8.3.0YesPure functional policies; no reflection.
FluentValidation11.9.0PartialProperty access via expressions; requires [DynamicDependency] annotations.
NUnit4.1.0NoTest discovery uses Assembly.GetTypes() and Activator.CreateInstance().
xUnit2.7.0NoSame as NUnit.
Bogus35.3.0PartialMost generators work; locale loading uses Assembly.GetManifestResourceStream.
Newtonsoft.Json13.0.3NoExtensive use of System.Reflection.Emit for fast accessor generation.
Dapper2.1.28NoGenerates IL for mapping SQL rows to objects via ILGenerator.
AutoMapper13.0.1NoUses Expression.Compile() for mapping functions.
MediatR12.2.0PartialCore pipeline is AOT-compatible; handlers discovered via reflection.
RestSharp110.2.0PartialHTTP client core is AOT-compatible; serialisation uses reflection by default.
FluentAssertions6.12.0NoExtensive reflection for assertion message generation.
Moq4.20.70NoMock generation uses System.Reflection.Emit.
Microsoft.Extensions.DependencyInjection8.0.0YesThe source-generator path is AOT-compatible.
Microsoft.Extensions.Http8.0.0YesWraps HttpClient; no reflection.
StackExchange.Redis2.7.23PartialCore is AOT-compatible; some serialisation helpers use reflection.
Npgsql8.0.3YesPostgreSQL provider has AOT-compatible mode since 8.0.
EntityFramework Core8.0.3PartialCompiled queries are AOT-compatible; dynamic queries use ILGenerator.
AWSSDK.Core3.7.200NoService client generation uses runtime Type.GetType and Activator.CreateInstance.

Summary: 4 fully AOT-compatible, 8 partially compatible, 8 not AOT-compatible. The partially-compatible packages require package-specific [DynamicDependency] annotations in the shim or use of the AOT-compatible API surface only.

The IsAotCompatible NuGet metadata property

NuGet packages can declare AOT compatibility via the <IsAotCompatible>true</IsAotCompatible> property in their .csproj:

<PropertyGroup>
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

This sets the build_metadata.PackageReference.IsAotCompatible NuGet item metadata that consumers can check. The bridge reads this property from the package's .nuspec / .props files and uses it as an advisory hint.

The bridge's lock-time AOT check:

$ mochi pkg lock --bridge=nativeaot
[1/3] Resolving packages ...
[2/3] Checking AOT compatibility:
System.Text.Json 8.0.0: IsAotCompatible=true (advisory)
Newtonsoft.Json 13.0.3: IsAotCompatible=false
Dapper 2.1.28: IsAotCompatible=false (no property declared, heuristic: ILGenerator usage detected)
[3/3] WARNING: 2 packages are not AOT-compatible.
Consider switching to AOT-compatible alternatives:
- Newtonsoft.Json: use System.Text.Json source generators
- Dapper: use Dapper.AOT (AOT-compatible fork)
Or switch to CLR hosting: [dotnet] bridge = "clr-hosting"
Proceeding with partial AOT (AOT-incompatible types will be skipped).

Trimming roots and [DynamicDependency]

The IL trimmer requires explicit roots (types/methods that must be kept) for types that are only referenced via runtime strings (reflection). The bridge generates a trimmer roots descriptor for each [UnmanagedCallersOnly] entry:

<!-- dotnet_shim/Serilog/TrimmerRoots.xml -->
<linker>
<assembly fullname="SerilogShim">
<type fullname="SerilogShim.SerilogShimEntry" preserve="All" />
</assembly>
</linker>

For types accessed via [DynamicDependency], the shim adds the attribute:

[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Serilog.Core.Logger))]
public static class SerilogShimEntry { ... }

This preserves the trimmer root for Logger even though it is accessed via a string at runtime.

CLR startup cost trade-off

The CLR hosting API (hostfxr_initialize_for_runtime_config) incurs a startup cost:

ScenarioStartup cost
CLR hosting, cold start (no .NET runtime pre-loaded)150-300 ms
CLR hosting, warm start (runtime already in memory)20-50 ms
NativeAOT, cold start1-5 ms (library loading via dlopen)
NativeAOT, warm start<1 ms

For long-running processes (web servers, daemons), the CLR startup cost is paid once at process startup and amortised across the lifetime. For short-lived CLI tools that make one or two .NET calls, the CLR startup cost (150-300 ms) is noticeable.

For short-lived tools where startup time matters and the dep graph is AOT-compatible, [dotnet] bridge = "nativeaot" eliminates the CLR overhead.

Activation

The NativeAOT path is activated via mochi.toml:

[dotnet]
bridge = "nativeaot"
runtime = "linux-x64" # required for NativeAOT (must specify target RID)

The lock step checks AOT compatibility and warns or errors as configured by [dotnet.nativeaot] compat-mode:

  • "warn" (default): warn on incompatible packages, proceed.
  • "error": fail lock on any incompatible package.
  • "skip": silently skip incompatible packages; they receive no shim.

The build step compiles the shim via dotnet publish -r <runtime> -p:PublishAot=true -p:StripSymbols=true.

Cross-references