Mid-level 11 min · March 06, 2026

C# Source Generators — Silent Failure After .NET 8 Update

After updating to .NET 8, source generators may silently stop producing output.

N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.

Follow
Production
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.

io.thecodeforge.generators/HelloWorldGenerator.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
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace io.thecodeforge.generators
{
    [Generator(LanguageVersion.CSharp12)]
    public sealed class HelloWorldGenerator : IIncrementalGenerator
    {
        public void Initialize(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
{
    public static class Greeter
    {
        public static string Hello() => ""Hello from a Source Generator!"";
    }
}";
                    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
UseUse ISourceGenerator with [Generator] attribute targeting CSharp9. Incremental generators require .NET 6+.
C# Source Generators Pipeline After .NET 8 THECODEFORGE.IO C# Source Generators Pipeline After .NET 8 Flow from generator creation to silent failure risks Incremental Generator Pipeline Register via [Generator] attribute on class Compilation Input Syntax trees, semantic model, options Transform & Emit Generate DTOs, mappers, INotifyPropertyChanged Silent Failure Risk No output if pipeline not incremental Production Pitfall Forgetting RegisterOutputs or caching ⚠ Missing RegisterOutputs causes no generated code Always call context.RegisterSourceOutput in Execute THECODEFORGE.IO
thecodeforge.io
C# Source Generators Pipeline After .NET 8
Source Generators Csharp

Building an Incremental Generator Pipeline

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.

io.thecodeforge.generators/NotifyPropertyChangedGenerator.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
[Generator(LanguageVersion.CSharp12)]
public sealed class NotifyPropertyChangedGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Find all classes with [GenerateNotify] attribute
        IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: (node, _) => node is ClassDeclarationSyntax cds &&
                    cds.AttributeLists.Count > 0,
                transform: (ctx, _) => ctx.Node as ClassDeclarationSyntax)
            .Where(c => c is not null)!;

        // Step 2: For each class, extract property info using semantic model
        IncrementalValuesProvider<NotifyModel> models = classDeclarations
            .Select((cds, ct) => ExtractModel(cds, ct));

        // Step 3: Combine and register output
        context.RegisterSourceOutput(models,
            (spc, model) => GeneratePartialClass(spc, model));
    }

    private NotifyModel ExtractModel(ClassDeclarationSyntax cds, CancellationToken ct)
    {
        return new NotifyModel(cds.Identifier.Text);
    }

    private void GeneratePartialClass(SourceProductionContext spc, NotifyModel model)
    {
        string source = $"""
// <auto-generated/>
partial class {model.ClassName} : INotifyPropertyChanged
{{
    public event PropertyChangedEventHandler PropertyChanged;
    protected void OnPropertyChanged([CallerMemberName] string name = null)
        => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}}
""";
        spc.AddSource($"{model.ClassName}.g.cs", SourceText.From(source, Encoding.UTF8));
    }

    private readonly record struct NotifyModel(string ClassName);
}
Pipeline as Filter Map Reduce
  • 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:

  1. DTO Generation from database models – Generate DTO classes with the same properties as your EF Core entities, automatically adding JsonPropertyName attributes from column names.
  2. 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.
  3. 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)]
public sealed class GenerateDtoAttribute : Attribute
{
    public Type SourceType { get; set; }
}

// In generator pipeline:
private IEnumerable<(string Name, ITypeSymbol Type, bool IsNav)> GetProperties(INamedTypeSymbol entity)
{
    foreach (var member in entity.GetMembers())
    {
        if (member is IPropertySymbol prop && !prop.IsStatic)
        {
            yield return (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

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.

io.thecodeforge.generators/GeneratorProject.csprojMARKUP
1
2
3
4
5
6
7
8
9
10
11
12
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>12</LangVersion>
    <EnforceAnalyzer>true</EnforceAnalyzer>
    <IncludeBuildOutput>false</IncludeBuildOutput>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.10.0" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
  </ItemGroup>
</Project>
Generator Project Must Target netstandard2.0
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 files

public interface ICommand { }
public class CreateOrderCommand : 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 tutorial

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;

// Production rule: never use ISourceGenerator.
// Implement IIncrementalGenerator or your team will hate you.
[Generator(LanguageNames.CSharp)]
public sealed class EnumExtensionsGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Filter for enum declarations only — don't scan every node
        IncrementalValuesProvider<EnumDeclarationSyntax> enumDeclarations =
            context.SyntaxProvider.CreateSyntaxProvider(
                predicate: (node, _) => node is EnumDeclarationSyntax,
                transform: (ctx, _) => (EnumDeclarationSyntax)ctx.Node)
            .Where(static e => e is not null);

        // Only re-run when an enum definition actually changes
        IncrementalValueProvider<(Compilation, ImmutableArray<EnumDeclarationSyntax>)> combined =
            context.CompilationProvider.Combine(enumDeclarations.Collect());

        context.RegisterSourceOutput(combined, GenerateSource);
    }

    private void GenerateSource(
        SourceProductionContext context,
        (Compilation Compilation, 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 tutorial

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Text;

[Generator(LanguageNames.CSharp)]
public sealed class EnumExtensionsGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var enums = context.SyntaxProvider.CreateSyntaxProvider(
            static (node, _) => node is EnumDeclarationSyntax,
            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 = new StringBuilder();
                sb.AppendLine($$"""
                // <auto-generated/>
                namespace {{ns}}
                {
                    public static class {{name}}Extensions
                    {
                        public static string ToDisplayString(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 tutorial

using System.ComponentModel;
using System.Runtime.CompilerServices;

// Source attribute — define in a common project
[AttributeUsage(AttributeTargets.Field)]
public sealed class NotifyAttribute : Attribute { }

// Your ViewModel stays clean: just fields and one attribute
public partial class UserViewModel : INotifyPropertyChanged
{
    public event PropertyChangedEventHandler? PropertyChanged;

    [Notify] private string _userName;
    [Notify] private int _loginCount;
    [Notify] private bool _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 tutorial

using Microsoft.CodeAnalysis;
using Microsoft.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(new MyGenerator());
driver.RunGeneratorsAndUpdateCompilation(compilation, out var 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.

DebugHelper.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — csharp tutorial

using System.Diagnostics;

[Generator]
public class MyGenerator : ISourceGenerator
{
    public void Initialize(GeneratorInitializationContext context) { }

    public void Execute(GeneratorExecutionContext context)
    {
        if (Environment.GetEnvironmentVariable("DEBUG_GENERATOR") == "1")
            Debugger.Launch();

        context.ReportNoConfigDiagnostic(
            Diagnostic.Create(
                new DiagnosticDescriptor("GEN001", "Debug", "Generator ran", "Debug", DiagnosticSeverity.Info, true),
                Location.None));
    }
}
Output
Warning GEN001: Generator ran (compiler output)
Production Trap:
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.

TakeawayExample.csCSHARP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// io.thecodeforge — csharp tutorial
// Key takeaway: always cache and compare inputs
internal class MyIncrementalGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitContext context)
    {
        var pipeline = context.SyntaxProvider
            .CreateSyntaxProvider(
                (node, _) => node is ClassDeclarationSyntax,
                (ctx, _) => ctx.Node)
            .Collect()
            .SelectMany((classes, _) => classes.Distinct());

        context.RegisterSourceOutput(pipeline, (spc, classDecl) =>
        {
            var source = $"// Generated from {classDecl.Identifier}\n";
            spc.AddSource(classDecl.Identifier.Text, source);
        });
    }
}
Output
// Generated from MyClass
Production Trap:
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.
Commands
dotnet build --force (to ignore incremental cache)
Compare generated files with diff tool against previous known-good version.
Fix now
Implement IEquatable<InputType> in your generator pipeline inputs to ensure stable hashing.
Source Generator vs Alternative Code Generation Techniques
TechniqueRuntime OverheadType SafetyBuild Time ImpactDebugging ExperienceMaintenance Complexity
Source GeneratorZero (compile-time only)Full (generated code is compiled)Moderate (adds to compilation time)Can debug generated code if EmitCompilerGeneratedFiles=trueLow to medium (generator is a separate project)
Reflection (e.g., PropertyChanged.Fody)Low to moderate (MethodBase.GetCurrentMethod, etc.)Partial (runtime errors if types mismatch)MinimalHard (runtime weaving tool)Medium (tool configuration)
T4 TemplatesNone (generated at design time)Full if regenerated before buildMinimal (manual or before-build)Good (generated file is in source)Low to medium (template syntax)
Runtime Code Generation (Reflection.Emit)High (compilation on first use)None (runtime exception if IL is wrong)None (runtime only)Very hard (cannot debug emitted IL)High (IL is fragile)

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.
FAQ · 4 QUESTIONS

Frequently Asked Questions

01
What happens if my source generator throws an exception during build?
02
Can a source generator see other generated code from the same compilation?
03
Do source generators work with Blazor WebAssembly or Xamarin?
04
How do I unit test a source generator?
N
Naren Founder & Principal Engineer

20+ years shipping production .NET services in enterprise systems. Notes here come from systems that actually shipped.

Follow
Verified
production tested
May 23, 2026
last updated
1,554
articles · all by Naren
🔥

That's C# Advanced. Mark it forged?

11 min read · try the examples if you haven't

Previous
Middleware Pipeline in .NET
13 / 15 · C# Advanced
Next
Unsafe Code in C#