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.

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
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>
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));
});
}
}
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
}
Let's break down each stage.
The Syntactic Predicate — Keep It Cheap
private static bool IsCandidateClass(SyntaxNode node)
=> node is ClassDeclarationSyntax { AttributeLists.Count: > 0 };
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;
}
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));
}
}
A few things to call out:
-
The class must be
partial. We're emitting a second partial declaration with theToString()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>
-
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; }
}
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);
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
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>
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} }}";
}
}
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:
-
Generator targets anything other than
netstandard2.0— compiles, loads, generates nothing. No error. -
Missing
OutputItemType="Analyzer"on the project reference — compiles, generator never invoked. No error. -
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. -
Using
ISourceGeneratorinstead ofIIncrementalGenerator— 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. -
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)