DEV Community

Cover image for Source Generators in .NET: Build Your Own [AutoToString] From Scratch
Shathish
Shathish

Posted on

Source Generators in .NET: Build Your Own [AutoToString] From Scratch

If you've worked with System.Text.Json, Minimal APIs, or Blazor route discovery, you've already benefited from source generators — you just didn't know it. These features all use compile-time code generation under the hood to avoid reflection and deliver faster startup, AOT compatibility, and compile-time safety.

In this series, we'll build source generators from the ground up. By the end, you'll understand how they work, how to write your own, and how to apply the pattern to real-world problems like CQRS handler registration (think Wolverine/MediatR-style pipelines), return type generation, and async/await patterns.

In this first post, we'll start simple: a generator that finds classes marked with [AutoToString] and generates a ToString() override that prints all public properties. No reflection. No runtime cost.

What Is a Source Generator?

A source generator is a component that plugs into the C# compiler (Roslyn). During compilation, it can inspect your code — syntax trees, symbols, attributes — and emit new C# source files that get compiled alongside everything else.

Source Generator Pipeline — compilation runs, the source generator step analyses source code and generates new source code, which is added back into the compilation before it resumes.
Image credit: Introducing C# Source Generators — .NET Blog

The key constraint: generators add source files. They never modify your existing code. This is a deliberate design choice that keeps the model simple and predictable.

Project Structure

We need two projects:

SourceGenDemo/
├── SourceGenDemo.sln
├── SourceGenDemo.Generator/     ← netstandard2.0 class library
│   ├── SourceGenDemo.Generator.csproj
│   ├── AutoToStringAttributeGenerator.cs
│   └── AutoToStringGenerator.cs
└── SourceGenDemo.App/           ← net10.0 console app
    ├── SourceGenDemo.App.csproj
    ├── Models/
    │   └── Models.cs
    └── Program.cs
Enter fullscreen mode Exit fullscreen mode

The separation matters. The generator is a tool the compiler loads at build time; the app is what actually runs. They have fundamentally different target frameworks and lifecycles.

The Generator Project

The .csproj — This Is Where Most People Get Stuck

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
    <IsRoslynComponent>true</IsRoslynComponent>
  </PropertyGroup>

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

</Project>
Enter fullscreen mode Exit fullscreen mode

netstandard2.0 is mandatory. This is the single most common mistake. The Roslyn compiler loads generators into its own process, which targets netstandard2.0. If you target net10.0 here, the project compiles fine but the generator silently fails to load. No error, no warning — your generated code just never appears.

EnforceExtendedAnalyzerRules and IsRoslynComponent opt you into stricter analysis rules that catch common generator authoring mistakes at compile time.

Step 1: Ship the Marker Attribute

Before we can find classes decorated with [AutoToString], that attribute needs to exist. A common pattern is to emit it from the generator itself, so consumers don't need a separate shared library:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using System.Text;

namespace SourceGenDemo.Generator;

[Generator]
public class AutoToStringAttributeGenerator : IIncrementalGenerator
{
    public const string AttributeSource = """
        // <auto-generated/>
        namespace SourceGenDemo.Attributes;

        [System.AttributeUsage(
            System.AttributeTargets.Class,
            Inherited = false,
            AllowMultiple = false)]
        public sealed class AutoToStringAttribute : System.Attribute { }
        """;

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        context.RegisterPostInitializationOutput(ctx =>
        {
            ctx.AddSource(
                "AutoToStringAttribute.g.cs",
                SourceText.From(AttributeSource, Encoding.UTF8));
        });
    }
}
Enter fullscreen mode Exit fullscreen mode

RegisterPostInitializationOutput runs unconditionally — it doesn't inspect any user code. It just injects the attribute source into the compilation so the consumer can write using SourceGenDemo.Attributes; without referencing anything extra.

Step 2: The Generator Itself

This is the core. We implement IIncrementalGenerator and set up a three-stage pipeline:

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
using System.Collections.Immutable;
using System.Linq;
using System.Text;

namespace SourceGenDemo.Generator;

[Generator]
public class AutoToStringGenerator : IIncrementalGenerator
{
    private const string AutoToStringAttributeFullName =
        "SourceGenDemo.Attributes.AutoToStringAttribute";

    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // STEP 1: Syntactic filter — fast, runs on every keystroke
        IncrementalValuesProvider<ClassDeclarationSyntax> classDeclarations =
            context.SyntaxProvider
                .CreateSyntaxProvider(
                    predicate: static (node, _) => IsCandidateClass(node),
                    transform: static (ctx, _) => GetSemanticTarget(ctx))
                .Where(static cls => cls is not null)!;

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

        // STEP 3: Generate source
        context.RegisterSourceOutput(combined, static (spc, source) =>
        {
            var (compilation, classes) = source;
            Execute(compilation, classes, spc);
        });
    }

    // ... continued below
}
Enter fullscreen mode Exit fullscreen mode

Let's break down each stage.

The Syntactic Predicate — Keep It Cheap

private static bool IsCandidateClass(SyntaxNode node)
    => node is ClassDeclarationSyntax { AttributeLists.Count: > 0 };
Enter fullscreen mode Exit fullscreen mode

This runs on every syntax node in your entire compilation — potentially thousands of nodes on every keystroke in the IDE. It must be trivially fast. No semantic model access, no LINQ, no allocations. We're just asking: "Is this a class declaration with at least one attribute?" That's it.

The Semantic Transform — Verify the Attribute

private static ClassDeclarationSyntax? GetSemanticTarget(
    GeneratorSyntaxContext context)
{
    var classDecl = (ClassDeclarationSyntax)context.Node;
    var model = context.SemanticModel;

    foreach (var attributeList in classDecl.AttributeLists)
    {
        foreach (var attribute in attributeList.Attributes)
        {
            var symbol = model.GetSymbolInfo(attribute).Symbol?.ContainingType;
            if (symbol?.ToDisplayString() == AutoToStringAttributeFullName)
                return classDecl;
        }
    }

    return null;
}
Enter fullscreen mode Exit fullscreen mode

This only runs on nodes that passed the syntactic filter. Now we can afford to use the semantic model. We resolve the attribute's actual type symbol and compare the fully qualified name. This prevents false positives — if someone has a completely different [AutoToString] attribute in another namespace, we won't accidentally match it.

The difference between syntax and semantics here is important: syntax tells you "there's a class with an attribute called AutoToString"; the semantic model tells you "that attribute resolves to this specific type in this compilation."

The Execution — Emit Source

private static void Execute(
    Compilation compilation,
    ImmutableArray<ClassDeclarationSyntax> classes,
    SourceProductionContext context)
{
    if (classes.IsDefaultOrEmpty)
        return;

    var distinctClasses = classes.Distinct();

    foreach (var classDecl in distinctClasses)
    {
        var model = compilation.GetSemanticModel(classDecl.SyntaxTree);
        if (model.GetDeclaredSymbol(classDecl) is not INamedTypeSymbol classSymbol)
            continue;

        var namespaceName = classSymbol.ContainingNamespace.IsGlobalNamespace
            ? null
            : classSymbol.ContainingNamespace.ToDisplayString();

        var className = classSymbol.Name;

        var properties = classSymbol
            .GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public
                     && !p.IsStatic
                     && !p.IsIndexer)
            .ToList();

        var sb = new StringBuilder();
        sb.AppendLine("// <auto-generated/>");
        sb.AppendLine();

        if (namespaceName is not null)
        {
            sb.AppendLine($"namespace {namespaceName};");
            sb.AppendLine();
        }

        sb.AppendLine($"public partial class {className}");
        sb.AppendLine("{");
        sb.AppendLine("    public override string ToString()");
        sb.AppendLine("    {");

        if (properties.Count == 0)
        {
            sb.AppendLine($"        return \"{className} {{ }}\";");
        }
        else
        {
            sb.Append($"        return $\"{className} {{{{ ");

            for (int i = 0; i < properties.Count; i++)
            {
                var prop = properties[i];
                sb.Append($"{prop.Name} = {{{prop.Name}}}");
                if (i < properties.Count - 1)
                    sb.Append(", ");
            }

            sb.AppendLine(" }}}}\";");
        }

        sb.AppendLine("    }");
        sb.AppendLine("}");

        context.AddSource(
            $"{className}_AutoToString.g.cs",
            SourceText.From(sb.ToString(), Encoding.UTF8));
    }
}
Enter fullscreen mode Exit fullscreen mode

A few things to call out:

  • The class must be partial. We're emitting a second partial declaration with the ToString() override. If the original class isn't partial, the compiler sees two conflicting definitions and errors.
  • We deduplicate. A class might appear multiple times if it has multiple attribute lists or partial declarations.
  • The {{{{ brace escaping. This trips everyone up. Our generator code uses $"" interpolation, and the output is also an interpolated string. So braces go through two levels of interpretation: {{{{ in the generator → {{ in the generated file → { at runtime. Get this wrong and you'll see CS1073/CS1525 errors pointing at your generated files.

The Consumer Project

The .csproj — Two Magic Properties

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <LangVersion>latest</LangVersion>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <ProjectReference
      Include="..\SourceGenDemo.Generator\SourceGenDemo.Generator.csproj"
      OutputItemType="Analyzer"
      ReferenceOutputAssembly="false" />
  </ItemGroup>

</Project>
Enter fullscreen mode Exit fullscreen mode
  • OutputItemType="Analyzer" tells the compiler to load this project as an analyzer/generator.
  • ReferenceOutputAssembly="false" means the app doesn't reference the generator DLL at runtime — it's a build-time-only dependency.

Without both of these, the compiler treats the generator as a normal project reference and your generator never runs. Like the netstandard2.0 requirement, there's no helpful error message. It just silently doesn't work.

The Models

using SourceGenDemo.Attributes;

namespace SourceGenDemo.App.Models;

[AutoToString]
public partial class Product(string name, decimal price, string category)
{
    public string Name { get; set; } = name;
    public decimal Price { get; set; } = price;
    public string Category { get; set; } = category;

    private string _internalCode = "SKU-001";
}

[AutoToString]
public partial class Customer(string firstName, string lastName, string email)
{
    public string FirstName { get; set; } = firstName;
    public string LastName { get; set; } = lastName;
    public string Email { get; set; } = email;
    public int LoyaltyPoints { get; set; }
}

// No [AutoToString] — the generator ignores this.
public class Order
{
    public int Id { get; set; }
    public decimal Total { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Notice partial on both decorated classes. The private _internalCode field on Product won't appear in the generated ToString() because our generator only collects public properties.

Program.cs

using SourceGenDemo.App.Models;

var product = new Product("Mechanical Keyboard", 149.99m, "Electronics");

var customer = new Customer("Adolphous", "Dev", "adolphous@example.com")
{
    LoyaltyPoints = 4200
};

Console.WriteLine(product);
Console.WriteLine(customer);

// Falls through to default Object.ToString()
var order = new Order { Id = 1, Total = 299.98m };
Console.WriteLine(order);
Enter fullscreen mode Exit fullscreen mode

Output

Product { Name = Mechanical Keyboard, Price = 149.99, Category = Electronics }
Customer { FirstName = Adolphous, LastName = Dev, Email = adolphous@example.com, LoyaltyPoints = 4200 }
SourceGenDemo.App.Models.Order
Enter fullscreen mode Exit fullscreen mode

The Order line shows the default Object.ToString() since it doesn't have the attribute.

Inspecting the Generated Code

Add this to the consumer's .csproj:

<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
Enter fullscreen mode Exit fullscreen mode

After building, check obj/Debug/net10.0/generated/SourceGenDemo.Generator/. You'll see files like Product_AutoToString.g.cs:

// <auto-generated/>

namespace SourceGenDemo.App.Models;

public partial class Product
{
    public override string ToString()
    {
        return $"Product {{ Name = {Name}, Price = {Price}, Category = {Category} }}";
    }
}
Enter fullscreen mode Exit fullscreen mode

This is real, inspectable C# that the compiler treats exactly like hand-written code. You can set breakpoints in it, navigate to it with "Go to Definition", and see it in your IDE's generated files node.

The Gotchas That Will Waste Your Afternoon

Let me save you some debugging time. These are the non-obvious failure modes, ranked by how silently they fail:

  1. Generator targets anything other than netstandard2.0 — compiles, loads, generates nothing. No error.
  2. Missing OutputItemType="Analyzer" on the project reference — compiles, generator never invoked. No error.
  3. Consumer class isn't partial — you get a compiler error, but it points at the generated file, which can be confusing if you don't know generated files exist yet.
  4. Using ISourceGenerator instead of IIncrementalGenerator — works, but re-runs your generator on every single keystroke in the IDE. For a small generator it's fine; for anything touching the semantic model across many files, it tanks IDE performance.
  5. Brace escaping in generated interpolated strings{ in your generator's $"" becomes a literal brace in the output, but if the output is also an interpolated string, you need {{{{ to get a literal brace at runtime. Expect CS1073/CS1525 errors if you get this wrong.

Why IIncrementalGenerator Over ISourceGenerator?

The older ISourceGenerator interface has a simple Execute method that receives the entire compilation. Simple, but the compiler has to re-run it from scratch whenever anything changes. The incremental pipeline we built (predicate → transform → output) lets the compiler cache intermediate results. If you edit Program.cs, the generator doesn't re-process Product and Customer because their syntax trees haven't changed.

For a two-class demo this doesn't matter. For a generator running across a 500-file solution in VS, it's the difference between instant feedback and a frozen IDE.

What's Next

This covers the foundation: project structure, the incremental pipeline, attribute discovery, and source emission. In the next post, we'll build something more practical — a source generator that automatically discovers and registers CQRS command/query handlers, similar to how Wolverine and MediatR find handlers at startup, but shifted entirely to compile time.


This is Part 1 of the Source Generators in .NET series. Follow along for the next post on CQRS handler auto-registration.

Top comments (0)