Learnixo
Back to blog
Backend Systemsintermediate

Source Generators in C# — Code That Writes Code

Build C# Source Generators that run at compile time: IIncrementalGenerator, syntax-tree inspection, generating boilerplate, and real-world use cases including AutoMapper alternatives and logging optimisation.

Asma Hafeez KhanMay 24, 20264 min read
csharpdotnetsource-generatorsroslynmetaprogrammingcompile-time
Share:𝕏

Source Generators in C# — Code That Writes Code

Source generators run during compilation and inject new C# files into the build. They eliminate reflection-based runtime code, produce AOT-compatible output, and generate boilerplate that would otherwise be handwritten.


How Source Generators Work

Compilation pipeline:
  1. Compiler reads your .cs files
  2. Roslyn creates a syntax tree for each file
  3. Source generator inspects the syntax tree
  4. Generator emits new .cs files into the compilation
  5. Final assembly includes both your code and generated code

You see generated files in:
  obj/Debug/net9.0/generated//...

Incremental Generator (the modern API)

C#
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;

[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: select classes marked with [GenerateToString]
        IncrementalValuesProvider<ClassDeclarationSyntax> classes = context
            .SyntaxProvider
            .CreateSyntaxProvider(
                predicate: static (node, _) =>
                    node is ClassDeclarationSyntax cls &&
                    cls.AttributeLists.Count > 0,
                transform: static (ctx, _) =>
                    (ClassDeclarationSyntax)ctx.Node
            )
            .Where(static c => c is not null)!;

        // Step 2: combine with compilation info for symbol resolution
        IncrementalValueProvider<(Compilation, ImmutableArray<ClassDeclarationSyntax>)> combined =
            context.CompilationProvider.Combine(classes.Collect());

        // Step 3: generate source for each matching class
        context.RegisterSourceOutput(combined, static (spc, source) =>
        {
            var (compilation, classDeclarations) = source;
            foreach (var cls in classDeclarations)
                GenerateToString(spc, compilation, cls);
        });
    }

    private static void GenerateToString(
        SourceProductionContext context,
        Compilation compilation,
        ClassDeclarationSyntax cls)
    {
        var model  = compilation.GetSemanticModel(cls.SyntaxTree);
        var symbol = model.GetDeclaredSymbol(cls) as INamedTypeSymbol;
        if (symbol is null) return;

        // Check for [GenerateToString] attribute
        var attr = symbol.GetAttributes()
            .FirstOrDefault(a => a.AttributeClass?.Name == "GenerateToStringAttribute");
        if (attr is null) return;

        var ns = symbol.ContainingNamespace.ToDisplayString();
        var name = symbol.Name;

        // Get all public properties
        var props = symbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public);

        var sb = new StringBuilder();
        sb.AppendLine($"namespace {ns};");
        sb.AppendLine($"partial class {name}");
        sb.AppendLine("{");
        sb.Append($"    public override string ToString() => $\"");
        sb.Append(name + " {{ ");
        sb.Append(string.Join(", ", props.Select(p => $"{p.Name}={{{p.Name}}}")));
        sb.Append(" }}\";");
        sb.AppendLine();
        sb.AppendLine("}");

        context.AddSource($"{name}.g.cs", sb.ToString());
    }
}

Using the Generator

C#
// Mark your class with the attribute
[GenerateToString]
public partial class Order
{
    public int     Id         { get; init; }
    public string  Reference  { get; init; } = "";
    public decimal Total      { get; init; }
}

// Generated at compile time (obj/Debug/.../Order.g.cs):
// namespace YourNamespace;
// partial class Order
// {
//     public override string ToString() =>
//         $"Order {{ Id={Id}, Reference={Reference}, Total={Total} }}";
// }

var order = new Order { Id = 1, Reference = "ORD-001", Total = 99.99m };
Console.WriteLine(order);
// Order { Id=1, Reference=ORD-001, Total=99.99 }

Real-World Use Cases

Compile-Time Logging (LoggerMessage)

C#
// This is what .NET itself generates for high-performance logging
// Source generator creates static LoggerMessage delegates — zero allocation
public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} created")]
    public static partial void OrderCreated(ILogger logger, int orderId);

    [LoggerMessage(Level = LogLevel.Error, Message = "Payment failed for {OrderId}: {Reason}")]
    public static partial void PaymentFailed(ILogger logger, int orderId, string reason);
}

// Usage:
Log.OrderCreated(logger, 42);
Log.PaymentFailed(logger, 42, "Insufficient funds");
// No string formatting at runtime unless the log level is enabled

System.Text.Json Source Generation

C#
using System.Text.Json.Serialization;

// Generates fast serialiser/deserialiser at compile time
// No reflection at runtime — AOT compatible
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
public partial class AppJsonContext : JsonSerializerContext { }

// Usage with generated context:
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
var obj  = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

Regex Source Generation (C# 11+)

C#
using System.Text.RegularExpressions;

public partial class EmailValidator
{
    // Generates a compiled Regex at build time — faster than runtime compilation
    [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.IgnoreCase)]
    private static partial Regex EmailPattern();

    public static bool IsValid(string email)
        => EmailPattern().IsMatch(email);
}

Setting Up a Generator Project

XML
<!-- YourGenerator.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.x.x" PrivateAssets="all" />
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.x.x" PrivateAssets="all" />
  </ItemGroup>
</Project>

<!-- Consumer project references generator as analyzer -->
<!-- YourApp.csproj -->
<ItemGroup>
  <ProjectReference Include="..\YourGenerator\YourGenerator.csproj"
                    OutputItemType="Analyzer"
                    ReferenceOutputAssembly="false" />
</ItemGroup>

Interview Answer

"Source generators run as part of the Roslyn compilation pipeline — they inspect syntax trees and emit new .cs files that are compiled alongside your code. The key benefit over reflection is compile-time safety and zero runtime cost (no assembly scanning at startup). The modern API is IIncrementalGenerator, which uses fine-grained tracking to avoid re-running when unrelated files change. Real uses in the .NET ecosystem: [LoggerMessage] generates zero-allocation log delegates, [JsonSerializable] generates System.Text.Json serialisers without reflection (AOT-compatible), and [GeneratedRegex] compiles Regex at build time. When building your own: use IIncrementalGenerator (not the older ISourceGenerator), test with Microsoft.CodeAnalysis.Testing, and emit partial classes so consumers can add their own members alongside generated ones."

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.