Source Generators are Roslyn components that produce C# source files at compile time.
They run during compilation, not at runtime — zero startup cost, full type safety.
The modern approach is IIncrementalGenerator — caches results between builds.
Generated code is compiled into your assembly as if you wrote it manually.
Biggest mistake: assuming generated code must use string concatenation — use SyntaxGenerator API.
✦ Definition~90s read
What is Source Generators in C#?
C# Source Generators are a compiler feature introduced in .NET 5 that let you inspect user code during compilation and emit new C# source files that get compiled into the same assembly. Unlike runtime reflection or IL weaving, source generators run at compile time inside the Roslyn compiler pipeline, producing code that's fully visible, debuggable, and trimmable.
★
Imagine you're a chef who has to hand-write the same recipe card every single day before you can start cooking.
They solve the problem of boilerplate code — DTOs, mappers, serialization stubs, dependency injection registrations — without the performance cost or fragility of runtime code generation. The key tradeoff: you're writing code that writes code, and the generator must be stateless, deterministic, and incremental to avoid recompiling everything on every keystroke.
In the .NET ecosystem, source generators compete with T4 templates (which run outside the compiler and don't participate in incremental builds), reflection-based libraries like AutoMapper (which pay runtime costs and can fail silently on trimming), and post-compilation tools like Fody (which modify IL after compilation, making debugging harder). Source generators win when you need compile-time guarantees, IDE support (IntelliSense on generated code), and AOT compatibility.
They lose when your generation logic depends on runtime data, requires complex state, or needs to modify existing code rather than add new files.
A critical detail often missed: after .NET 8, the incremental generator pipeline became mandatory — the old ISourceGenerator API still compiles but runs as a non-incremental fallback, silently killing IDE performance and causing builds to re-run the generator on every keystroke. If you're upgrading a generator from .NET 6 or 7 and didn't migrate to IIncrementalGenerator, your generator appears to work but actually degrades the developer experience to unusable levels.
This is the silent failure the article addresses.
Plain-English First
Imagine you're a chef who has to hand-write the same recipe card every single day before you can start cooking. Now imagine hiring an assistant who watches what ingredients you have, then automatically prints all the recipe cards before you even walk into the kitchen. That assistant is a Source Generator — it runs before your app starts, reads your existing code, and generates new C# files so you never have to write the same boilerplate again. The generated code is real, compiled, fully typed C# — not strings, not reflection magic at runtime.
Every non-trivial C# codebase eventually drowns in boilerplate. You've written the same INotifyPropertyChanged implementation fifteen times. You've hand-rolled JSON serialization methods that drift out of sync with your models. You've copy-pasted mapping code between domain objects and DTOs until maintaining it felt like archaeology. The problem isn't laziness — it's that the language used to give you no good alternative between writing it yourself and paying the runtime cost of reflection or dynamic code generation.
Source Generators, introduced in .NET 5 and significantly improved through .NET 6, 7, and 8 with Incremental Generators, solve this at the right layer: compile time. Instead of generating code while your application runs and slowing down startup, or using T4 templates that live outside the build pipeline and break silently, Source Generators are first-class Roslyn components. They receive a full semantic model of your code, produce new C# source files, and those files are compiled into your assembly as if you wrote them yourself. Zero runtime overhead. Full IntelliSense. Full debuggability.
By the end of this article you'll understand how the Roslyn compilation pipeline hands control to your generator, how to build both a basic ISourceGenerator and the modern IIncrementalGenerator, how to handle real-world scenarios like caching, diagnostics, and multi-targeting, and — critically — the production gotchas that will bite you if you skip them. You'll walk away able to write a production-grade generator and explain it confidently in an interview.
What Are C# Source Generators?
Source Generators are components that plug into the Roslyn compiler pipeline. They receive the full compilation object — syntax trees, semantic model, references — and produce additional source files that become part of the same compilation. Unlike code generation tools that run as a separate build step (T4, custom MSBuild tasks), generators run inside the compiler. That means they have access to type information, can read attributes, and can produce code that the compiler type-checks in the same pass.
The magic happens around the CompilationUnit stage: after the compiler parses all files and binds symbols, it invokes registered generators before emitting IL. The generated files are merged into the compilation as if they were written by hand. No post-processing, no second compilation step.
The key distinction: you should never generate code that could be written as a direct method call or a generic. Generators shine when code must be repeated across types that can't share a common base class, or when the pattern depends on metadata unavailable at the generic level.
usingMicrosoft.CodeAnalysis;
usingMicrosoft.CodeAnalysis.Text;
usingSystem.Text;
namespace io.thecodeforge.generators
{
[Generator(LanguageVersion.CSharp12)]
publicsealedclassHelloWorldGenerator : IIncrementalGenerator
{
publicvoidInitialize(IncrementalGeneratorInitializationContext context)
{
// Register a pipeline that produces a single constant source file
context.RegisterSourceOutput(context.CompilationProvider,
(spc, compilation) =>
{
string source = @"// <auto-generated/>namespace io.thecodeforge
{
publicstaticclassGreeter
{
publicstaticstringHello() => ""Hello from a SourceGenerator!"";
}
}";
spc.AddSource("Greeter.g.cs", SourceText.From(source, Encoding.UTF8));
});
}
}
}
Output
// File: Greeter.g.cs (generated)
namespace io.thecodeforge
{
public static class Greeter
{
public static string Hello() => "Hello from a Source Generator!";
}
}
🔥GeneratorAttribute Language Version
Always specify the [Generator(LanguageVersion)] attribute. Without it, your generator may not activate in newer SDK versions. Use the latest C# version your target frameworks support.
📊 Production Insight
The generator runs in a sandboxed environment — no access to the file system by default. If you need to read external files, you must configure additional files in the project.
The compilation object passed to the generator is a snapshot — don't cache it across builds unless you implement IIncrementalGenerator properly.
Always wrap output in <auto-generated/> to prevent IDE warnings from the generated code.
🎯 Key Takeaway
Source Generators run inside the compiler, not before it
Use IIncrementalGenerator for anything beyond a demo
Always add language version to the generator attribute
Choosing Between ISourceGenerator and IIncrementalGenerator
IfGenerator will process static input (no caching needed)
→
UseUse ISourceGenerator for simplicity, but note it's deprecated in .NET 9+.
IfGenerator reads source attributes or syntax trees and may re-run on partial changes
→
UseUse IIncrementalGenerator. Required for production-grade performance.
IfGenerator needs to handle .NET Standard 2.0 and older SDKs
An incremental generator consists of a pipeline of steps. Each step takes an input (like a SyntaxNode or SemanticModel) and produces a value. The framework caches these values using equality comparers you provide. When a source file changes, only the affected pipeline branches re-execute.
The pipeline starts in the Initialize method where you register sources of data: CompilationProvider, SyntaxProvider, etc. The SyntaxProvider filters syntax nodes (e.g., all classes with a specific attribute). Then you transform those nodes into your model (like a DTO description), combine with the compilation, and finally register the source output.
Here's a real example that generates a TypeScript-like partial class for INotifyPropertyChanged without using reflection at runtime.
[Generator(LanguageVersion.CSharp12)]
publicsealedclassNotifyPropertyChangedGenerator : IIncrementalGenerator
{
publicvoidInitialize(IncrementalGeneratorInitializationContext context)
{
// Step 1: Find all classes with [GenerateNotify] attributeIncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
.CreateSyntaxProvider(
predicate: (node, _) => node isClassDeclarationSyntax cds &&
cds.AttributeLists.Count > 0,
transform: (ctx, _) => ctx.NodeasClassDeclarationSyntax)
.Where(c => c is not null)!;
// Step 2: For each class, extract property info using semantic modelIncrementalValuesProvider<NotifyModel> models = classDeclarations
.Select((cds, ct) => ExtractModel(cds, ct));
// Step 3: Combine and register output
context.RegisterSourceOutput(models,
(spc, model) => GeneratePartialClass(spc, model));
}
privateNotifyModelExtractModel(ClassDeclarationSyntax cds, CancellationToken ct)
{
returnnewNotifyModel(cds.Identifier.Text);
}
privatevoidGeneratePartialClass(SourceProductionContext spc, NotifyModel model)
{
string source = $"""
// <auto-generated/>
partial class {model.ClassName} : INotifyPropertyChanged
{{
publiceventPropertyChangedEventHandlerPropertyChanged;
protectedvoidOnPropertyChanged([CallerMemberName] string name = null)
=> PropertyChanged?.Invoke(this, newPropertyChangedEventArgs(name));
}}
""";
spc.AddSource($"{model.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8));
}
privatereadonly record structNotifyModel(stringClassName);
}
Mental Model
Pipeline as Filter Map Reduce
Think of the pipeline like a Spark RDD: each transformation produces an immutable step, and the framework tracks changes at each step.
SyntaxProvider creates an initial stream of candidate nodes.
Select is lazy — it only runs when outputs need to be produced.
Each step can be cached by providing a .WithComparer() implementation.
The final RegisterSourceOutput is the action that emits generated files.
📊 Production Insight
Without a comparer, every change triggers full pipeline re-execution. For a codebase with 1000 attributes, that means re-extracting model data for all 1000 classes even if only one file changed.
Always implement IEquatable for your model types used in the pipeline. Use record structs or override Equals and GetHashCode.
Don't pass large objects — use SyntaxNode identifiers and semantic model lookups sparingly.
🎯 Key Takeaway
Incremental generators eliminate rebuild overhead when only one file changes
Always provide equality comparers for every pipeline step
test your generator's caching behavior with a hot-reload scenario
When to Use SyntaxProvider vs CompilationProvider
IfYou need to generate code based on attributes or method signatures in source
→
UseUse SyntaxProvider to filter syntax nodes then transform with semantic model.
IfYou need to generate code based on assembly-level metadata or global settings
→
UseUse CompilationProvider or combine both via .Combine() to gather cross-cutting info.
IfYou need to generate a single file per compilation (like a registry or router)
→
UseUse CompilationProvider alone. Collect all data in one step and output once.
Real-World Patterns: DTO Generation, Auto-Mapping, and Serialization
The most practical use of source generators is eliminating boilerplate that would otherwise require runtime reflection or code duplication. Three patterns dominate production code:
DTO Generation from database models – Generate DTO classes with the same properties as your EF Core entities, automatically adding JsonPropertyName attributes from column names.
Auto-Mapping – Generate extension methods that map object A to object B based on matching property names and types. No reflection, no slow runtime map building.
Serialization helpers – Generate custom JSON serializers for specific types that avoid the overhead of System.Text.Json's reflection fallback. A source generator can produce highly optimised serialization code that outperforms the built-in serialiser by 2-3x for known types.
The pattern in all three is the same: decorate a type or member with an attribute, then the generator reads that attribute and produces the corresponding code. The generated code is a regular C# file that you can inspect and debug.
io.thecodeforge.generators/DtoGenerator.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// Example: [GenerateDto(SourceType = typeof(Entity))]// Generates: EntityDto with same properties plus [JsonIgnore] for navigation properties
[AttributeUsage(AttributeTargets.Class)]
publicsealedclassGenerateDtoAttribute : Attribute
{
publicTypeSourceType { get; set; }
}
// In generator pipeline:privateIEnumerable<(stringName, ITypeSymbolType, boolIsNav)> GetProperties(INamedTypeSymbol entity)
{
foreach (var member in entity.GetMembers())
{
if (member isIPropertySymbol prop && !prop.IsStatic)
{
yieldreturn (prop.Name, prop.Type,
prop.Type.TypeKind == TypeKind.Class &&
prop.Type.SpecialType != SpecialType.System_String);
}
}
}
⚠ Performance Trap: Over-Generating for Every Type
Generating DTOs for 500 entities will bloat compilation and assembly size. Use a marker interface or filter by namespace to limit scope. The assembly size impact is permanent — every generated class lives in the output DLL.
📊 Production Insight
Source generators produce code that is compiled into the final binary. This means any bug in generated code is a compile-time error in the consuming project — good for safety, but it can hide runtime issues if you trust generation logic too much.
Generated serializers are faster than reflection but slower than hand-written per-type code. Profile before claiming gains.
When generating DTOs, be careful with navigation properties — they can cause circular references if both sides have generated DTOs.
🎯 Key Takeaway
Source generators replace reflection at compile time
Limit generation scope to avoid assembly bloat
Always test generated code with both compile and runtime integration tests
thecodeforge.io
Source Generators Csharp
Production Pitfalls and How to Avoid Them
Source generators are powerful but they come with unique production traps. Here are the ones that bite even experienced teams:
Pitfall 1: The generator runs in a partial compilation context. You cannot call System.IO.File.ReadAllText inside a generator without first registering the file as an additional file in the csproj. The generator host restricts I/O for security and determinism.
Pitfall 2: Error reporting is silent by default. If your generator throws an unhandled exception, the build may succeed with a warning. You must report errors explicitly via context.ReportDiagnostic(Diagnostic.Create(...)).
Pitfall 3: Multi-targeting confusion. If your library targets multiple frameworks, the generator must be conditionally included only for netstandard2.0 (since it runs on the analyzer host). The generated code must also be compatible with the target framework — you can't use C# 12 syntax in code deployed to .NET Framework 4.6.1.
Pitfall 4: Assembly versioning. Generated code lives in the consuming assembly. If you update the generator package, old generated files are not automatically invalidated. Use incremental generators with versioned pipeline inputs to force regeneration when the generator version changes.
Source generators run as Roslyn analyzers, which are loaded into a netstandard2.0 host. Target netstandard2.0 even if your consuming project uses .NET 8. Use LangVersion > 11 is fine — your generator can use modern C# syntax.
📊 Production Insight
Generated code that uses features not available in the target framework will cause cryptic compile errors. Always check the [SupportedPlatforms] attribute or generate platform-conditional code.
Source generators cannot reference each other. If you have multiple generators in a single project, they run independently — you cannot make one generator consume output from another.
The build performance impact of a generator that processes syntax trees for every file can be significant. Use predicate (first lambda) in CreateSyntaxProvider to pre-filter quickly without semantic model.
🎯 Key Takeaway
Report diagnostics explicitly — exceptions are swallowed
Target netstandard2.0 for the generator project
Profile pipeline predicate to avoid scanning every syntax node
Generator Crashes During Build: Decision Tree
IfGenerator crashes with NullReferenceException
→
UseCheck that you are not accessing semantic model symbols that may be null for partial types. Use null-conditional operators.
IfGenerator crashes with FileNotFoundException
→
UseThe generator assembly may be missing at compile time. Ensure it's included as an analyzer via PackageReference with PrivateAssets="all".
IfGenerator runs but produces no output
→
UseUse EmitCompilerGeneratedFiles=true to see what was generated. May be due to a missing analyzer reference in the consuming project.
When Not to Use Source Generators
Source generators are not the right tool for every problem. They add complexity to the build process and increase assembly size. Avoid them in these scenarios:
Simple runtime polymorphism – If a regular interface or abstract class solves the problem, don't bring a generator.
Dynamic code that changes during runtime – Generators are compile-time only. Use Reflection.Emit or System.Linq.Expressions.
Cross-cutting logging or tracing – Use a runtime AOP framework like Castle DynamicProxy or the .NET interceptors pattern instead of generating wrapper classes for every method.
Small projects – The setup overhead of a generator project, package dependency, and build caching complexity is not justified for a single DTO mapping.
A good heuristic: if you can solve the problem with a generic type and a few interfaces, do that first. Only reach for a generator when the pattern repeats across unrelated types and the alternative would be runtime reflection or hand-written copy-paste.
io.thecodeforge/WhenToUse.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Use a source generator if:// - You need INotifyPropertyChanged on every model class// - You generate custom JSON serializers for sealed types// - You auto-register MediatR handlers based on marker interfaces// Do NOT use a source generator for:// - Simple mapping between two known types (write a method)// - Logging around every method call (use DynamicProxy or interceptors)// - Runtime code generation based on configuration filespublicinterfaceICommand { }
publicclassCreateOrderCommand : ICommand { }
// This is overkill for a single command.// A generated handler registration is unnecessary.
💡Start Without a Generator
Write the boilerplate by hand first. Understand the exact pattern. Then write the generator to automate it. You'll produce better abstraction because you know exactly what the generated code should look like.
📊 Production Insight
A generator that does too much becomes a maintenance nightmare. If you find yourself writing complex code generation logic that reads XML files, multiple attributes, and project properties, you've gone too far.
Generated code is hard to refactor across projects. If the generator is in a NuGet package, all consumers must update to get fixes — you can't just grep and replace generated files.
🎯 Key Takeaway
Prefer interfaces and generics before reaching for generators
When in doubt, write the boilerplate once and measure the pain
A generator that saves 2 hours of typing but adds 20 hours of debugging is a net negative
How Source Generators Actually Work — The Compiler Pipeline
Stop thinking of Source Generators as magic. They're a compiler plugin. The Roslyn compiler parses your code into a syntax tree, runs semantic analysis, then hands that tree to your generator. Your generator walks the tree, extracts metadata, and spits out new syntax trees — which the compiler merges back in before emitting IL.
That's it. No runtime reflection. No IL weaving at build time. No post-compilation hacks. The generated code is first-class citizen from the start. If your generator throws, the build fails. If your generated code has a syntax error, the compiler tells you before it ever runs.
The critical detail most tutorials skip: generators run in an incremental pipeline. The compiler doesn't re-run your generator on every keystroke. It caches previous results and recomputes only when relevant input changes. That's why you implement IIncrementalGenerator, not the old ISourceGenerator. The old API kills IDE performance. The incremental API is mandatory for production use.
Your generator receives a SourceProductionContext and a pipeline of IncrementalValueProvider or IncrementalValuesProvider. These providers carry the syntax trees, compilation, or custom data you extract. Every generation step must be deterministic and side-effect free. No file I/O. No database calls. If you break that rule, the compiler can't cache your results, and your build time explodes.
IncrementalPipelineSkeleton.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// io.thecodeforge — csharp tutorialusingMicrosoft.CodeAnalysis;
usingMicrosoft.CodeAnalysis.CSharp;
usingMicrosoft.CodeAnalysis.CSharp.Syntax;
// Production rule: never use ISourceGenerator.// Implement IIncrementalGenerator or your team will hate you.
[Generator(LanguageNames.CSharp)]
publicsealedclassEnumExtensionsGenerator : IIncrementalGenerator
{
publicvoidInitialize(IncrementalGeneratorInitializationContext context)
{
// Filter for enum declarations only — don't scan every nodeIncrementalValuesProvider<EnumDeclarationSyntax> enumDeclarations =
context.SyntaxProvider.CreateSyntaxProvider(
predicate: (node, _) => node isEnumDeclarationSyntax,
transform: (ctx, _) => (EnumDeclarationSyntax)ctx.Node)
.Where(static e => e is not null);
// Only re-run when an enum definition actually changesIncrementalValueProvider<(Compilation, ImmutableArray<EnumDeclarationSyntax>)> combined =
context.CompilationProvider.Combine(enumDeclarations.Collect());
context.RegisterSourceOutput(combined, GenerateSource);
}
privatevoidGenerateSource(
SourceProductionContext context,
(CompilationCompilation, ImmutableArray<EnumDeclarationSyntax> Enums) source)
{
// generation logic here
}
}
Output
No output — this is a skeleton that compiles. The compiler emits the generated code as part of the assembly.
⚠ Production Trap: Leaky Caching
If your generator reads from disk, calls an API, or uses DateTime.Now anywhere in the pipeline, the compiler cannot cache results. Your builds will be 10x slower and IDE responsiveness tanks. Keep generation pure.
🎯 Key Takeaway
Source Generators are compile-time code factories. They don't run at runtime. They don't modify your source. They produce new source files during compilation.
Getting Started — Your First Generator Without the Fluff
Forget the "Hello World" that prints nothing. Here's the real deal: a generator that creates strongly-typed enum extensions. You need three projects: a .NET standard library for the generator, the generator project itself (targeting netstandard2.0 — not negotiable), and a consuming project that references both.
Why netstandard2.0? Because Source Generators run during compilation, not at runtime. The compiler runs on .NET Framework or .NET Core. If you target anything newer, the compiler can't load your assembly. This is the #1 mistake beginners make.
Set up your `.csproj` like this:<Project Sdk="Microsoft.NET.Sdk"> with <TargetFramework>netstandard2.0</TargetFramework>. Add the Microsoft.CodeAnalysis.CSharp and Microsoft.CodeAnalysis.Analyzers NuGet packages. That's it. No special analysis-level config. No <IsRoslynComponent> flag — that's an Analyzer thing, not a Generator thing.
Now write your generator class. It must implement IIncrementalGenerator and be decorated with [Generator]. The Initialize method sets up your pipeline. For a first pass, just grab all enum declarations and emit a partial class with a ToDisplayString() method that returns the enum name via reflection — no, that's stupid. Actually generate a switch expression. Compile-time resolution, zero reflection overhead.
Test your generator by adding a reference in your consuming project. Build. If no new files appear, check the error list. The C# compiler will tell you exactly what broke.
EnumExtensionsGenerator.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// io.thecodeforge — csharp tutorialusingMicrosoft.CodeAnalysis;
usingMicrosoft.CodeAnalysis.CSharp;
usingMicrosoft.CodeAnalysis.CSharp.Syntax;
usingSystem.Text;
[Generator(LanguageNames.CSharp)]
publicsealedclassEnumExtensionsGenerator : IIncrementalGenerator
{
publicvoidInitialize(IncrementalGeneratorInitializationContext context)
{
var enums = context.SyntaxProvider.CreateSyntaxProvider(
static (node, _) => node isEnumDeclarationSyntax,
static (ctx, _) => (EnumDeclarationSyntax)ctx.Node
).Where(static e => e is not null);
var compilationAndEnums = context.CompilationProvider.Combine(enums.Collect());
context.RegisterSourceOutput(compilationAndEnums, (spContext, source) =>
{
var (compilation, enumList) = source;
foreach (var enumDecl in enumList)
{
var model = compilation.GetSemanticModel(enumDecl.SyntaxTree);
if (model.GetDeclaredSymbol(enumDecl) is not INamedTypeSymbol symbol)
continue;
var ns = symbol.ContainingNamespace?.ToDisplayString() ?? "";
var name = symbol.Name;
var members = enumDecl.Members
.Select(m => m.Identifier.Text)
.ToList();
var sb = newStringBuilder();
sb.AppendLine($$"""
// <auto-generated/>namespace {{ns}}
{
publicstaticclass {{name}}Extensions
{
publicstaticstringToDisplayString(this {{name}} value) =>
""");
sb.AppendLine(" value switch");
sb.AppendLine(" {");
foreach (var member in members)
{
sb.AppendLine($" {{name}}.{member} => \"{member}\",");
}
sb.AppendLine(" _ => throw new ArgumentOutOfRangeException(nameof(value))");
sb.AppendLine(" };");
sb.AppendLine(" }");
sb.AppendLine("}");
spContext.AddSource($"{name}Extensions.g.cs", sb.ToString());
}
});
}
}
Output
Generated file: ColorExtensions.g.cs
// <auto-generated/>
namespace MyApp
{
public static class ColorExtensions
{
public static string ToDisplayString(this Color value) =>
value switch
{
Color.Red => "Red",
Color.Green => "Green",
Color.Blue => "Blue",
_ => throw new ArgumentOutOfRangeException(nameof(value))
};
}
}
🔥Senior Shortcut: The Header
Always start generated files with // <auto-generated/>. This tells the IDE to suppress warnings and skip analyzers on that file. Without it, you'll get CS1591 warnings on every method you generate.
🎯 Key Takeaway
Your generator project must target netstandard2.0. Your consuming project references it. No exceptions. Test by building — if no output appears, check the compiler error list.
Practical Use Case: INotifyPropertyChanged Without Pain
You've seen the INotifyPropertyChanged pattern. You've cursed the boilerplate. Here's how Source Generators kill it dead.
Problem: you have a ViewModel with 15 properties. Each property needs a backing field, a getter, a setter that calls OnPropertyChanged, and optionally a SetProperty helper. That's 45 lines of repetitive garbage per property. Hand-writing it is error-prone. Using CallerMemberName helps slightly but doesn't eliminate the copy-paste.
Solution: mark your class as partial and slap a custom attribute on it. The generator scans for classes with [AutoNotify], reads each field marked [Notify], and generates the full property-with-change-notification for you. No reflection. No runtime overhead. The generated code is as fast as if you wrote it by hand.
The generator pipeline: filter for classes with the attribute, extract fields with a specific marker attribute (or a naming convention like underscore-prefixed), then emit the property wrappers. Keep the generated code in a separate file to avoid confusion. Your source stays clean: just fields and the attribute.
This pattern scales. Extend it to generate ICommand wrappers for methods. Generate logging decorators. Generate JSON serialization contracts. Every time you catch yourself writing the same three-line pattern, ask: "Can I generate this?" If the answer is yes, you just saved future you from a bug.
Remember: Source Generators aren't magic. They're a compiler plugin that writes code you'd write anyway, but faster and with zero typos.
AutoNotifyViewModel.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// io.thecodeforge — csharp tutorialusingSystem.ComponentModel;
usingSystem.Runtime.CompilerServices;
// Source attribute — define in a common project
[AttributeUsage(AttributeTargets.Field)]
publicsealedclassNotifyAttribute : Attribute { }
// Your ViewModel stays clean: just fields and one attributepublic partial classUserViewModel : INotifyPropertyChanged
{
publiceventPropertyChangedEventHandler? PropertyChanged;
[Notify] privatestring _userName;
[Notify] privateint _loginCount;
[Notify] privatebool _isActive;
// PropertyChanged implementation is generated// The generator produces:// public string UserName { get => _userName; set => SetProperty(ref _userName, value); }// public int LoginCount { get => _loginCount; set => SetProperty(ref _loginCount, value); }// public bool IsActive { get => _isActive; set => SetProperty(ref _isActive, value); }// protected void SetProperty<T>(ref T field, T value, [CallerMemberName] string prop = null)// {// if (EqualityComparer<T>.Default.Equals(field, value)) return;// field = value;// PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(prop));// }
}
// Consumer code// var vm = new UserViewModel();// vm.PropertyChanged += (s, e) => Console.WriteLine($"{e.PropertyName} changed");// vm.UserName = "Alice"; // Output: "UserName changed"
Output
Consumer output:
UserName changed
💡Senior Shortcut: Name Your Marker Attributes Intuitively
Use [Notify] for fields, [AutoCommand] for methods. Makes the intent obvious. Don't hide the generation behind naming conventions (like fields with underscores) — attributes are explicit and compile-time checked.
🎯 Key Takeaway
Mark your partial class with a custom attribute. Mark fields with [Notify]. The generator wraps each field into a property with change notification. No runtime cost, zero boilerplate.
You Ship Generators Without Tests? Hope You Like Living Dangerously
Source generators produce code at compile time. If your generator has a bug, you don't get a runtime exception. You get a broken .g.cs file that silently corrupts your entire build. No stack trace. No breakpoint. Just confusion.
Testing generators means executing the generator against real compilation objects and asserting the output code compiles and behaves correctly. The standard approach: create a CSharpCompilation with your source files, run the generator via CSharpGeneratorDriver, and inspect the resulting syntax trees. Then compile those trees into an assembly and run unit tests against it.
Your generator should live in a project that references Microsoft.CodeAnalysis.CSharp. Test projects reference both the generator project and the compilation utilities. You don't need a test harness—just a console test fixture that builds, runs, and validates. This catches everything from missing file-scoped namespaces to broken partial method signatures before they reach production.
GeneratorTest.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// io.thecodeforge — csharp tutorialusingMicrosoft.CodeAnalysis;
usingMicrosoft.CodeAnalysis.CSharp;
var source = @"public partial class User { public string Name { get; set; } }";
var compilation = CSharpCompilation.Create("TestAssembly",
new[] { CSharpSyntaxTree.ParseText(source) },
new[] { MetadataReference.CreateFromFile(typeof(object).Assembly.Location) });
var driver = CSharpGeneratorDriver.Create(newMyGenerator());
driver.RunGeneratorsAndUpdateCompilation(compilation, outvar updatedCompilation, out _);
var generated = updatedCompilation.SyntaxTrees.Last().ToString();
Console.WriteLine(generated.Contains("partial void OnNameChanged") ? "PASS" : "FAIL");
Output
PASS
⚠ Senior Shortcut:
Never test blindly — always compile the generator's output and run assertions against it. This catches syntax errors and semantic mismatches that visual inspection never catches.
🎯 Key Takeaway
Test generators the same way you test production code: compile the output, then execute it. Anything less is gambling.
Debug Generators Without Pulling Your Hair Out
Source generators run inside the compiler process. You can't just hit F5 in your generator project. The debugger attaches to the wrong process and you stare at breakpoints that never fire. That's the default experience—and it sucks.
Use a simple trick: add a static bool field to your generator, say Debugger.Launch(). Guard it with an environment variable check so it only triggers when you're explicitly debugging. Then compile your consuming project in Debug mode with that environment variable set. The debugger attaches directly to the compiler process and hits your breakpoints.
Better yet, output generator diagnostics using the context.ReportDiagnostic() method. This surfaces issues as compiler warnings/errors in VS Error List without attaching a debugger. Combined with incremental caching, you get fast iteration: change source, rebuild, and see diagnostics update immediately. No more black-box guesswork.
Remove Debugger.Launch before shipping. If you accidentally leave it in, every build on your CI machine will pop a dialog asking for a debugger — and fail silently.
🎯 Key Takeaway
Debug generators by attaching to the compiler process with Debugger.Launch, but use diagnostics for everyday feedback. Don't guess why output changed—make the compiler tell you.
Key Takeaways
Source generators change the game by moving runtime work to compile time, but their power comes with strict constraints. You learned that an incremental generator pipeline is essential for performance—relying on caching and equality checks avoids re-running your generator unnecessarily. Real patterns like DTO generation or INotifyPropertyChanged eliminate boilerplate without reflection, yet demand rigorous testing because generated code is invisible until build time. Production pitfalls taught you to handle file paths carefully, avoid global state, and never use \(locals.currentUser. The compiler pipeline revealed that generators execute before emit, meaning they cannot access final assembly metadata. The rule stands: use generators only for repeatable, deterministic code transformations triggered by syntax or attributes. Avoid them for runtime logic, external data, or anything requiring dependency injection. Your generator is a build-time tool, not a runtime library—treat it with the same discipline as hand-written code.
Without incremental caching, each keystroke re-runs the generator, killing build performance. Always implement equality comparers.
🎯 Key Takeaway
Source generators are for compile-time deterministic code production, not runtime magic.
Next Steps
Start by shipping an incremental generator with a real project today—choose a small pain point like automatic constructor generation or partial method stubs. Then, write unit tests using Microsoft.CodeAnalysis.Testing to validate generated output against edge cases: empty inputs, nested types, and multiple files. Next, implement a registration mechanism so your generator can coexist with others without collisions—use unique source name prefixes. For performance, add an IncrementalValueProvider that tracks attribute arguments and syntax tree changes, then profile with the Source Generator Verifier tool. Finally, publish your generator as a NuGet package with a .props file that automatically adds the analyzer reference—this makes adoption seamless. Avoid the trap of over-engineering: start simple, ship early, then expand based on real usage feedback. The best generators feel invisible—they reduce boilerplate so developers focus on business logic, not ceremony.
NextSteps.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
// io.thecodeforge — csharp tutorial// Next step: publish a NuGet package with props// In .nuspec or csproj:// <files>// <file src="bin\Release\netstandard2.0\*.dll" target="analyzers\dotnet\cs" />// </files>// Auto-include via buildTransitive\YourGenerator.props:// <Project>// <ItemGroup>// <Analyzer Include="$(MSBuildThisFileDirectory)..\analyzers\dotnet\cs\YourGenerator.dll" />// </ItemGroup>// </Project>
Output
Build succeeds with auto-referenced generator
💡Production Trap:
Skipping NuGet packaging means every consumer must manually add analyzer references—automate with a props file.
🎯 Key Takeaway
Package your generator as NuGet with build props for zero-config adoption.
● Production incidentPOST-MORTEMseverity: high
The Generator That Silently Stopped Running After an SDK Update
Symptom
After updating to .NET 8, the source generator produced no output. No compilation errors, no warnings. The generated partial classes were absent, causing runtime binding failures.
Assumption
The generator is forward-compatible and will work across .NET versions without changes. The SDK update shouldn't affect our custom generator.
Root cause
The generator was built as an ISourceGenerator (not IIncrementalGenerator) and relied on the deprecated Microsoft.CodeAnalysis.Common package version 3.x. .NET 8 ships with Roslyn 4.8, which dropped support for generators that don't declare a supported language version in their [Generator] attribute. The generator didn't specify LanguageVersion.
Fix
Add an explicit [Generator(LanguageVersion.CSharp12)] attribute to the generator class, or better yet, migrate to IIncrementalGenerator which handles versioning automatically through incremental pipeline registration.
Key lesson
Always specify the generator attribute's language version target to ensure forward compatibility.
Migrate to IIncrementalGenerator for production generators — it's not optional anymore.
Add a unit test that compiles the generator and verifies output exists after each SDK version bump.
Production debug guideSymptom → Action reference for when your generator doesn't behave4 entries
Symptom · 01
Generator produces no output during build
→
Fix
Add a breakpoint in the Initialize method using Debugger.Launch(). Enable source generator debugging via <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in csproj.
Symptom · 02
Generated code causes compilation errors in consuming project
→
Fix
Inspect the emitted files in the IntermediateOutputPath (obj/) folder. Look for syntax errors. Use Roslyn's SourceGenerator.Run command from the command line to isolate.
Symptom · 03
Generator runs on every build despite incremental caching
→
Fix
Check that your IIncrementalGenerator's pipeline uses .WithComparer() on each step. Missing comparers cause full regeneration on every build.
Symptom · 04
Generator crashes but build succeeds silently
→
Fix
Wrap all pipeline operations in try/catch and call context.ReportDiagnostic() to surface errors. The generator exception is swallowed by the host.
★ Source Generator Quick Debug Cheat SheetThree commands and one config change to debug any source generator issue fast.
No generated code appears in IntelliSense−
Immediate action
Clean and rebuild the project with detailed build logging.
Commands
dotnet clean && dotnet build -v n -f net8.0
Check IntermediateOutputPath for generated files: ls obj/Debug/net8.0/generated/
Fix now
Add <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> to the csproj and check the generated folder.
Generator throws NullReferenceException+
Immediate action
Enable source generator debugging in Visual Studio.
Commands
Set environment variable ROSLYN_GENERATOR_DEBUG to 1 and restart the build.
Attach debugger to the dotnet.exe process that runs the build.
Fix now
Use Debugger.Launch() in the generator's Initialize method and rebuild from command line.
Generator output changes unexpectedly between builds+
Immediate action
Check for non-deterministic inputs like file order or hash codes.
You Ship Generators Without Tests? Hope You Like Living Dang
DebugHelper.cs
using System.Diagnostics;
Debug Generators Without Pulling Your Hair Out
TakeawayExample.cs
internal class MyIncrementalGenerator : IIncrementalGenerator
Key Takeaways
Key takeaways
1
Source generators run inside the Roslyn compiler pipeline, producing C# source files that are compiled alongside your code.
2
Always use IIncrementalGenerator for production work
it caches intermediate results and avoids full re-runs.
3
Debug by enabling EmitCompilerGeneratedFiles=true and using Debugger.Launch()
never assume the generator ran correctly.
4
Limit generation scope with predicate filtering to avoid scanning every syntax node; profile build times.
5
A generator that saves a few keystrokes but increases build time by 10 seconds is a net loss
measure before committing.
6
Generated code is real C#
test it with unit tests that compile and validate output, not just integration tests.
Common mistakes to avoid
4 patterns
×
Using ISourceGenerator instead of IIncrementalGenerator
Symptom
Full re-execution of generator on every build, even when nothing changed. Can add 30+ seconds to build time for large codebases.
Fix
Migrate to IIncrementalGenerator. Implement a pipeline with proper comparers. The incremental framework caches intermediate results.
×
Omitting [Generator(LanguageVersion)] attribute
Symptom
Generator silently stops running after SDK upgrade. No errors, but no generated code either.
Fix
Add [Generator(LanguageVersion.CSharp12)] (or your target C# version) to the generator class declaration.
×
Accessing the file system inside the generator without registering additional files
Symptom
IOException or SecurityException at compile time. The generator host blocks I/O.
Fix
In the csproj, add <AdditionalFiles Include="config.json" /> and read via context.AdditionalTextsProvider.
×
Not handling incremental caching correctly (missing comparer)
Symptom
Build times increase linearly with codebase size, even though only one file changed.
Fix
Implement IEquatable<T> for your pipeline input types and call .WithComparer(EqualityComparer<T>.Default) on each Select step.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01SENIOR
Explain the difference between ISourceGenerator and IIncrementalGenerato...
Q02SENIOR
How do you debug a source generator that is not producing output?
Q03SENIOR
Your team uses a source generator to create INotifyPropertyChanged parti...
Q04SENIOR
What are the limitations of source generators compared to runtime code g...
Q01 of 04SENIOR
Explain the difference between ISourceGenerator and IIncrementalGenerator. When would you use each?
ANSWER
ISourceGenerator is the original interface from .NET 5. It runs once per compilation and has no built-in caching. IIncrementalGenerator (introduced in .NET 6) adds a pipeline that caches intermediate outputs based on input equality. You should use IIncrementalGenerator for any production generator to avoid re-processing unchanged code. ISourceGenerator is acceptable for simple demos or generators that produce the same output regardless of input (e.g., a constant file). However, .NET 9 is deprecating ISourceGenerator, so new code should always use IIncrementalGenerator.
Q02 of 04SENIOR
How do you debug a source generator that is not producing output?
ANSWER
First, enable <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in the consuming project to see generated files in the obj folder. If no files appear, check that the generator project targets netstandard2.0 and references the correct Roslyn packages. Insert Debugger.Launch() in the Initialize method and rebuild from the command line — Visual Studio will prompt to attach a debugger. Also add a Diagnostic with severity Error in the generator to surface any exceptions (generator exceptions are swallowed otherwise).
Q03 of 04SENIOR
Your team uses a source generator to create INotifyPropertyChanged partial classes. After moving from .NET 7 to .NET 8, the generated code no longer appears. No build errors. What went wrong and how do you fix it?
ANSWER
The most likely cause is that the generator class lacks the [Generator(LanguageVersion)] attribute, or it uses an outdated Roslyn package version. In .NET 8, Roslyn requires an explicit language version attribute to activate generators. Fix by adding [Generator(LanguageVersion.CSharp12)] to the generator class declaration. Also ensure the generator project targets netstandard2.0 and references Microsoft.CodeAnalysis.CSharp version 4.8+ (matching .NET 8's Roslyn 4.8). If the problem persists, check that the consuming project has an analyzer reference to the generator DLL.
Q04 of 04SENIOR
What are the limitations of source generators compared to runtime code generation?
ANSWER
Source generators run at compile time and cannot react to runtime data. They can only generate code based on the static structure of the source files and configuration. They cannot read environment variables, configuration files that are not registered as additional files, or runtime user input. Also, source generators cannot generate code that depends on other generated code within the same compilation — they see only the original source. Runtime code generation (Reflection.Emit, System.Linq.Expressions) can create code dynamically based on conditions, but carries a startup cost and loses compile-time type safety.
01
Explain the difference between ISourceGenerator and IIncrementalGenerator. When would you use each?
SENIOR
02
How do you debug a source generator that is not producing output?
SENIOR
03
Your team uses a source generator to create INotifyPropertyChanged partial classes. After moving from .NET 7 to .NET 8, the generated code no longer appears. No build errors. What went wrong and how do you fix it?
SENIOR
04
What are the limitations of source generators compared to runtime code generation?
SENIOR
FAQ · 4 QUESTIONS
Frequently Asked Questions
01
What happens if my source generator throws an exception during build?
The exception is caught by the Roslyn host and a warning is emitted, but the build may still succeed with the generated output missing. Always report errors explicitly using context.ReportDiagnostic(Diagnostic.Create(...)) with severity Error, which will cause the build to fail.
Was this helpful?
02
Can a source generator see other generated code from the same compilation?
No. Generators run before the output of other generators is available. Each generator sees only the original source files and the compilation's semantic model based on that original code. If you need to combine outputs from multiple generators, you must use a separate build step or a runtime aggregation pattern.
Was this helpful?
03
Do source generators work with Blazor WebAssembly or Xamarin?
Yes, as long as the generator targets netstandard2.0 and the consuming project supports analyzers. Blazor WebAssembly works. Xamarin may require additional configuration because of its historical build pipeline. Starting with .NET MAUI, generator support is equivalent to .NET 6+.
Was this helpful?
04
How do I unit test a source generator?
Add the Microsoft.CodeAnalysis.Testing package to your test project. Use the VerifySourceGenerator or CSharpSourceGeneratorVerifier<YourGenerator> to create a test compilation that includes sample source files, run the generator, and assert on generated output. This is the reliable way to test generator correctness without running the full build pipeline.