AOT Publishing

::: warning Status: in progress. Wolverine 6.0 sets up the AOT story but the full per-file annotation pass is still landing — see the AOT-pillar tracking issue. The guidance below describes the target end state plus the safe escape hatches that work today. :::

Wolverine 6.0 introduces first-class support for publishing applications with Native AOT (dotnet publish /p:PublishAot=true) and aggressive trimming. The story is two-part:

  1. At build time — pre-generate handler dispatch code via dotnet run -- codegen write, then ship that pre-generated code as ordinary, statically-analyzable C# inside your app’s Internal/Generated/ folder.
  2. At runtime — configure Wolverine in TypeLoadMode.Static so the host loads the pre-generated types instead of compiling them at boot. With Static mode in effect, Wolverine’s Roslyn-based AssemblyGenerator is never invoked, and the AOT compiler / trimmer have everything they need statically.

Why this matters

Wolverine’s default TypeLoadMode.Dynamic (and Auto) configurations compile handler dispatch via JasperFx.RuntimeCompiler on host startup. That path:

  • Loads Microsoft.CodeAnalysis.CSharp (~6 MB Roslyn binaries) into your process
  • Emits and JIT-compiles new types at runtime — fundamentally incompatible with Native AOT
  • Reflects over handler / saga / middleware types in ways the trimmer can’t follow statically

TypeLoadMode.Static shifts all of that to build time. Production binaries published with Static mode + pre-generated code do not carry Roslyn at all, start faster, and pass dotnet publish /p:PublishAot=true without IL2026 / IL3050 warnings outside the explicitly-annotated runtime-codegen surface.

Walkthrough

1. Pre-generate handler code

In your project root, run:

dotnet run -- codegen write

This invokes the JasperFx command-line surface that Wolverine extends. The command discovers handlers / sagas / middleware in the configured assemblies, runs the same code-generation passes the host would run at startup, and writes the resulting C# into Internal/Generated/ (or wherever opts.CodeGeneration.GeneratedCodeOutputPath points). The files are normal C# — check them in, run them through the trimmer, set breakpoints in them.

::: tip codegen write is the same command that’s been available in earlier Wolverine versions for dev-time inspection. The 6.0 difference is that the output is byte-stable and round-trips into TypeLoadMode.Static — i.e. you can rely on the pre-generated files being the authoritative dispatch code, not just a debugging aid. :::

2. Configure TypeLoadMode.Static

In your application’s Program.cs:

builder.Host.UseWolverine(opts =>
{
    opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static;
    // … the rest of your configuration
});

TypeLoadMode.Static tells Wolverine “the pre-generated handler types are in this assembly; load them by name and skip the runtime compile step entirely.” If a handler is missing pre-generated code, host startup fails with a clear error pointing at the missing file — so the failure mode is “loud and at startup” rather than “silent fallback to Roslyn.”

3. Publish AOT

Reference PublishAot in your csproj or pass it on the command line:

dotnet publish -c Release /p:PublishAot=true

A clean AOT-published Wolverine app in Static mode emits no IL2026 / IL3050 warnings from Wolverine itself today against the AOT-clean subset of the surface (Envelope, WolverineOptions, DeliveryOptions, the scheduling helpers — what the Wolverine.AotSmoke regression-guard project exercises).

::: warning Until the AOT-pillar tracking issue (#2746) is fully closed out, Wolverine APIs outside the AOT-clean subset above may emit IL warnings under PublishAot=true. The warnings are informative — they tell you which Wolverine APIs require dynamic code / aren’t trim-safe. The Static-mode runtime path avoids most of them; the rest get annotated as the pillar work progresses. :::

What Wolverine.AotSmoke already guarantees

The Wolverine.AotSmoke project in the Wolverine repo sets IsAotCompatible=true, TrimMode=full, and promotes the IL2026/IL2046/IL2055/IL2065/IL2067/IL2070/IL2072/IL2075/IL2090/IL2091/IL2111/IL3050/IL3051 warning codes to errors. Every PR runs it via the aot workflow.

Currently exercised AOT-clean surface (will fail the smoke build if any of these APIs gains a [RequiresDynamicCode] / [RequiresUnreferencedCode] annotation):

  • Envelope construction + value-shape (Id, Headers, CorrelationId, MessageType, …)
  • Envelope.TryGetHeader fast path
  • Envelope.SetMessageType<T> closed-generic
  • Envelope.SetMetricsTag
  • Envelope scheduling helpers (ScheduleAt, ScheduleDelayed, IsScheduledForLater, IsExpired)
  • DeliveryOptions construction
  • WolverineOptions construction
  • WolverineOptions.DetermineSerializer (dictionary lookup)

As the per-file annotation pass in #2746 progresses, the smoke project expands to exercise the newly-annotated entry points — tightening the regression guard incrementally.

Surfaces that intentionally aren’t AOT-clean

A handful of Wolverine APIs require runtime codegen by design. They carry [RequiresDynamicCode] / [RequiresUnreferencedCode] annotations so the trimmer surfaces a clear warning if your AOT-published app reaches them:

  • HandlerGraph.Compile and its callees — runtime Roslyn codegen of handler dispatch. Static-mode apps don’t reach this.
  • Reflective handler / saga / transport discovery (HandlerDiscovery.IncludeAssembly type-scan, the saga-type-descriptor StartingMessages reflection, transport IMessageRoutingConvention reflection). Static-mode + a pre-generated handler manifest avoid these.
  • MakeGenericType / Activator.CreateInstance driven factories — the few that still exist in core.
  • The WolverineFx.Newtonsoft companion package (Newtonsoft.Json itself isn’t AOT-friendly; if you need Newtonsoft, you’re not on the AOT path).

If you publish AOT in Dynamic mode (the default), expect warnings from these surfaces — they tell you what to migrate before you can ship a working AOT binary.

Cold-start side benefit

Even without publishing AOT, TypeLoadMode.Static produces meaningful cold-start improvements: the host doesn’t pay the per-handler Roslyn compile cost on first message. The CritterStackScalability coldstart wolverine harness measures this against the V5.39.0 baseline.

Pre-generated static registries

codegen write also emits a GeneratedHandlerRegistry (under Internal/Generated/WolverineHandlers/) that captures the discovered handler types as a compile-time typeof(...) array. In TypeLoadMode.Static, Wolverine consumes this registry at startup and skips conventional handler discovery’s assembly scan entirely — there’s no Assembly.ExportedTypes walk and no convention filtering across your assemblies. Handler-method selection still runs over exactly the registry’s types, so behavior is identical to a full scan.

This is on by default in TypeLoadMode.Static. Dynamic-mode apps that still want to skip the scan can opt in explicitly:

builder.Host.UseWolverine(opts =>
{
    opts.UseStaticRegistries();
    // … the rest of your configuration
});

The fallback is safe rather than loud: if no GeneratedHandlerRegistry is present (you forgot to run codegen write, or the file wasn’t committed), Wolverine logs a single warning and falls back to the runtime assembly scan instead of throwing. Re-run dotnet run -- codegen write and commit the regenerated files to eliminate the scan.

::: tip Regenerating during codegen write always performs a fresh scan, so the registry can never perpetuate a stale handler set — add the regeneration step to CI alongside the rest of your pre-generated code. :::

Validation and AOT

There is no pre-generated ValidatorRegistry to build for AOT — but the picture has two distinct paths, only one of which scans assemblies. See #2855 for the full investigation.

Middleware application is scan-free (AOT-clean). FluentValidationPolicy decides whether to apply validation to a handler chain by querying the IoC container per message type (container.RegistrationsFor(typeof(IValidator<>))), not by walking Assembly.ExportedTypes. The codegen-time policy and the runtime Execute* calls carry leaf trim/AOT annotations (see AOT-pillar tracking), so this path is trim-safe. Likewise DataAnnotationsValidationExecutor reflects only over the handler-rooted message type’s [Validation*] attribute graph (leaf-annotated, no scan).

Validator discovery at bootstrap is the one non-AOT seam. opts.UseFluentValidation() defaults to RegistrationBehavior.DiscoverAndRegisterValidators, which runs FluentValidation’s AssemblyScanner over your ApplicationAssembly to find and register IValidator<T> implementations. That scan is fundamentally trim-hostile (it walks exported types and constructs open generics reflectively). For trim/AOT publishing, opt out of the scan and register validators explicitly:

opts.UseFluentValidation(RegistrationBehavior.ExplicitRegistration);
 
// then register each validator yourself (trim-safe):
opts.Services.AddScoped<IValidator<CreateCustomer>, CreateCustomerValidator>();

With ExplicitRegistration, Wolverine performs no assembly scan; the middleware-application path was already scan-free, so the whole FluentValidation surface is then AOT-clean. (If you instead rely on FluentValidation’s own AddValidatorsFromAssembly* outside Wolverine, that scan has the same trim-hostility — register explicitly there too, and see FluentValidation’s AOT guidance.)

Migration checklist

If you’re moving an existing 5.x Wolverine app to 6.0 AOT:

  1. Upgrade to Wolverine 6.0 (see the migration guide)
  2. Run dotnet run -- codegen write locally; verify the Internal/Generated/ folder gets populated and the files compile
  3. Add opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static; to your UseWolverine configuration
  4. Run your existing test suite — same behavior, just no runtime codegen
  5. Add /p:PublishAot=true to your dotnet publish command, fix any remaining IL warnings (or accept them with <NoWarn>) and ship

If a handler type changes between releases, re-run codegen write and commit the regenerated files. Add the regeneration step to your CI pipeline (or pre-commit hook) so the pre-generated code never drifts from the source handlers.



url: /guide/health-checks.md