Compile-time is the new runtime: source generators as an application architecture
Roslyn source generators are usually sold as boilerplate killers. Elarion uses them as the architecture: one attributed class becomes a DI registration, an HTTP endpoint, a JSON-RPC method and an MCP tool at build time — and the costs of that choice are just as instructive as the wins.
Every .NET application has a second architecture nobody drew: the one that assembles itself at startup. Assembly scanning finds your handlers, reflection reads your attributes, an IoC container wires the graph, and a routing table materializes out of convention. It works — until you enable trimming and the scanner finds nothing, or startup slows as the app grows, or someone renames a class and the first sign of trouble is a 500 in production.
I’ve been building Elarion, an application framework that takes the opposite bet: discovery happens at compile time, and the generated wiring is the architecture — inspectable, statically typed, and enforced by the build. Source generators are usually pitched as boilerplate killers for serializers and regexes. This post is about what happens when you promote them to the load-bearing structure of an application — what that buys, what the JVM crowd knew about this a decade earlier, and the pitfalls that only show up once you’re in deep.
One class, four artifacts
Here is a complete, real handler from one of Elarion’s samples — no controller, no Program.cs entry, no interface registration anywhere:
[Handler("market.quote")][HttpEndpoint("quotes/{symbol}")]public sealed class GetQuote(IActorSystem actors) : IHandler<GetQuote.Query, Result<GetQuote.Response>> { public sealed record Query(string Symbol) : IQuery;
public sealed record Response(Quote Quote);
public async ValueTask<Result<Response>> HandleAsync(Query query, CancellationToken ct) { var quote = await actors.Get<IStockQuote>(query.Symbol.ToUpperInvariant()).GetQuote(ct); return quote is null ? AppError.NotFound( $"No live quote for '{query.Symbol}' (unknown symbol, or the feed has not primed it yet).") : new Response(quote); }}At build time, generators turn that one class into four ordinary, readable C# artifacts:
- A DI registration — a
GetQuoteRegistration.AddGetQuote()extension wrapping the handler in its decorator pipeline (validation, authorization, caching, auditing — all derived from attributes). - A minimal-API endpoint — a concrete
app.MapGet("quotes/{symbol}", …)lambda with typed parameter binding. The verb isn’t even declared:Query : IQueryimplies GET,ICommandimplies POST. - A JSON-RPC method —
dispatcher.Map<Query, Response>("market.quote", …)on a named request/reply bus. - An MCP tool — an entry in a metadata table (name, description, parameter schema pulled from
[Description]attributes) that exposes the same operation to AI agents.
The architecture worth stealing here is not “lots of codegen” — it’s the shape. JSON-RPC and MCP are not two parallel stacks; they are two thin adapters over one transport-neutral bus, and the generator builds the single registry both adapters resolve. HTTP deliberately bypasses the bus with a concrete per-handler route, for a reason we’ll get to, because it’s one of the best AOT lessons in the codebase.
Elarion is at 54 NuGet packages on .NET 10 now, and the generator count tells its own story: the ADR that codified generator conventions casually mentions “~12 generators across two packages” — today there are 35 incremental generators across 16 analyzer projects. Wiring, validation, permissions, scheduled jobs, event consumers, actors, EF Core DbSets: all of it is emitted, none of it is scanned.
“Zero reflection” only counts if a test enforces it
The standard pitch for build-time discovery is real and I’ll keep it short: startup is deterministic (no assembly scan), the app is Native-AOT- and trim-safe (nothing discovers types at runtime), and an entire failure class moves left — a missing trigger or an invalid contract is a build error, not a runtime surprise.
But claims rot. The interesting question is how you keep “no runtime reflection” true across 35 generators and years of contributions. Two mechanisms in Elarion do the work:
First, the repo flips the serializer default project-wide — JsonSerializerIsReflectionEnabledByDefault=false — so any DTO missing from a source-generated JsonSerializerContext throws loudly instead of silently falling back to reflection.
Second, and my favorite: the invariant is asserted in generator tests. Elarion’s validation generator emits resolvers whose DataAnnotations attributes are constant-constructed from compile-time metadata, and the test pins it:
generated.Should().Contain( "new global::System.ComponentModel.DataAnnotations.StringLengthAttribute(100) { MinimumLength = 3 },");generated.Should().Contain( "new global::System.ComponentModel.DataAnnotations.RangeAttribute(1, 10),");generated.Should().NotContain("GetCustomAttributes");That last line is the whole philosophy in one assertion. If a future refactor reaches for GetCustomAttributes, the build goes red. Architectural properties you don’t test are architectural opinions.
The JVM got here first
If this build-time religion sounds novel, it isn’t — Java arrived at it years ago, driven by the same forcing function: native compilation.
Annotation processing (JSR 269) has been in javac since Java 6. Dagger used it for fully compile-time dependency injection back when .NET DI was 100% runtime. Micronaut went furthest: DI, AOP and configuration binding all resolved by annotation processors at compile time, precisely so that startup didn’t pay a reflection tax and GraalVM native images could work without config-file archaeology. Quarkus took a different route to the same destination — build-time augmentation that pre-computes framework metadata and generates bytecode, because on GraalVM, whatever you don’t resolve at build time you pay for (or lose) at image-build time.
The .NET version of the idea differs in one way that matters enormously in practice: Roslyn generators run continuously inside the IDE, on every keystroke. An annotation processor runs when the build runs. An incremental source generator is effectively a live participant in your editing session — which makes performance a correctness concern, and leads directly to the pitfalls section.
The pitfalls that actually happen
Your generator silently stops being incremental
An incremental generator caches its pipeline stages; if a stage’s inputs are equal to last run, everything downstream is skipped. The catch: it’s value equality, and almost everything you naturally put in a pipeline model — ImmutableArray<T>, ISymbol, Location, the Compilation itself — compares by reference. Get this wrong and your generator re-runs its full semantic scan on every edit in the IDE.
An internal review of Elarion found exactly that, in the framework’s own words:
This compiles, passes tests, and emits correct code — and it re-runs the entire O(all-source) semantic scan on every edit … A 2026-06 review found six generators in this shape, several scanning every syntax tree twice … the failure mode is invisible — there is no compiler warning, no failing test, just an IDE that gets slower as the codebase grows.
Six generators, in a codebase that takes generators more seriously than almost anyone. The countermeasures became conventions: every pipeline model is value-equatable (an EquatableArray<T> exists solely to work around ImmutableArray<T>’s reference equality), and — the part I’d steal for any generator project — every generator carries a cache test. It runs the generator, appends an irrelevant declaration to the source, runs it again, and asserts every tracked pipeline node reports Cached or Unchanged. Output tests can’t catch this regression; output stays byte-identical while your IDE quietly dies. Only a cache assertion goes red.
Generators cannot see other generators
Roslyn generators running in the same compilation all receive the same input — the output of one is invisible to its peers. That constraint shapes real decisions. Elarion couldn’t adopt Microsoft’s bundled minimal-API ValidationsGenerator for a stack of reasons, but the structural one is that it discovers validatable types from minimal-API endpoint signatures — and Elarion’s endpoint mappings are themselves generator output, which no generator running beside it can observe. If your architecture is generated, every other generator in your compilation sees an empty application. Plan for it: within a compilation, generators compose by convention (shared attributes, manifests), never by reading each other’s work.
AOT-safety does not survive a generic helper
The elegant way to emit endpoint registrations would be one generic helper — MapElarionHandler<TRequest, TResponse>(route) — called in a loop. Elarion instead emits a concrete, fully-typed MapGet/MapPost lambda per handler, and an ADR exists specifically so nobody “fixes” this:
ASP.NET Core’s Request Delegate Generator … only intercepts statically-analyzable
MapGet/MapPost(...)calls with concrete delegate and parameter types. A genericMapElarionHandler<TRequest,TResponse>(route)is opaque to RDG, so it falls back to the reflection-basedRequestDelegateFactory— incompatible with AOT/trimming.
If that sounds like it contradicts the previous pitfall — didn’t I just say generators can’t see each other’s work? — look at where the boundary sits. Sideways, within one compilation, generators are mutually blind. Downstream, generated code is ordinary code: the compiler type-checks it, it lands in the assembly’s IL, and that is where the next round of static analysis runs — the trimmer, the Native AOT compiler, and generators in referencing projects that see your assembly’s compiled metadata. Blind sideways, visible downstream. So the discipline cuts both ways: never rely on a generator beside you reading your output, and always emit code whose static shape — concrete delegates, closed generics, no reflection — survives the analyzers that come after you. That’s also why the framework’s HTTP path is concrete-per-handler while the JSON-RPC/MCP bus stays generic: no source generator downstream of the bus needs to recognize its call shape — its AOT story rests on registered JsonTypeInfo for every request and response — whereas HTTP’s minimal-API path has RDG waiting for statically-analyzable calls.
The paper cuts
Two smaller ones that cost real time. Analyzer assets are not transitive in NuGet: every assembly that declares handlers needs a direct reference to the generator package, or wiring silently doesn’t appear. And IDEs refresh generator output lazily — Rider can show stale generated code until a rebuild or cache invalidation, which reliably makes someone file a bug against the generator that dotnet build disproves.
When should generators carry your architecture?
| Situation | Verdict |
|---|---|
| Serializers, regexes, P/Invoke — local boilerplate | Always; this is settled |
| App targets Native AOT or trimming | Generation isn’t optional; reflection discovery is already broken |
| Framework-style wiring (handlers, DI, transports) | Yes — with value-equatable models and cache tests from day one |
| One team, one app, no AOT ambitions | Runtime reflection is genuinely fine; don’t pay the ceremony tax |
| Logic that changes per environment or tenant at runtime | Wrong tool — build time is the one thing you can’t vary per deployment |
The honest cost summary: writing the generator is the easy 20%. The other 80% is incrementality discipline, diagnostics that explain themselves, cache tests, and documentation for the day the IDE shows stale output.
My rule of thumb: anything that is knowable at compile time and would otherwise fail at runtime deserves to be computed at compile time — and every architectural property you claim (“zero reflection”, “cached pipeline”) deserves a test that turns red when it stops being true. The compiler is the one reviewer who never gets tired; hand it as much of your architecture as it can hold.