C# Source Generators: Compile-Time Code Generation with Roslyn in .NET
The Problem: Death by Ceremony
Every .NET developer has been here. You’re building a system with 50 DTOs. Same five chores, every class. For each one you need:
- A mapper to convert between entities and DTOs
- Validation logic
- Equality comparisons
ToString()overrides for debugging- JSON serialization hints
That’s 5 pieces of boilerplate per class. 250 methods that are 90% identical, differing only in property names. You write them by hand, copy-paste errors creep in, and when a property changes, you forget to update the mapper.
The traditional solutions all have problems:
| Approach | Problem |
|---|---|
| Reflection (AutoMapper) | Runtime overhead, no compile-time safety |
| T4 Templates | Clunky tooling, poor IDE support, runs pre-build |
| Code snippets | Still manual, still error-prone |
| IL Weaving (Fody) | Post-compilation magic, hard to debug |
Source generators offer a different path: generate the code at compile time, as if you wrote it yourself.
What Source Generators Actually Do
Source generators hook into the Roslyn compiler. They:
- Inspect your code via syntax trees and semantic models
- Generate new C# source files
- Add them to your compilation
The generated code is real C# - visible in your IDE, navigable with “Go to Definition”, fully debuggable. There’s zero runtime overhead for the generation itself. Took me a while to trust that, but the debugger does not lie.
They sit in the build pipeline like this:
flowchart TD
A[Your Code] --> B[Roslyn Parser]
B --> C[Syntax Trees]
C --> D[Source Generators]
D --> E[Additional Code]
E --> F[Compilation]
F --> G[IL]
The key difference from reflection: by the time your app runs, the generated code is already compiled. No Type.GetProperties(), no Activator.CreateInstance(), no JIT compilation of dynamic methods.
The Code: A Complete Playground Repo
I’ve put all the samples into a runnable playground solution:
You can access the full sample code here: GitHub Repo
It includes both Roslyn source generators and the equivalent T4-generated output (checked in) so you can compare the two approaches.
What Gets Generated (In This Repo)
The playground focuses on four patterns that show up constantly in real .NET codebases.
1) Strongly-Typed Configuration Binding
Define a POCO and mark it:
using AnimatLabs.SourceGenerators.Attributes;
namespace AnimatLabs.SourceGenerators.Demo.Models;
[GenerateConfiguration(SectionName = "Database")]
public partial class DatabaseSettings
{
public string ConnectionString { get; set; } = string.Empty;
public int Timeout { get; set; } = 30;
public RetrySettings Retry { get; set; } = new();
}
public class RetrySettings
{
public int MaxAttempts { get; set; } = 3;
public int DelayMs { get; set; } = 1000;
}
The generator emits Bind(IConfiguration) plus nested binders for complex properties.
// Generated (shape): DatabaseSettings.Configuration.g.cs
public static global::AnimatLabs.SourceGenerators.Demo.Models.DatabaseSettings Bind(
global::Microsoft.Extensions.Configuration.IConfiguration configuration)
{
var section = configuration.GetSection("Database");
return BindSection_AnimatLabs_SourceGenerators_Demo_Models_DatabaseSettings(section);
}
Supported property types: string, int, bool, double, decimal, enums, and nested classes. Unsupported types are skipped.
2) Enum Extensions (Display Names + Parsing)
using System.ComponentModel.DataAnnotations;
using AnimatLabs.SourceGenerators.Attributes;
namespace AnimatLabs.SourceGenerators.Demo.Models;
[GenerateEnumExtensions]
public enum OrderStatus
{
[Display(Name = "Pending Approval")] Pending,
[Display(Name = "In Progress")] Processing,
Shipped,
Delivered,
Cancelled
}
The generator emits:
ToDisplayName()(usesDisplayAttribute.Namewhen present)TryParse(string, out OrderStatus)(accepts both member name and display name)GetAll()
3) DTO Mapping Without Reflection
using AnimatLabs.SourceGenerators.Attributes;
using AnimatLabs.SourceGenerators.Demo.Models;
namespace AnimatLabs.SourceGenerators.Demo.Mappers;
[GenerateMapper]
public partial class UserMapper
{
public partial UserDto ToDto(User entity);
public partial User ToEntity(UserDto dto);
}
The generator implements each method by assigning properties where name and type match.
4) Auto-Generated ToString()
using AnimatLabs.SourceGenerators.Attributes;
namespace AnimatLabs.SourceGenerators.Demo.Models;
[AutoToString]
public partial class Person
{
public string FirstName { get; set; } = string.Empty;
public string LastName { get; set; } = string.Empty;
public int Age { get; set; }
}
The generator emits an override using StringBuilder (and supports Exclude + IncludePrivate options).
How The Playground Generator Is Structured
The repo uses a single incremental generator (AnimatLabsSourceGenerators) that registers four independent pipelines using ForAttributeWithMetadataName(...).
How It’s Wired Into A Consumer Project
The demo project shows the standard setup:
- reference the attributes project normally
- reference the generator project as an analyzer (so it runs at compile time)
<ItemGroup>
<ProjectReference Include="..\AnimatLabs.SourceGenerators.Attributes\AnimatLabs.SourceGenerators.Attributes.csproj" />
<ProjectReference Include="..\AnimatLabs.SourceGenerators\AnimatLabs.SourceGenerators.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
Debugging and Testing Generators
View Generated Files
Enable compiler-generated files in the consuming project (the playground demo already does this):
<PropertyGroup>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>
Then inspect obj/Debug/net8.0/ for the emitted .g.cs files.
Attach Debugger During Build
public void Initialize(IncrementalGeneratorInitializationContext context)
{
#if DEBUG
if (!Debugger.IsAttached)
{
Debugger.Launch();
}
#endif
// ... rest of initialization
}
Unit Test Generators Properly
using System;
using System.Linq;
using AnimatLabs.SourceGenerators;
using AnimatLabs.SourceGenerators.Attributes;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;
public class GeneratorTests
{
[Fact]
public void AutoToString_GeneratesOverride()
{
var source = """
using AnimatLabs.SourceGenerators.Attributes;
namespace Demo;
[AutoToString]
public partial class Person
{
public string Name { get; set; } = string.Empty;
}
""";
var output = RunGenerator(source);
Assert.Contains("public override string ToString()", output, StringComparison.Ordinal);
Assert.Contains("Name =", output, StringComparison.Ordinal);
}
private static string RunGenerator(string source)
{
var syntaxTree = CSharpSyntaxTree.ParseText(source, new CSharpParseOptions(LanguageVersion.Latest));
var references = AppDomain.CurrentDomain.GetAssemblies()
.Where(assembly => !assembly.IsDynamic)
.Select(assembly => assembly.Location)
.Where(location => !string.IsNullOrWhiteSpace(location))
.Distinct(StringComparer.OrdinalIgnoreCase)
.Select(location => MetadataReference.CreateFromFile(location))
.ToList();
references.Add(MetadataReference.CreateFromFile(typeof(AutoToStringAttribute).Assembly.Location));
var compilation = CSharpCompilation.Create(
assemblyName: "Tests",
syntaxTrees: new[] { syntaxTree },
references: references,
options: new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
var driver = CSharpGeneratorDriver.Create(new AnimatLabsSourceGenerators());
driver.RunGeneratorsAndUpdateCompilation(compilation, out var outputCompilation, out _);
return string.Join("\n", outputCompilation.SyntaxTrees
.Where(tree => tree.FilePath.EndsWith(".g.cs", StringComparison.OrdinalIgnoreCase))
.Select(tree => tree.ToString()));
}
}
Performance: Making Generators Fast
Generators run on every keystroke. A slow generator destroys the IDE experience.
Do: Use Incremental Generators
// GOOD - Only regenerates when inputs change
var provider = context.SyntaxProvider.ForAttributeWithMetadataName(
"MyAttribute",
predicate: (node, _) => true,
transform: (ctx, _) => ProcessNode(ctx));
Do: Filter Early
// GOOD - Cheap syntax check before expensive semantic analysis
predicate: static (node, _) =>
node is ClassDeclarationSyntax { AttributeLists.Count: > 0 }
Don’t: Process Everything
// BAD - Analyzes entire compilation on every keystroke
context.RegisterSourceOutput(context.CompilationProvider, (ctx, compilation) => {
foreach (var tree in compilation.SyntaxTrees) // Every file!
{
// ...
}
});
Don’t: Allocate Heavily
// BAD - New StringBuilder per call
var sb = new StringBuilder();
// BETTER - Use pooled builder or pre-allocated
var sb = StringBuilderPool.Get();
try { /* ... */ }
finally { StringBuilderPool.Return(sb); }
Libraries Using Source Generators
These production libraries prove the pattern works at scale:
| Library | What It Generates |
|---|---|
| System.Text.Json | JSON serializers (built-in) |
| Mapperly | Object-to-object mappers |
| Refit | REST API clients |
| StronglyTypedId | Strongly-typed ID wrappers |
| Dunet | Discriminated unions |
| Generator.Equals | Equality members |
| PropertyChanged.SourceGenerator | INotifyPropertyChanged |
Common Pitfalls and Solutions
| Pitfall | Symptom | Solution |
|---|---|---|
| Wrong target framework | Generator silently doesn’t run | Must be netstandard2.0 |
| Missing analyzer reference | Generated code not found | Add OutputItemType="Analyzer" |
| Stale generated code | Changes not reflected | Restart IDE, clean rebuild |
| Non-deterministic output | Different output each build | Don’t use timestamps, random values |
| Slow IDE | Typing lag, high CPU | Use incremental generator, filter early |
| Missing partial keyword | Compiler error on generated code | Consumer class must be partial |
What I’d Actually Ship
Source generators aren’t just a cool compiler trick - they’re a practical tool for eliminating the repetitive code that makes codebases harder to maintain. The examples above show real patterns you can implement today:
- Configuration binding without magic strings
- Enum helpers that respect
DisplayAttribute(no reflection on the hot path) - DTO mapping with compile-time safety
ToString()overrides without hand-written plumbing
The investment in building a generator pays off every time it saves someone from writing (and debugging) ceremony. Start with a simple pattern, test thoroughly, and scale from there.
One long rant worth the pixels: if your team keeps copy-pasting the same mapper and the same ToString() for every new model, you are not “moving fast,” you are accruing diff noise that will explode the first time someone renames a property and forgets to update the third copy of the same method.
Next steps:
- Clone the playground repo: https://github.com/animat089/playground/tree/main/SourceGenerators
- Run
dotnet testand inspect generated.g.csoutput underobj/ - Compare source-generator output with the checked-in T4 output under
tools/
If you’ve built a generator for a different pattern, I’d be curious to see it.
Comments