Source Generators
Compile-time code generation with Roslyn -- incremental generators, the built-in JSON/logging/regex generators, and writing your own to replace runtime reflection
Source Generators
A source generator is a compiler plugin -- a Roslyn component -- that runs during compilation, inspects your code as the compiler sees it, and emits additional C# source that is then compiled alongside everything you wrote. The defining constraint is that it is purely additive: a generator can add code, but it can never modify or remove a line you wrote. It hooks into the compilation, reads the syntax and the semantic model, and produces new files; your original source is untouched and the generated source joins it as a first-class part of the same assembly.
The payoff is that work which traditionally happened at runtime -- serialization, dependency-injection wiring, logging, object mapping -- moves to build time. Instead of a library reflecting over your types on first use to figure out how to serialize them, the generator does that analysis once, while you compile, and writes out straight-line code. The results are faster startup, no per-call reflection cost, code the trimmer can actually see, and -- the driver that has made source generators mainstream rather than a curiosity -- Native AOT compatibility, because AOT forbids runtime code generation and sharply limits reflection. If you want your code to run under AOT, the reflection has to go somewhere, and source generators are where it goes.
Why They Exist: Reflection's Cost
It is worth being precise about what reflection actually costs, because that cost is the whole reason this feature exists. A reflection-based library -- a JSON serializer, an object mapper, a DI container building a graph -- inspects your types at runtime: it walks properties, reads attributes, and constructs delegates or interpreters on the fly to do its work. That has four prices. It costs startup time, because the discovery happens on first use. It allocates, because the metadata walk and the dynamic dispatch produce garbage. It defeats trimming, because the linker cannot see a member that is only ever accessed by a string name through reflection, so it either keeps everything or breaks. And it breaks outright under Native AOT, where dynamic code generation simply is not available.
A source generator does the identical analysis -- the same property walk, the same attribute reading -- but it does it once, at build time, and emits the equivalent code as plain C#. The reflection has not disappeared; it has been shifted left, from every program startup to a single compilation. That single idea -- shift the reflection to compile time -- is the frame for this entire page, and it is the lens to apply to every built-in generator below and to any generator you might write yourself.
The Built-In Generators You Already Use
Source generators are not exotic; if you write modern .NET you almost certainly depend on several first-party ones already, often without noticing. Recognising them is the fastest way to internalise the pattern.
System.Text.Json ships a generator that turns a partial JsonSerializerContext into reflection-free (de)serializers for the types you nominate. This is the canonical example, and it is required for trimmed and AOT applications, where the reflection-based serializer cannot work. You declare the types and the generator writes the metadata and the read/write logic.
[JsonSerializable(typeof(Order))]
internal partial class AppJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);LoggerMessage generates high-performance logging methods from a partial method signature. Because the message template is parsed at build time and the parameters are strongly typed, the generated method allocates nothing on the hot path -- no boxing of value-type arguments, no parsing the template string on every call -- which is exactly the overhead a naive _logger.LogInformation($"...") quietly incurs.
public partial class OrderLog
{
[LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} shipped to {Region}")]
public static partial void OrderShipped(ILogger logger, string orderId, string region);
}The regex generator compiles a pattern at build time into a purpose-built matcher rather than feeding the pattern to the interpreting regex engine at runtime. [GeneratedRegex] on a partial method gives you a Regex whose matching code is emitted as C#, with startup and steady-state both faster than the runtime-compiled option and fully AOT-safe.
public partial class Validation
{
[GeneratedRegex(@"^\d{2}-\d{4}-\d{7}-\d{2}$")]
public static partial Regex NzBankAccount();
}The list goes well beyond these three. The configuration-binding generator emits IConfiguration binding code so the Options pattern works without reflection; the COM and P/Invoke interop generators (LibraryImport superseding the old reflection-driven DllImport marshalling) emit marshalling stubs at compile time; and ASP.NET Core and MVC use generators internally for request delegate construction and more. The point is that this is the mainstream direction of the platform, not a fringe technique.
How a Generator Runs
A generator is referenced as an analyzer, not as an ordinary package dependency. That distinction matters: it does not ship in your application's output, because it is a build-time tool that runs inside the compiler, not a runtime library your code calls. The compiler hands the generator the compilation -- the syntax trees and the semantic model -- through a provider pipeline, the generator inspects what it needs, and it adds source files to the same compilation.
Those generated files are real C#. You can step into them in the debugger, the IDE surfaces them in the project tree, and you can write them to disk to read them by enabling EmitCompilerGeneratedFiles in the project file (paired with CompilerGeneratedFilesOutputPath to choose where). Treating the output as ordinary code you can inspect and debug -- rather than as opaque compiler magic -- is the right mental model, and it is what makes a misbehaving generator tractable to diagnose.
Incremental Generators
The modern API is IIncrementalGenerator, and it has fully replaced the original ISourceGenerator. The entire reason it exists is IDE performance. The original API re-ran the whole generator over the whole compilation on essentially every keystroke, which made heavy generators a noticeable drag on the editing experience. The incremental model instead expresses the work as a pipeline of cached, comparable steps, so that when you type a character only the outputs actually affected by that change are recomputed -- everything else is served from cache.
The pipeline is a chain of providers. You start from a provider such as SyntaxProvider (to find interesting syntax, like types carrying a particular attribute) or CompilationProvider (to reach compilation-wide information), then Where/Select to filter and transform down to a small data model, and finally register an output that turns that model into source. The cardinal rule -- the one that separates a fast generator from one that makes the IDE crawl for everyone who references it -- is that the output of each step must be cacheable and value-equatable. Use record types and an EquatableArray<T> for collections so that two runs producing equal data compare equal and short-circuit. Never put a Compilation, an ISymbol, or a syntax node into your data model: those types are reference-equal and enormous, so caching them both defeats incrementality and roots huge object graphs in memory. Extract the handful of strings and booleans you actually need into a small record, and throw the symbols away before the data leaves the transform.
Writing a Minimal Generator
A real IIncrementalGenerator is smaller than its reputation suggests. The shape below is the one to learn: register a post-initialization source to emit a marker attribute the user will apply (so your generator ships its own trigger), use ForAttributeWithMetadataName to locate the annotated types efficiently, transform each match into a small equatable record, and RegisterSourceOutput to emit a partial type from that model.
[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var models = context.SyntaxProvider.ForAttributeWithMetadataName(
"MyLib.GenerateToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => ToModel(ctx)); // -> an equatable record
context.RegisterSourceOutput(models, static (spc, model) =>
spc.AddSource($"{model.Name}.g.cs", Emit(model)));
}
}ForAttributeWithMetadataName is the entry point to reach for. It is heavily optimised inside Roslyn -- the compiler maintains an index of which syntax nodes carry which attributes, so the predicate runs only against plausible candidates rather than every node in the tree -- and it is the single biggest lever on a generator's IDE cost. The predicate does a cheap syntactic check; the transform does the expensive semantic work and, critically, returns your small record rather than the symbol it inspected.
Note the static lambdas: they avoid capturing state into a closure, which keeps the pipeline steps pure and cacheable. The transform should pull the names, types, and flags it needs out of the symbol and return them in a record; the emit step is then a pure function from that record to a string of C#. And you almost always emit partial types, because that is how generated code and hand-written code combine into one type without either side knowing the other's details.
Partial Methods and the Pattern
The recurring shape across every built-in generator is the partial declaration. You write a partial method or partial class decorated with an attribute, and the generator supplies the missing implementation. That is precisely why [LoggerMessage] and [GeneratedRegex] sit on partial methods with no body -- you declare the signature and the intent, and the generated half fills in the body the compiler then links into your type as if you had written it.
When to Write Your Own
Reach for a generator when you have genuinely repetitive boilerplate spread across many types -- mapping between DTOs and domain models, generating ToString/equality members, registering a family of services, building command dispatchers -- because that is exactly the case where writing the analysis once and emitting per-type code pays off. Reach for one, too, when you need to remove runtime reflection to make an app AOT- or trimming-compatible, or when you want to enforce a pattern at compile time and surface mistakes as build errors rather than runtime failures.
Do not write one when something simpler will do. If a runtime factory, a small T4 template, or just writing the code by hand solves the problem, those carry a fraction of the authoring and debugging burden. Generators have a real learning curve and genuinely awkward debugging, and a generator is a dependency every consumer's IDE has to run. Just as importantly, check whether the ecosystem already has one: Mapperly covers object mapping, and there is a deep bench of [GenerateX] libraries for equality, builders, enums, and DI registration. Writing your own is justified when your boilerplate is specific to your codebase, not when you are reinventing a solved problem.
Sharp Edges
The failures here are distinctive. The most common and most damaging is breaking incrementality by putting non-equatable types -- a Compilation, an ISymbol, a syntax node -- into the pipeline; the result is a generator that re-runs constantly and slows the IDE for everyone who references it, with no error to point at. Generators cannot see each other's output: there is no chaining, so one generator cannot consume the source another emitted in the same compilation, and designs that assume otherwise quietly fail. Debugging means either attaching a debugger to the compiler process or, far more practically, unit-testing the generator with CSharpGeneratorDriver, which runs it in-process against a snippet of source so you can assert on the output. Report problems through a Diagnostic rather than throwing -- a generator that throws breaks the build opaquely, with a stack trace from inside the compiler instead of a clear message at the user's code. Generated output must be deterministic, or you poison incremental builds and caching. And every generator is coupled to a Microsoft.CodeAnalysis version: it targets a specific Roslyn API surface, so a generator built against a newer analyzer than the consumer's SDK provides will fail to load.