
"It's Going to Take a Long Time": How the Critter Stack Got to AOT Compliance
Back in May of 2023, a user filed an issue against Marten because publishing with PublishTrimmed=true blew up with System.InvalidOperationException: Compilation failures! — the trimmer had helpfully removed compiler-required members that Marten's runtime code generation needed at startup. Jeremy's response, almost a year later, was honest if not encouraging:
"Dude, it's going to take a long time for Marten to be usable with AOT or trimming"
Jeremy wasn't wrong. It took three years. But as of the Critter Stack 2026 release wave, every shipped library in the JasperFx organization — JasperFx itself, Weasel, Marten, Polecat, and Wolverine — publishes AOT-clean or trim-clean with explicit, accurate annotations, backed by IsAotCompatible=true on the packages and smoke-test projects in CI that fail the build if we ever regress. This post is the story of how we got there, why it was so hard, and what you can do with it today.
Why this was hard: we bet on runtime codegen, and we'd do it again
The Critter Stack's whole performance story was built on runtime code generation. Going back to Marten V4 and the earliest days of Wolverine (née Jasper), the frameworks generate C# source at runtime for your specific application — document storage classes, event projections, message handler pipelines — and compile it in memory with Roslyn. That bought us "no reflection on the hot path" performance with "just write idiomatic code" usability, and it's a big part of why Wolverine's middleware approach benchmarks the way it does.
It also meant the stack was almost maximally hostile to Native AOT:
- Roslyn at runtime is the single most un-AOT thing a library can do. You cannot compile and load new assemblies inside an AOT-published binary, full stop.
- Reflection everywhere else. Assembly scanning for handler and extension discovery,
MakeGenericType+Activator.CreateInstancefor closing generic types at runtime,GetType().GetProperties()in Weasel's command building. - Expression compilation. Where we didn't use Roslyn, we used
FastExpressionCompiler— for event projection apply-method dispatch, envelope mapping in Wolverine, document identity accessors. Under AOT,Expression.Compile()silently falls back to an interpreter that's too slow to be acceptable.
The trimmer issue from 2023 wasn't a bug to fix. It was the architecture working exactly as designed, in an environment the architecture had never contemplated.
The groundwork was already there — we just didn't know it yet
The funny thing in hindsight is that the escape hatch had existed for years. Since the Marten V4/V5 era, we've allowed you to run dotnet run -- codegen write at development time to write all the generated code to disk as real C# files, compile them into your assembly, and boot with TypeLoadMode.Static so nothing gets generated at runtime. We built that for cold-start times and for debuggability (generated code you can read and step through beats generated code hiding in memory).
What we hadn't done was make that path complete. Static mode still shipped Roslyn in your deployment. The type loading still went through reflective lookups. And a hundred smaller reflective surfaces all over the codebase would still trip the trimmer even with every generated type pre-baked. Pre-generation solved the biggest problem, but AOT compliance is a wall of a thousand bricks, and we'd only laid the first one.
The other proof point was Polecat, our SQL Server-backed document database and event store. Polecat went source-generator-first back in its 3.x days — no runtime codegen at all — and its cold-start numbers were consistently the best in the stack. That was the existence proof that the model worked.
Polecat 5.0 that should be out before you read this is shifting to a new shared runtime model with Marten that likewise avoids both source generators and the runtime code generation
Critter Stack 2026: making it a pillar, not a wish
When we planned the Critter Stack 2026 release wave (JasperFx 2.0, Weasel 9, Marten 9, Polecat 4, Wolverine 6), we organized the cross-cutting work into three pillars: cold-start performance, Marten↔Polecat dedupe, and — finally — AOT compliance as a first-class deliverable with a real definition of done:
Every shipping library either compiles AOT-clean with no
IL2026/IL3050warnings, or has every dynamic surface annotated with[RequiresDynamicCode]/[RequiresUnreferencedCode]so a downstreamIsAotCompatible=trueconsumer sees a precise punch-list rather than a wall of analyzer noise.
That second clause matters. "AOT compliance" doesn't mean "no dynamic code anywhere" — it means honesty about where the dynamic code is, so the compiler can tell you exactly what your app is signing up for.
The work broke down into a few big moves.
1. Evict Roslyn from the runtime
The foundational change was JasperFx #190: an ITypeLoader abstraction that splits the type-loading pipeline in two. StaticTypeLoader is AOT-safe by construction; DynamicTypeLoader and the whole Roslyn AssemblyGenerator moved behind a separate JasperFx.RuntimeCompiler package that you opt into with services.AddRuntimeCompilation(). Don't call it, and the trimmer drops the entire Roslyn dependency graph (~6 MB) from your published app.
Wolverine 6 finished the thought with #2876: core WolverineFx no longer references JasperFx.RuntimeCompiler at all. The Roslyn compiler is now a development-time dependency. Dynamic-mode apps add the WolverineFx.RuntimeCompilation package; Static-mode and AOT apps ship without a compiler in the box — which, when you say it out loud, is a strange thing to have ever been shipping.
2. Annotate everything (the brick wall, one brick at a time)
Then came the grind: auditing every reflective call site in five repositories and either annotating it, suppressing it with a documented justification, or replacing it outright. The numbers give a sense of the scale — JasperFx + JasperFx.Events went from 236 IL trim/AOT warnings to zero; the Wolverine work ran through a lettered series of "AOT chunks" from A to U plus a second pass over thirteen extension packages; Polecat took seven slices; Weasel and Marten each took their own passes.
A few of these were genuinely fun archaeology: Weasel's CommandBuilderBase doing GetType().GetProperties() needed a DynamicallyAccessedMembers annotation; Wolverine's IForwardsTo<T> interface discovery via Assembly.ExportedTypes scanning became explicit registration (a breaking change worth making); node records got a proper JsonSerializerContext instead of reflective STJ.
3. Replace expression compilation with source generators
FastExpressionCompiler is an awesome project that we have very much benefited from using it over the years.
Annotations make dynamic code visible; they don't make it work under AOT. For the surfaces that had to actually run in an AOT binary, FastExpressionCompiler had to go. Wolverine's EnvelopeMapper moved to plain delegate dispatch. Event projection apply-method dispatch — the heart of every Marten and Polecat projection — moved to JasperFx.Events.SourceGenerator, which emits a typed dispatcher per registered projection at compile time. No reflection, no expression trees, and the generated dispatcher is right there in your IDE if you want to read it.
4. Guard it in CI, or it's fiction
AOT compliance rots instantly without enforcement. Every product now carries an AotSmoke project — a consumer app with IsAotCompatible=true, TrimMode=full, and the full IL warning list (IL2026, IL3050, and eleven friends) escalated to errors. One reflective call added without annotation anywhere in the library, and the build fails. On top of the per-product gates sits a cross-stack smoke app wiring Marten 9 + Polecat 4 + Wolverine 6 + JasperFx 2.0 together in Static mode, because the composition surfaces (the Wolverine outbox over Marten, projection registration flowing through the source generators) are exactly where per-product tests have blind spots.
The pillar closed on May 20, 2026. Three years and three days after that original trimming issue.
5. Eliminate assembly scanning with source generators
The entire stack including our command line integration and especially Wolverine relied on quite a bit of "Assembly Scanning" to automatically discover handlers, command line tools, validators, and extensions with Reflection. In the "Critter Stack 2026" wave we switched to source generators to find and wire up command line handlers to help eliminate the assembly scanning and improve the "cold start" times -- which is suddenly more important for command line usage in our new era of AI assisted development. Likewise, the dotnet run codegen write mechanism still left in Wolverine eliminates the need for almost all assembly scanning at bootstrap time.
Fluent Validation of all things is still lagging on that front
What it bought us: the numbers
The AOT work and the cold-start pillar were always the same project wearing two hats, and the measurements bear that out. On our benchmark harness, a Marten 9 app in the old Dynamic mode paid ~589ms at the p50 for its first operation — most of it runtime codegen. The same app in Static mode with pre-generated code: 67% faster total cold start (~265ms), 85% faster to first operation, right in the neighborhood of source-generator-first Polecat (~170–180ms). For serverless and scale-to-zero deployments, that's the difference between "fine" and "why is your health check timing out."
And of course, dotnet publish /p:PublishAot=true now produces a working binary against the whole stack — something that was a hard architectural impossibility eighteen months ago.
An honest asterisk: Wolverine's runtime codegen isn't going anywhere
You can now completely drop the Roslyn dependency from production deployments of Wolverine applications for the very slimmest deployments and fastest quick start times
One deliberate scoping decision deserves daylight. Marten 9.0 landed with zero runtime codegen, but with mandatory source generators. Wolverine is not — its handler pipeline generation composes middleware, policies, and per-handler specialization in ways that we concluded are not remotely feasible to express as a Roslyn source generator. So Wolverine's runtime codegen is annotated, not removed: AOT users pre-generate with codegen write and run Static, exactly like the Marten workflow, and Dynamic-mode users accept the (now clearly labeled) dynamic-code dependency. I'd rather ship an honest constraint than a compromised architecture.
Publishing AOT with the Critter Stack today
The short version of the publishing guides that shipped with the wave:
- Reference the source generator packages (
JasperFx.Events.SourceGeneratorfor projections) alongside Marten 9 / Wolverine 6. - At development time, run
dotnet run -- codegen writeand commit the generated code underInternal/Generated/. - Set
TypeLoadMode.Static, and do not referenceWolverineFx.RuntimeCompilation/ callAddRuntimeCompilation(). dotnet publish /p:PublishAot=true.
Known exclusions, documented rather than discovered: Marten.PLv8 stays codegen-dependent, and dynamically registering document types at runtime (opts.Schema.For(Type.GetType(name))) is inherently a JIT-only trick.
Summary
If you've been holding off on the Critter Stack for serverless, trimmed containers, or AOT-mandated environments — the answer used to be "it's going to take a long time." The answer now with Wolverine is "run codegen write and publish."
