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:
- 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'sInternal/Generated/folder. - At runtime — configure Wolverine in
TypeLoadMode.Staticso the host loads the pre-generated types instead of compiling them at boot. With Static mode in effect, Wolverine's Roslyn-basedAssemblyGeneratoris 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 writeThis 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=trueA 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):
Envelopeconstruction + value-shape (Id,Headers,CorrelationId,MessageType, …)Envelope.TryGetHeaderfast pathEnvelope.SetMessageType<T>closed-genericEnvelope.SetMetricsTag- Envelope scheduling helpers (
ScheduleAt,ScheduleDelayed,IsScheduledForLater,IsExpired) DeliveryOptionsconstructionWolverineOptionsconstructionWolverineOptions.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.
Wolverine.AI.AotSmoke
There's a sister project under the same settings that guards WolverineFx.AI's LLM callout path, covering type resolution, schema generation, and deserialization. It exercises the real LlmResponseBinder that LlmCalloutExecutor delegates to, rather than a copy of it.
Note that CI runs this one as well as building it, which is a bit unusual for a smoke project. That's because the failure it's guarding against is silent -- when schema generation can't see a type it answers with an empty schema instead of throwing, so a build-only check would sail right past the exact regression the project exists to catch.
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.Compileand its callees — runtime Roslyn codegen of handler dispatch. Static-mode apps don't reach this.- Reflective handler / saga / transport discovery (
HandlerDiscovery.IncludeAssemblytype-scan, the saga-type-descriptorStartingMessagesreflection, transportIMessageRoutingConventionreflection). Static-mode + a pre-generated handler manifest avoid these. MakeGenericType/Activator.CreateInstancedriven factories — the few that still exist in core.- The
WolverineFx.Newtonsoftcompanion package (Newtonsoft.Json itself isn't AOT-friendly; if you need Newtonsoft, you're not on the AOT path). LlmCallout.Ask(prompt, context)inWolverineFx.AI-- serializing an arbitrary context object means reflecting over its shape, and there's no way around that. Use.WithContext(context, typeInfo)instead, which the annotation's message tells you right at the call site.
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:
- Upgrade to Wolverine 6.0 (see the migration guide)
- Run
dotnet run -- codegen writelocally; verify theInternal/Generated/folder gets populated and the files compile - Add
opts.CodeGeneration.TypeLoadMode = TypeLoadMode.Static;to yourUseWolverineconfiguration - Run your existing test suite — same behavior, just no runtime codegen
- Add
/p:PublishAot=trueto yourdotnet publishcommand, 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.
Related
- Code Generation — full reference for
TypeLoadMode,codegen write, and the underlying JasperFx runtime-compilation surface Wolverine.AotSmoke— the regression-guard project this guide references- AOT-pillar tracking — what's annotated, what's left
- JasperFx 2.0 AOT pillar — the foundation Wolverine builds on

