Still hand-writing ToString
, factories, or mappers? You’re paying that cost at runtime or in code reviews. What if the compiler stamped out that boring code at build time instead?
What a Source Generator actually does
A Source Generator is a Roslyn plug‑in that runs during compilation and adds C# files to your project. No reflection at startup, no runtime emission. You write small hints (usually attributes); the generator inspects your syntax tree and produces code before your app runs.
Why you should care
- Boilerplate reduction: let the compiler write the repetitive parts.
- Startup gains: replace reflection-heavy scanning with generated code.
- Safer refactors: generated code is typed, shows errors in the IDE.
Tiny example: one attribute → one method
Below is a minimal, compilable demo. The block marked generated would normally be produced by a generator; here it’s inlined so the snippet runs end‑to‑end.
using System;
[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class AutoToStringAttribute : Attribute { }
[AutoToString]
public partial class User
{
public string Name { get; }
public int Age { get; }
public User(string name, int age) => (Name, Age) = (name, age);
}
// --- generated by a Source Generator ---
public partial class User
{
public override string ToString()
=> $"User(Name={Name}, Age={Age})";
}
public static class Program
{
public static void Main()
{
Console.WriteLine(new User("Ana", 42));
}
}
How the generator emits that partial
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
[Generator]
public sealed class AutoToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext ctx)
{
var classes = ctx.SyntaxProvider.CreateSyntaxProvider(
static (s, _) => s is ClassDeclarationSyntax c && c.AttributeLists.Count > 0,
static (c, _) => (ClassDeclarationSyntax)c.Node);
ctx.RegisterSourceOutput(classes, static (sp, cls) =>
{
if (!cls.AttributeLists.ToString().Contains("AutoToString")) return;
var name = cls.Identifier.Text;
sp.AddSource($"{name}.AutoToString.g.cs",
@"// <auto-generated/>
public partial class " + name + @"
{
public override string ToString() => \"" + name + "(Name={Name}, Age={Age})\";
}");
});
}
}
How it works in a real project
- You tag a class with
[AutoToString]
. - The generator finds that attribute and emits a matching
partial
withoverride ToString()
. - The compiler merges both partials. No reflection needed when the app starts.
Compile-time cost vs runtime wins
Source Generators shift work left:
- Build time increases a bit (the generator runs), especially if it scans the whole solution.
- Startup time often drops because you remove reflection and dynamic code paths.
- Incremental generators cache results between builds; when inputs don’t change, they don’t re-run.
Rules of thumb from my projects
- Use a generator when the runtime alternative would scan assemblies or use heavy reflection.
- Keep outputs small and focused; emit only what you’ll call.
- Prefer
IIncrementalGenerator
over the classicISourceGenerator
for better build perf.
Common pitfalls (and quick fixes)
- Full-compilation scans hurt builds. Fix: use
SyntaxProvider
to filter to the exact nodes (e.g., classes with your attribute) instead of walking every symbol. - Non-deterministic output (timestamps, GUIDs) causes rebuild loops. Fix: generated text must be stable for the same inputs.
- Editing generated files by hand leads to lost changes. Fix: write to
obj/
as the compiler does, and mark files as generated so IDE hints make that clear.
When it breaks
- Big solutions + naive generators = minutes added to CI. Start with one focused feature, measure, then expand.
Quick detection
- Turn on a binary log:
dotnet build -bl
. Check the generator’s contribution to build time. Trim input set if it’s high.
Where to use them
- DTO mappers,
ToString
,Equals/GetHashCode
, builders. - DI source for known registrations (instead of assembly scans).
- API clients from contracts (attributes or .json schemas).
Checklist
- Add small attributes to mark what should be generated.
- Use
IIncrementalGenerator
+SyntaxProvider
. - Keep outputs tiny and deterministic.
- Measure with
dotnet build -bl
. - Remove runtime reflection where possible.
Try it now (5 minutes)
dotnet new console -n GenDemo
- Add a class library
GenDemo.Generators
withMicrosoft.CodeAnalysis.CSharp
and set<IsRoslynComponent>true</IsRoslynComponent>
. - Implement an
IIncrementalGenerator
that picks[AutoToString]
and emits the partialToString
. - Reference the generator as an Analyzer in the app project.
- Tag a class with
[AutoToString]
and run. Compare startup vs a reflection-based version.
Conclusion
Let the compiler handle the boring bits and earn back startup time. Start with one tiny feature like ToString
and grow from there. What piece of boilerplate will you delete first?