Skip to main content

C# Source Generators Tutorial — Automate Code at Compile Time

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:

  1. Initialization: You register which syntax nodes you care about (classes with a specific attribute, methods with a certain signature, etc.).
  2. 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(), and IsDefined() methods that avoid boxing and allocation.
  • Strongly-typed configuration: Read appsettings.json schemas 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 partial keyword: If your generator adds members to existing types, those types must be partial. 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 obj folder 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 IIncrementalGenerator over the older ISourceGenerator for IDE performance.
  • Target netstandard2.0 and inject marker attributes via RegisterPostInitializationOutput.
  • Use record struct with ImmutableArray<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.

About csharp-coder.com
Your go-to resource for C#, .NET, and modern software development. Follow along for daily tutorials, tips, and real-world examples.

Comments

Popular posts from this blog

Angular 14 CRUD Operation with Web API .Net 6.0

How to Perform CRUD Operation Using Angular 14 In this article, we will learn the angular crud (create, read, update, delete) tutorial with ASP.NET Core 6 web API. We will use the SQL Server database and responsive user interface for our Web app, we will use the Bootstrap 5. Let's start step by step. Step 1 - Create Database and Web API First we need to create Employee database in SQL Server and web API to communicate with database. so you can use my previous article CRUD operations in web API using net 6.0 to create web API step by step. As you can see, after creating all the required API and database, our API creation part is completed. Now we have to do the angular part like installing angular CLI, creating angular 14 project, command for building and running angular application...etc. Step 2 - Install Angular CLI Now we have to install angular CLI into our system. If you have already installed angular CLI into your system then skip this step.  To install angular CLI ope...

Angular 14 : 404 error during refresh page after deployment

In this article, We will learn how to solve 404 file or directory not found angular error in production.  Refresh browser angular 404 file or directory not found error You have built an Angular app and created a production build with ng build --prod You deploy it to a production server. Everything works fine until you refresh the page. The app throws The requested URL was not found on this server message (Status code 404 not found). It appears that angular routing not working on the production server when you refresh the page. The error appears on the following scenarios When you type the URL directly in the address bar. When you refresh the page The error appears on all the pages except the root page.   Reason for the requested URL was not found on this server error In a Multi-page web application, every time the application needs to display a page it has to send a request to the web server. You can do that by either typing the URL in the address bar, clicking on the Me...

Send an Email via SMTP with MailKit Using .NET 6

How to Send an Email in .NET Core This tutorial show you how to send an email in .NET 6.0 using the MailKit email client library. Install MailKit via NuGet Visual Studio Package Manager Console: Install-Package MailKit How to Send an HTML Email in .NET 6.0 This code sends a simple HTML email using the Gmail SMTP service. There are instructions further below on how to use a few other popular SMTP providers - Gmail, Hotmail, Office 365. // create email message var email = new MimeMessage(); email.From.Add(MailboxAddress.Parse("from_address@example.com")); email.To.Add(MailboxAddress.Parse("to_address@example.com")); email.Subject = "Email Subject"; email.Body = new TextPart(TextFormat.Html) { Text = "<h1>Test HTML Message Body</h1>" }; // send email using var smtp = new SmtpClient(); smtp.Connect("smtp.gmail.com", 587, SecureSocketOptions.StartTls); smtp.Authenticate("[Username]", "[Password]"); smtp.Se...