
Learn C# source generators to automate code generation at compile time. Step-by-step tutorial with examples, best practices, and real-world use cases.
What Are C# Source Generators?
C# source generators let you inspect your code during compilation and automatically produce new C# files that become part of your project — no manual coding, no runtime reflection, no post-build scripts. Introduced with .NET 5 and Roslyn, they run inside the compiler itself, giving you a compile-time hook to eliminate boilerplate at the source.
If you have ever written the same property-changed notification, the same mapping method, or the same serialization logic across dozens of classes, source generators in C# solve that problem permanently. The compiler writes the code for you, every time you build.
In this tutorial you will build a working source generator from scratch, understand the API surface, learn the patterns that scale, and avoid the mistakes that waste hours of debugging.
Why Use Source Generators Instead of Reflection?
Developers often reach for reflection to solve boilerplate problems. Reflection works, but it carries costs that compound in production:
- Performance: Reflection discovers types at runtime. Source generators resolve everything at compile time — zero runtime overhead.
- AOT compatibility: Native AOT and trimming break reflection-heavy code. Generated code survives both because it is plain C#.
- Type safety: Reflection returns
object. Generated code is strongly typed, so the compiler catches mistakes before you ship. - Debuggability: You can view, step through, and set breakpoints in generated code. Reflection-based logic is opaque at debug time.
- IntelliSense: Generated members appear in your IDE immediately. Reflection-based members do not.
The .NET team itself uses source generators heavily. System.Text.Json, LoggerMessage, regex, and the minimal API framework all rely on Roslyn source generators to eliminate runtime reflection and improve startup time.
How C# Source Generators Work — The Compilation Pipeline
A source generator is a .NET Standard 2.0 class library that the compiler loads as an analyzer. During compilation, the Roslyn compiler invokes your generator in two phases:
- Initialization: You register which syntax nodes you care about (classes with a specific attribute, methods with a certain signature, etc.).
- Execution: For each matching node the compiler found, you receive the semantic model and produce source code strings that get added to the compilation.
The generated files exist only in memory during compilation. They do not appear in your project folder unless you configure EmitCompilerGeneratedFiles. But they are fully visible in your IDE, and the compiler treats them exactly like hand-written code.
Incremental Generators vs. Source Generators
The original ISourceGenerator interface from .NET 5 runs on every keystroke in the IDE, which can slow down IntelliSense. The newer IIncrementalGenerator interface (introduced in .NET 6) uses a pipeline model with caching — it only re-runs when the inputs it depends on actually change. Always use IIncrementalGenerator for new work.
Setting Up Your First C# Source Generator Project
You need two projects: the generator itself (a .NET Standard 2.0 class library) and a consuming project that references it.
Step 1 — Create the Generator Project
// File: AutoToString.Generator/AutoToString.Generator.csproj
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.9.2"
PrivateAssets="all" />
</ItemGroup>
</Project>
Key points: the target must be netstandard2.0 because the Roslyn compiler host requires it. The EnforceExtendedAnalyzerRules flag catches common mistakes like referencing assemblies the compiler host cannot load.
Step 2 — Create the Marker Attribute
Source generators typically use a marker attribute so developers can opt in per class. Define the attribute inside the generator so it gets injected into the consuming project automatically:
// File: AutoToString.Generator/AutoToStringAttribute.cs
namespace AutoToString;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct, Inherited = false)]
public sealed class AutoToStringAttribute : Attribute { }
Step 3 — Write the Incremental Generator
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Collections.Immutable;
using System.Text;
namespace AutoToString.Generator;
[Generator]
public class AutoToStringGenerator : IIncrementalGenerator
{
private const string AttributeSource = @"
namespace AutoToString
{
[System.AttributeUsage(
System.AttributeTargets.Class | System.AttributeTargets.Struct,
Inherited = false)]
internal sealed class AutoToStringAttribute : System.Attribute { }
}";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Inject the marker attribute into every compilation
context.RegisterPostInitializationOutput(ctx =>
ctx.AddSource("AutoToStringAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
// Filter for class/struct declarations with our attribute
var targets = context.SyntaxProvider
.ForAttributeWithMetadataName(
"AutoToString.AutoToStringAttribute",
predicate: (node, _) => node is ClassDeclarationSyntax
or StructDeclarationSyntax,
transform: (ctx, _) => GetClassInfo(ctx))
.Where(info => info is not null)
.Select((info, _) => info!.Value);
context.RegisterSourceOutput(targets, GenerateToString);
}
private static ClassInfo? GetClassInfo(
GeneratorAttributeSyntaxContext context)
{
var symbol = (INamedTypeSymbol)context.TargetSymbol;
var properties = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public
&& !p.IsStatic
&& p.GetMethod is not null)
.Select(p => p.Name)
.ToImmutableArray();
if (properties.Length == 0)
return null;
return new ClassInfo(
Namespace: symbol.ContainingNamespace.ToDisplayString(),
ClassName: symbol.Name,
IsRecord: symbol.IsRecord,
Properties: properties);
}
private static void GenerateToString(
SourceProductionContext context,
ClassInfo info)
{
var sb = new StringBuilder();
sb.AppendLine("// Auto-generated by AutoToStringGenerator");
sb.AppendLine($"namespace {info.Namespace};");
sb.AppendLine();
sb.AppendLine($"partial class {info.ClassName}");
sb.AppendLine("{");
sb.AppendLine(" public override string ToString()");
sb.AppendLine(" {");
sb.Append(" return $\"");
sb.Append(info.ClassName);
sb.Append(" {{ ");
for (int i = 0; i < info.Properties.Length; i++)
{
if (i > 0) sb.Append(", ");
sb.Append($"{info.Properties[i]} = {{{info.Properties[i]}}}");
}
sb.AppendLine(" }}\";");
sb.AppendLine(" }");
sb.AppendLine("}");
context.AddSource(
$"{info.ClassName}.ToString.g.cs",
SourceText.From(sb.ToString(), Encoding.UTF8));
}
private readonly record struct ClassInfo(
string Namespace,
string ClassName,
bool IsRecord,
ImmutableArray<string> Properties);
}
Step 4 — Reference the Generator from Your Application
// File: MyApp/MyApp.csproj
<ItemGroup>
<ProjectReference Include="..\AutoToString.Generator\AutoToString.Generator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
The OutputItemType="Analyzer" tells MSBuild to load the DLL as a Roslyn analyzer, not a runtime dependency. ReferenceOutputAssembly="false" prevents the generator assembly from shipping with your app.
Step 5 — Use It
using AutoToString;
namespace MyApp;
[AutoToString]
public partial class Customer
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public string Email { get; set; } = string.Empty;
}
// Usage:
var customer = new Customer { Id = 1, Name = "Alice", Email = "alice@example.com" };
Console.WriteLine(customer.ToString());
// Output: Customer { Id = 1, Name = Alice, Email = alice@example.com }
The class must be partial so the generated file can add the ToString override. If you forget partial, the compiler reports a duplicate definition error.
Debugging C# Source Generators
Debugging compile-time code generation in C# requires a different workflow than normal application debugging. Here are the techniques that actually work:
Emit Generated Files to Disk
Add these properties to the consuming project to write generated files where you can inspect them:
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
Attach a Debugger
Add Debugger.Launch() temporarily in your Initialize method. When the build runs, a dialog appears asking you to attach a debugger. This works in Visual Studio but not in dotnet build from the command line.
Report Diagnostics
Use context.ReportDiagnostic() to emit compiler warnings or errors. This is the right way to tell users their code is incompatible with your generator:
private static readonly DiagnosticDescriptor MustBePartial = new(
id: "ATS001",
title: "Class must be partial",
messageFormat: "The class '{0}' must be declared as partial to use [AutoToString]",
category: "AutoToString",
DiagnosticSeverity.Error,
isEnabledByDefault: true);
// In your execution method:
if (!syntax.Modifiers.Any(SyntaxKind.PartialKeyword))
{
context.ReportDiagnostic(
Diagnostic.Create(MustBePartial, syntax.GetLocation(), symbol.Name));
return;
}
Real-World Use Cases for .NET Source Generators
The ToString example above demonstrates the mechanics. Here is where source generators deliver the most value in production:
- DTO mapping: Generate
MapTo<T>()methods between domain models and API contracts. Eliminates AutoMapper for simple cases with zero runtime cost. - Dependency injection registration: Scan for classes implementing an interface and generate
AddServices()extension methods. No more forgetting to register a new service. - Enum utilities: Generate
ToStringFast(),TryParseFast(), andIsDefined()methods that avoid boxing and allocation. - Strongly-typed configuration: Read
appsettings.jsonschemas at compile time and generate typed option classes. - Validation: Generate
Validate()methods from data annotation attributes with no reflection. - Builder pattern: Generate fluent builders for immutable types automatically.
Best Practices for C# Source Generators
After building and maintaining source generators across production codebases, these are the practices that prevent the most pain:
1. Always Use IIncrementalGenerator
The original ISourceGenerator interface re-executes on every keystroke. IIncrementalGenerator caches intermediate results and only regenerates when inputs actually change. The IDE performance difference is dramatic.
2. Keep the Syntax Predicate Fast
The predicate in ForAttributeWithMetadataName runs on every syntax node in the compilation. Do the minimum check here — just verify the node type. Move expensive logic to the transform function, which only runs on matches.
3. Make Transform Output Equatable
The incremental pipeline compares previous and current outputs to decide whether to regenerate. If your model type does not implement value equality correctly, the generator re-runs unnecessarily. Use record struct for your model types and ImmutableArray<T> for collections (regular arrays break equality comparison).
4. Never Reference the Consuming Project's Types
Your generator targets netstandard2.0 and loads inside the compiler. It cannot reference types from the project being compiled. Instead, inject marker attributes via RegisterPostInitializationOutput and use Roslyn's semantic model to inspect types.
5. Generate Deterministic Output
Do not include timestamps, GUIDs, or random values in generated code. Non-deterministic output forces the IDE to regenerate files constantly and breaks incremental builds.
6. Use Stable File Names
The hint name you pass to AddSource must be stable across compilations for the same input. Use the class name or a hash of the input, not a counter or index.
Common Pitfalls and How to Avoid Them
- Missing
partialkeyword: If your generator adds members to existing types, those types must bepartial. Report a diagnostic error if they are not — do not silently skip them. - Namespace collisions: Always generate code in the same namespace as the target type. Hardcoding a namespace causes ambiguous reference errors.
- IDE caching: Visual Studio sometimes caches old generator output. Restart the IDE or clear the
objfolder if you see stale generated code. - Circular dependencies: A generator cannot reference output from another generator in the same compilation. Structure your generators to be independent.
- Thread safety: The compiler may invoke your generator concurrently. Do not use static mutable state. The incremental pipeline handles state management for you.
Testing Your Source Generator
You can unit test a source generator by compiling code in memory and inspecting the output. Microsoft provides Microsoft.CodeAnalysis.CSharp.SourceGenerators.Testing for this, but a manual approach gives you more control:
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
[Fact]
public void GeneratesToStringForDecoratedClass()
{
var source = @"
using AutoToString;
namespace TestApp;
[AutoToString]
public partial class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
}";
var generator = new AutoToStringGenerator();
var driver = CSharpGeneratorDriver.Create(generator);
var compilation = CSharpCompilation.Create("Tests",
new[] { CSharpSyntaxTree.ParseText(source) },
new[] { MetadataReference.CreateFromFile(
typeof(object).Assembly.Location) });
driver = (CSharpGeneratorDriver)driver.RunGeneratorsAndUpdateCompilation(
compilation, out var outputCompilation, out var diagnostics);
Assert.Empty(diagnostics);
var generatedTrees = outputCompilation.SyntaxTrees
.Where(t => t.FilePath.Contains(".g.cs"))
.ToList();
Assert.Contains(generatedTrees,
t => t.GetText().ToString().Contains("public override string ToString()"));
}
Conclusion — Start Using C# Source Generators Today
C# source generators turn the compiler into your personal code-writing assistant. You define the pattern once, and the compiler applies it across your entire codebase — at build time, with full type safety, and zero runtime cost. Whether you are eliminating repetitive mapping code, generating validation logic, or building framework-level infrastructure, compile-time code generation in C# is the tool that makes it sustainable.
Key takeaways:
- Always use
IIncrementalGeneratorover the olderISourceGeneratorfor IDE performance. - Target
netstandard2.0and inject marker attributes viaRegisterPostInitializationOutput. - Use
record structwithImmutableArray<T>for your model types to ensure correct caching. - Report diagnostics for invalid usage instead of silently skipping classes.
- Test generators by compiling in-memory and inspecting the output compilation.
Pick one piece of boilerplate in your current project — the repetitive code you keep copy-pasting — and write a source generator for it. Once you see the compiler producing correct code for every class automatically, you will never go back to writing it by hand.
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.
Comments
Post a Comment