Reflection asks "what does this type look like?" at runtime, every time, at a real cost. A source generator asks the same question once — at compile time — and writes the answer down as ordinary code.
The previous two lessons built up a real, working pattern: attributes as declarative metadata, reflection as the mechanism that reads them back and acts on them at runtime. That pattern is genuinely useful — and it has a genuine cost, paid on every single call, that matters once you're in a hot path or a startup-time-sensitive, AOT-compiled application.
What if the same discovery — "which properties does this type have, and what do their names/attributes say" — could happen exactly once, at compile time, instead of over and over at runtime? That's precisely what a source generator does. You've almost certainly used one already without realizing it: System.Text.Json's source-generated serialization mode, and the [LoggerMessage]-based logging pattern in modern ASP.NET Core code, are both source generators quietly doing this trick.
In this lesson, at an introductory level: what a source generator actually is, why it exists (directly motivated by reflection's runtime cost and AOT-compatibility limits), a real example you've likely already used, and the basic mental model of "runs at compile time, adds files to your compilation" — without a deep API walkthrough of writing one yourself, which belongs to a dedicated, more advanced lesson.
A source generator is a piece of code that plugs into the C# compiler itself. While your project is being compiled, the generator gets to look at your actual source code — your classes, your attributes, your method signatures — and, based on what it sees, write brand-new C# source files that get compiled right alongside the code you wrote by hand. You never see these generated files unless you go looking for them, but they're real, ordinary C# — compiled, type-checked, and just as fast as anything you typed yourself.
A source generator is a component (most commonly implemented as an IIncrementalGenerator) that hooks into the Roslyn (C# compiler) compilation pipeline. During compilation, it receives access to the syntax and semantic model of your source code, can inspect it (typically looking for specific attributes, type shapes, or patterns), and produces additional C# source text that the compiler adds to the same compilation and compiles normally — before the final assembly is produced. The generated code becomes part of your program exactly as if you'd written it by hand, but it was written for you, automatically, based on what your actual code looks like.
Reflection gives you enormous flexibility — as the previous two lessons showed, it's what makes serializers, DI containers, and validators work generically. But that flexibility isn't free, in two concrete ways:
Developers needed a way to get the same generic, "works automatically based on what my type looks like" convenience that reflection provides — without paying reflection's runtime cost on every call, and without the AOT-compatibility problems that come from needing to discover and invoke code dynamically at runtime.
Move the discovery work from runtime to compile time. A source generator inspects your types once, while the project is being built — the exact same kind of question reflection would otherwise ask at runtime ("what properties does this type have?") — and, instead of answering that question dynamically on every call, it writes the answer down as ordinary, hand-looking, directly-callable C# code. That generated code has zero reflection overhead and is fully visible to the AOT compiler, because it's just... normal code, sitting in your compilation like anything else.
[JsonSerializable(typeof(Customer))]
public partial class AppJsonContext : JsonSerializerContext { }
[JsonSerializable] attribute here is the "marker" — it tells the generator which type to generate serialization code for. This is directly connected to the previous lesson: attributes as declarative metadata, now read by a compile-time consumer instead of a runtime one.AppJsonContext, sees the [JsonSerializable(typeof(Customer))] marker, and looks at Customer's actual properties — Name, Balance, whatever they are — using the compiler's own understanding of your code (not reflection; the compiler already knows this information while it's compiling).WriteCustomer(Utf8JsonWriter writer, Customer value) method that directly reads value.Name and value.Balance and writes them out, with no reflection involved at all. This generated file becomes part of the AppJsonContext partial class.AppJsonContext.Default.Customer-based serialization is just an ordinary, direct method call — as fast as anything hand-written, and fully visible to an AOT compiler.System.Text.Json's source-generated serialization mode, the most common source generator most .NET developers encounter directly:
public class Customer
{
public string Name { get; set; } = "";
public decimal Balance { get; set; }
}
// The marker: a partial class, attributed with the types to generate serialization code for
[JsonSerializable(typeof(Customer))]
public partial class AppJsonContext : JsonSerializerContext { }
// Usage — looks almost identical to the plain reflection-based API...
var customer = new Customer { Name = "Alice", Balance = 100m };
string json = JsonSerializer.Serialize(customer, AppJsonContext.Default.Customer);
// ...but under the hood, AppJsonContext.Default.Customer resolves to GENERATED, non-reflective
// serialization code that the source generator wrote for Customer specifically, at compile time.
Meaning: Compare this to plain JsonSerializer.Serialize(customer) with no context — that call uses reflection at runtime to discover Customer's properties, exactly like the reflection lesson described. The source-generated version does that same discovery once, at compile time, and AppJsonContext ends up containing real, generated, directly-callable serialization code for Customer — no runtime property discovery left to do at all.
A second real, widely-used source generator: [LoggerMessage]-based logging, common in modern ASP.NET Core code, which replaces a reflection- and boxing-heavy logging call pattern with generated, allocation-minimal code.
public partial class OrderService
{
private readonly ILogger<OrderService> _logger;
[LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} shipped to {Destination}")]
partial void LogOrderShipped(int orderId, string destination);
public void ShipOrder(int orderId, string destination)
{
// ...shipping logic...
LogOrderShipped(orderId, destination); // looks like an ordinary method call — because it now IS one
}
}
You write only the partial void LogOrderShipped(...) declaration and the [LoggerMessage] attribute describing the log message template — you never write the method body. The source generator sees the attribute, inspects the method's declared parameters (orderId, destination), and generates the actual implementation: efficient, allocation-minimal logging code matching the message template to the parameters, with no runtime string-formatting or reflection-based argument boxing involved. Calling LogOrderShipped(orderId, destination) is, after compilation, exactly as fast as calling any ordinary method you wrote entirely by hand — because, after the generator runs, that's precisely what it is.
Reflection is like a live interpreter standing beside a speaker, translating every single sentence in real time, for every single listener, every single time the speech is given. It's flexible — it works for a speech nobody's heard before — but it's genuinely slower than reading, and it has to be redone from scratch at every performance.
A source generator is like translating the entire speech into a printed script once, ahead of time, before the event even starts. Every audience member afterward just reads the printed script directly — no live interpretation happening at all during the actual event. The upfront translation work (the generator running at compile time) is exactly the same kind of work the live interpreter does — reading the source and producing a translation — it's just done once, in advance, instead of repeatedly, live, under time pressure.
IIncrementalGenerator, syntax providers, incremental pipelines for build performance) is a substantial topic with its own dedicated, more advanced lesson elsewhere. The goal here is just the mental model: compile-time inspection in, compile-time-generated ordinary code out.This is worth stating plainly because the name invites the mistake: a source generator never runs as part of your deployed application. All of its work happens on the machine (or build server) compiling your project. By the time you have a finished executable or published application, the generator has already done everything it's going to do — what ships is ordinary, already-generated C#, compiled to ordinary IL, with the generator itself nowhere in the runtime picture at all.
A tool that must genuinely discover and act on types it has never seen before, chosen dynamically at runtime (a plugin system loading arbitrary assemblies at startup, for instance) still needs reflection — a source generator can't generate code for a type it doesn't know exists yet at compile time. Source generators shine specifically when the relevant types are known up front, in your source code, which covers a large share — though not all — of what reflection is used for today.
Because the generated files genuinely exist and go through genuine compilation, most tooling lets you view them directly (for example, IDE features to inspect generated files, or MSBuild settings to emit them to disk). This is quite different from reflection's runtime behavior, which has no equivalent "look at the generated code" step — there's simply no generated code to look at, because reflection discovers everything dynamically, on the fly.
Expecting [JsonSerializable(typeof(...))]-style generation to somehow work for a type loaded dynamically from a plugin assembly the generator never saw at compile time. Source generators require compile-time knowledge of the target types — for genuinely dynamic scenarios, reflection (or a hybrid approach) is still the right tool.
partial Writing [LoggerMessage(...)] on a method inside a non-partial class and being confused why the generator's contribution doesn't compile alongside it. Many source generators (including [LoggerMessage]) rely on partial classes/methods specifically so the hand-written declaration and the generator's contribution can be split across two files that the compiler merges — this is a real, common setup requirement worth double-checking.
Assuming that because a library offers a source-generated mode, reflection is entirely gone from it everywhere. Many libraries (like System.Text.Json) offer both a reflection-based mode (maximally flexible, works with types not registered ahead of time) and a source-generated mode (faster, AOT-friendly, requires upfront registration) — you often choose which one fits your scenario, rather than one having fully replaced the other across the board.
System.Text.Json's) for hot paths — high-throughput serialization, logging in a busy web application — where the compile-time-known types make it a strict upgrade over the reflection-based equivalent.partial declarations, registering a context) isn't worth it for a small, non-hot-path use case.System.Text.Json's source-generated serialization context and [LoggerMessage]-based logging are both real, widely-used source generators you've likely benefited from without noticing.You've seen what a source generator is, why it exists, and two real examples you may have already used. Let's check your understanding.
1. When does a source generator's code actually run?
Correct: B
Why B is correct: This is the central distinction from reflection — a source generator does all of its work during compilation. By the time your application is running, the generator has already finished; what's running is ordinary, already-generated code.
Why A is incorrect: This describes how reflection-based discovery works (lazily, at runtime) — the exact behavior a source generator is designed to avoid, as covered in "Common Confusion."
Why C is incorrect: Source generators have nothing to do with exception handling — they run unconditionally as a normal part of every build.
Why D is incorrect: There's no generator activity happening "in the background" once the application is running — the generator's job is finished the moment the build completes.
Reinforcement: "Compile time, not runtime" is the single most important fact about source generators — it's the direct source of both their performance benefit and their AOT-compatibility.
2. Why does source-generated JSON serialization avoid the runtime performance cost that plain reflection-based JsonSerializer.Serialize(obj) pays?
Correct: B
Why B is correct: The generator moves exactly the work described in the reflection lesson — discovering a type's properties — from runtime (paid every call) to compile time (paid once). The generated code that results is ordinary, direct property access, with none of reflection's per-call overhead.
Why A is incorrect: There's nothing about threading involved here — the performance win is about eliminating reflective lookup and boxing, not about parallelism.
Why C is incorrect: The generator doesn't change the JSON output format or compression — it changes how the serialization code itself is produced and executed.
Why D is incorrect: JsonSerializerContext holds generated, in-memory serialization code — there's no database involved anywhere in this mechanism.
Reinforcement: The performance win is entirely about when the "what does this type look like" question gets answered — once at compile time, versus every time at runtime.
3. A plugin system needs to load assemblies chosen by the user at runtime and discover types inside them that were completely unknown when the host application was compiled. Is a source generator a good fit for this specific requirement?
Correct: B
Why B is correct: As covered in "Common Confusion" and "Common Mistakes," source generators require the relevant types to be known at compile time. A plugin loaded dynamically at runtime, with types the host never saw while compiling, is exactly the scenario reflection remains the right tool for.
Why A is incorrect: A source generator's inspection window is strictly the compile-time compilation — it has no visibility into assemblies loaded later, at runtime, that didn't exist as part of that compilation.
Why C is incorrect: The source language of the plugin assembly is irrelevant — the fundamental limitation is about compile-time versus runtime knowledge, not language.
Why D is incorrect: This reverses the relationship entirely — source generators are a newer complement to reflection for compile-time-known scenarios, not a wholesale replacement, and reflection remains fully relevant for genuinely dynamic cases like this one.
Reinforcement: Source generators and reflection solve overlapping but distinct problems — compile-time-known types are a great fit for generators; genuinely dynamic, runtime-discovered types still need reflection.
You now have the mental model for source generators, and you can see exactly how they connect back to reflection's real runtime cost and attributes' role as declarative metadata — the throughline for this entire module.
dotnetmadeeasy.com — Learn C# and .NET, the right way.