Skip to content
Kumar Chandrachooda
.NET

Trading Reflection for Roslyn: Source Generators and Native AOT

Everything FastEndpoints does with reflection - discovery, setters, factories - has a compile-time twin in its source generators, and following that migration is a case study in making a reflection-heavy library AOT-ready. Part 15 of FastEndpoints in Depth.

By Kumar Chandrachooda 16 May 2026 5 min read
The same catalogue, built at compile time instead of discovered at runtime

This series has leaned on one runtime trick after another: assembly scanning (Part 2), compiled property setters (Part 4), reflection-built executors (Part 11). Native AOT forbids or cripples most of that — no Assembly.GetTypes() over trimmed assemblies, no MakeGenericType for unreferenced combinations, no expression-tree compilation. A reflection-heavy library facing AOT has three options: refuse, restrict, or move the reflection to compile time. FastEndpoints chose the third, and its Generator package is one of the better public examples of the migration. This part reads the generators — because even if you never ship AOT, one of them is worth using tomorrow morning.

The one you should use anyway: DiscoveredTypes

The AddFastEndpoints() overload everyone starts with scans assemblies at startup. The source marks it bluntly:

// Src/Library/Main/MainExtensions.cs (FastEndpoints, MIT)
const string AotWarning = "Reflection-based endpoint discovery is not supported. " +
                          "Use AddFastEndpoints(DiscoveredTypes.All) with the source generator.";

[RequiresUnreferencedCode(AotWarning), RequiresDynamicCode(AotWarning)]
public static IServiceCollection AddFastEndpoints(this IServiceCollection services)

The replacement is DiscoveredTypesGenerator, an incremental Roslyn generator. It watches every class declaration in the compilation and keeps those implementing a whitelist of interfaces — the Part 2 scan list plus the messaging and processor interfaces (twelve entries in the source: IEndpoint, IEventHandler, ICommandHandler, IValidator, IMapper, pre/post processors, command middleware, job storage providers, summaries, stream handlers). The output is almost comically simple — a static class with a List<Type> literal:

// generated: DiscoveredTypes.g.cs
public static class DiscoveredTypes
{
    public static readonly List<Type> All = [ typeof(MyApp.CreateOrderEndpoint), ... ];
}

// startup:
builder.Services.AddFastEndpoints(o => o.SourceGeneratorDiscoveredTypes.AddRange(MyApp.DiscoveredTypes.All));

Same catalogue the scanner would build, zero startup scanning cost, and — the part I did not expect to care about until I measured it — meaningful cold-start savings on serverless. Scanning every referenced assembly's types is O(everything you reference); the generated list is O(what you wrote). On a Lambda-hosted API I measured double-digit-millisecond cold-start improvement from this switch alone. One generated list per assembly (endpoints in a class library need the generator referenced there too), pass them all in, done. There is no trade-off here; it is simply better, AOT or not.

The generator's incrementality is textbook: the predicate rejects non-class nodes cheaply, the transform extracts only a display string (so Roslyn's caching works on value equality), and generation runs only when the string set changes. If you are writing your own generator, this file is a fine 100-line syllabus.

The AOT-specific twins

Two more generators replace runtime codegen that AOT disallows:

Reflection data. The binder's compiled setters and object factories come from expression trees — RequiresDynamicCode territory. The ReflectionGenerator emits, for every request DTO, compile-time C# setters, getters and factory delegates that the binder's ReflectionCache consumes (you can see the seam in the benchmark project's startup: c.Binding.ReflectionCache.AddFromFEBench() — the generated extension registering everything). Under JIT the expression path is the default and the generated path is optional; under AOT the generated path is the only path. The design lesson: the runtime and compile-time implementations share one cache interface, so the rest of the binder is oblivious to where its delegates came from.

Generic processor/middleware closures. MakeGenericType at runtime for open-generic pre/post processors and command middleware needs those instantiations to exist in the AOT image; a small generator walks usage and emits the closed types so the trimmer keeps them.

Serialization, the other AOT frontier, is handled the STJ way rather than a FastEndpoints way: endpoints accept a JsonSerializerContext (SerializerContext(MyCtx.Default) in Configure()), and the library's own internal responses — error envelopes, problem details — ship with their own JsonSerializerContext (visible at the bottom of MainExtensions.cs). The repository keeps itself honest with a separate NativeAot.slnx solution and a NativeAotChecker test harness that actually publishes and exercises an AOT binary in CI — the only way AOT support stays true, because AOT regressions are invisible until you publish.

What AOT still costs

Reading the AOT-adjacent suppressions and comments across the codebase gives a candid picture of the remaining friction, and it is worth a paragraph of honesty. Value-type request DTOs can't ride the generic binder registration under AOT (the code special-cases them — a comment in Endpoint.Static.cs notes native AOT “cannot instantiate value type generic binders”). Swagger generation (Part 10) is a build/dev-time concern that leans on reflection — generate documents at build time rather than serving them from the AOT binary. Anything you write that reflects — custom binders, dynamic mappers — inherits the same constraints, and the library can't save you from yourself. AOT with FastEndpoints is genuinely supported, not merely tolerated; but it is a discipline, and the discipline extends to your code.

The CLI sibling and the other generators

The Generator.Cli package exists for build environments where wiring an analyzer/generator into every project is awkward — same outputs, invoked as a dotnet tool. And two more generators round out the package, both usability rather than AOT plays:

  • ServiceRegistrationGenerator: [RegisterService<IEmailSender>(LifeTime.Scoped)] on a class emits the services.AddScoped<...>() calls — DI registration as declaration, one generated method instead of a registration file that grows merge conflicts.
  • AccessControlGenerator: the Part 7 companion. Endpoints declaring AccessControl("Order_Refund") feed a generated Allow class of permission constants (with the same three-character SHA-256-derived codes the runtime's GetAclHash produces — the source comments in both files warn each other to stay in sync, which made me smile: even generators have contracts). Your permission vocabulary becomes compile-time constants shared by token issuance, endpoint declarations and the client SDK; a typo'd permission string becomes a compile error.

The pattern to steal

Step back from the specifics and the migration recipe is portable to any reflection-heavy library or app:

  1. Put a cache interface between reflection and its consumers (the ReflectionCache). Consumers ask for delegates; where delegates come from is a provider detail.
  2. Make the generator emit into that same interface. Runtime and compile-time providers coexist; adoption is incremental.
  3. Annotate the reflection paths (RequiresUnreferencedCode/RequiresDynamicCode) so the compiler routes users to the generated alternative instead of letting them discover trim breakage in production.
  4. CI-publish an actual AOT binary. Nothing else counts as support.

That is the last of the machinery. Two parts remain: a tour of the cross-cutting features that didn't fit anywhere else — rate limiting with honest limitations, idempotency built on output caching, server-sent events, and a genuinely surprising HTTP 402 payments implementation — and then the retrospective the series owes you. Part 16: Throttling, Idempotency, and an HTTP 402 Surprise.