Source Generators: Compile-Time Code Generation

Imagine a world where your code can write code for you. Sounds like magic? In C#, that magic exists in the form of source generators. Introduced in C# 9 as part of the Roslyn compiler platform, source generators allow you to generate C# code at compile-time. Instead of writing repetitive boilerplate, mapping classes, or serialization helpers manually, you can automate their creation. And because this happens at compile-time, your generated code is type-safe, fully integrated with IntelliSense, and debuggable.

Unlike runtime reflection or dynamic code generation, source generators run during compilation. They inspect your project, analyze its structure, and emit new source files before your code even runs. This makes them incredibly fast and safe, and it opens doors for a new class of tooling and productivity improvements in C#.

In this guide, we’ll explore source generators in detail: how they work, how to write them, and how to leverage advanced features like incremental generation and diagnostics. You’ll see examples ranging from simple enum helpers to complex DTO generators, all written with maintainability and performance in mind.

What Are Source Generators?

At a high level, source generators are compiler extensions that produce new C# source files during compilation. They are fundamentally different from traditional code generation approaches like T4 templates:

  • Fast: They only generate code for changes, thanks to incremental generation.
  • Integrated: Generated code is immediately visible in IntelliSense.
  • Type-safe: Generated code is compiled with your project, catching errors at compile time.
  • Debuggable: You can step through the generator code and generated output.

Source generators can do several things:

  • Analyze your existing code and metadata
  • Generate new source files based on patterns or rules
  • Add those files to the compilation automatically
  • Provide diagnostics, warnings, or suggestions during compilation

Think of source generators as compile-time assistants. They don’t replace runtime logic-they complement it by automating repetitive tasks, enforcing consistency, and improving performance.

Creating Your First Source Generator

Let’s start with a practical example: generating string constants for enum values. This is a common task when you need human-readable representations for serialization, logging, or UI display.

First, create a new class library project targeting .NET 5+ and add the following NuGet packages:

<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.4.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />

Then, implement a simple generator:

[Generator]
public class EnumExtensionsGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var enumDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => s is EnumDeclarationSyntax,
                transform: static (ctx, _) => (EnumDeclarationSyntax)ctx.Node)
            .Where(static m => m is not null);

        var compilationAndEnums = context.CompilationProvider.Combine(enumDeclarations.Collect());
        context.RegisterSourceOutput(compilationAndEnums, Execute);
    }

    private void Execute(SourceProductionContext context, (Compilation Left, ImmutableArray<EnumDeclarationSyntax> Right) tuple)
    {
        var (compilation, enums) = tuple;
        foreach (var enumDeclaration in enums)
        {
            var enumSymbol = compilation.GetSemanticModel(enumDeclaration.SyntaxTree)
                .GetDeclaredSymbol(enumDeclaration);
            if (enumSymbol is not INamedTypeSymbol enumType) continue;

            var source = GenerateExtensionClass(enumType);
            context.AddSource($"{enumType.Name}Extensions.g.cs", SourceText.From(source, Encoding.UTF8));
        }
    }

    private string GenerateExtensionClass(INamedTypeSymbol enumType)
    {
        var sb = new StringBuilder();
        sb.AppendLine($"namespace {enumType.ContainingNamespace.ToDisplayString()}");
        sb.AppendLine("{");
        sb.AppendLine($"    public static class {enumType.Name}Extensions");
        sb.AppendLine("    {");
        foreach (var member in enumType.GetMembers().OfType<IFieldSymbol>())
        {
            if (member.ConstantValue is string value)
                sb.AppendLine($"        public const string {member.Name}String = \"{value}\";");
        }
        sb.AppendLine("    }");
        sb.AppendLine("}");
        return sb.ToString();
    }
}

This generator finds all enum declarations in your project and creates extension classes with string constants for each value. The code is automatically included in compilation, fully type-safe, and visible in IntelliSense.

Understanding Incremental Generation

Performance is key with source generators. You don’t want your generator running across the entire project every time you make a small change. That’s where incremental generators shine.

Incremental generators work by building a dependency graph of data transformations:

  1. SyntaxProvider finds relevant syntax nodes.
  2. Transform converts nodes into semantic information.
  3. Combine merges with compilation data.
  4. Execute generates the final source only for changed items.

This approach drastically reduces compilation overhead for large projects and ensures your generator scales gracefully.

Working with Syntax Trees

Source generators operate on Roslyn syntax trees. A syntax tree represents the structure of your code, including classes, methods, properties, and attributes. By traversing these trees, you can find constructs that matter to your generator.

// Finding classes with specific attributes
var classesWithAttribute = context.SyntaxProvider
    .CreateSyntaxProvider(
        predicate: (s, _) => s is ClassDeclarationSyntax c &&
                            c.AttributeLists.Any(),
        transform: (ctx, _) =>
        {
            var classDecl = (ClassDeclarationSyntax)ctx.Node;
            var semanticModel = ctx.SemanticModel;
            var classSymbol = semanticModel.GetDeclaredSymbol(classDecl);

            var hasAttribute = classSymbol?.GetAttributes()
                .Any(a => a.AttributeClass?.Name == "GenerateSomethingAttribute") ?? false;

            return hasAttribute ? classDecl : null;
        })
    .Where(c => c is not null);

Syntax trees give you fine-grained control over what to generate and how. Combined with semantic models, you can inspect types, namespaces, and attributes to produce precise, context-aware code.

Advanced Example: DTO Generator

Let’s consider a more sophisticated generator: creating Data Transfer Objects (DTOs) from entity classes. This is common in layered architectures, where you want to expose a simplified contract to clients without exposing your domain models.

[Generator]
public class DtoGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var entityClasses = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (s, _) => s is ClassDeclarationSyntax,
                transform: static (ctx, _) => GetEntityClass(ctx))
            .Where(static c => c is not null);

        var compilationAndEntities = context.CompilationProvider.Combine(entityClasses.Collect());
        context.RegisterSourceOutput(compilationAndEntities, GenerateDtos);
    }

    private static ClassDeclarationSyntax? GetEntityClass(GeneratorSyntaxContext ctx)
    {
        var classDecl = (ClassDeclarationSyntax)ctx.Node;
        var classSymbol = ctx.SemanticModel.GetDeclaredSymbol(classDecl);
        var hasAttribute = classSymbol?.GetAttributes()
            .Any(a => a.AttributeClass?.Name == "GenerateDtoAttribute") ?? false;
        return hasAttribute ? classDecl : null;
    }

    private void GenerateDtos(SourceProductionContext context, (Compilation, ImmutableArray<ClassDeclarationSyntax>) tuple)
    {
        var (compilation, entities) = tuple;
        foreach (var entity in entities)
        {
            var semanticModel = compilation.GetSemanticModel(entity.SyntaxTree);
            var classSymbol = semanticModel.GetDeclaredSymbol(entity);
            if (classSymbol is null) continue;

            var dtoSource = GenerateDtoClass(classSymbol);
            context.AddSource($"{classSymbol.Name}Dto.g.cs", SourceText.From(dtoSource, Encoding.UTF8));
        }
    }

    private string GenerateDtoClass(INamedTypeSymbol classSymbol)
    {
        var sb = new StringBuilder();
        var namespaceName = classSymbol.ContainingNamespace.ToDisplayString();
        var className = classSymbol.Name;

        sb.AppendLine($"namespace {namespaceName}.Dtos");
        sb.AppendLine("{");
        sb.AppendLine($"    public class {className}Dto");
        sb.AppendLine("    {");
        foreach (var property in classSymbol.GetMembers().OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public))
        {
            sb.AppendLine($"        public {property.Type.ToDisplayString()} {property.Name} {{ get; set; }}");
        }
        sb.AppendLine("    }");
        sb.AppendLine("}");
        return sb.ToString();
    }
}

This DTO generator can be extended to include mapping methods, validation attributes, or JSON serialization metadata. By combining syntax tree inspection and semantic analysis, you can create highly reusable and safe code-generation tools.

Diagnostics and Validation

One of the strengths of source generators is the ability to produce compile-time diagnostics. This lets you warn developers about misconfigurations or invalid usage patterns directly in the IDE.

var diagnostic = Diagnostic.Create(
    new DiagnosticDescriptor(
        "SG001",
        "Invalid usage",
        "Class {0} must have a parameterless constructor",
        "SourceGenerator",
        DiagnosticSeverity.Error,
        isEnabledByDefault: true),
    classDecl.GetLocation(),
    classSymbol.Name);

context.ReportDiagnostic(diagnostic);

Diagnostics are invaluable for large teams or frameworks, ensuring generated code is consistent and safe without runtime surprises.

Best Practices and Performance

While source generators are powerful, they can impact build performance if misused. Some practical guidelines:

  • Always prefer incremental generation when possible
  • Cache expensive computations between generations
  • Generate code only when necessary
  • Use descriptive file names for generated files
  • Provide clear, actionable diagnostics for invalid input
  • Unit test generators, integration test generated code, and verify incremental behavior

Common Patterns and Use Cases

Source generators excel in scenarios such as:

  • Auto-mapping between domain models and DTOs
  • Generating serialization helpers
  • Validating attributes and conventions at compile-time
  • Creating DI container registration code
  • Generating API clients from OpenAPI or GraphQL schemas
  • Database access layers and query builders

Some popular libraries leveraging source generators include AutoMapper, NSwag, Entity Framework, and System.Text.Json optimizations.

Debugging Source Generators

Debugging can feel unintuitive at first because generators run during compilation, but there are effective strategies:

  • Insert Debugger.Launch() to attach a debugger when the generator runs
  • Use context.ReportDiagnostic() for inline logging and warnings
  • Check generated code in the obj folder of your project
  • Use Roslyn syntax visualizers or tools like RoslynPad to inspect syntax trees interactively

Limitations and Considerations

While source generators are powerful, they aren’t a silver bullet:

  • They cannot modify existing source files-only add new ones
  • Generated files aren’t intended to be checked into source control
  • They require .NET 5+ and C# 9+
  • Complex generators can slow compilation if not incremental

Use source generators when the generated code is predictable, stable, and performance-critical. They shine when boilerplate reduction, type-safety, and IDE integration are important.

Summary

Source generators represent a paradigm shift in C# development. By moving code generation to compile-time, they allow developers to write faster, safer, and more maintainable code. From simple enum helpers to advanced DTO or serialization generators, they open up possibilities that were previously tedious or error-prone.

The keys to effective generators are understanding the Roslyn APIs, leveraging incremental generation, and keeping maintainability in mind. Start with small, focused generators, then gradually tackle more complex scenarios. Once you embrace source generators, you'll find yourself writing code that is not just functional but also elegantly self-augmenting.