C# Source Generators: Moving Boilerplate to Compile Time
Source generators move mapping boilerplate from runtime reflection to compile-time code emission. A worked example shows a tiny attribute-driven mapper that produces debuggable, zero-overhead C# during the build.
27 Apr 2026, 06:46 UTC

The problem: runtime reflection and hand-written mappers
Every C# project accumulates mapping code—DTO to entity, request to command, view model to domain object. The usual options are tedious: write and maintain hundreds of lines of assignment statements, or lean on reflection-based libraries that add startup cost and obscure errors until runtime. Both approaches hurt productivity and performance.
Source generators shift that work to the compiler. They run during the build, emit real C# files, and those files are compiled alongside your hand-written code. The result is zero-reflection mapping with full IDE support, and the generated code is just as debuggable as anything you type yourself.
How source generators fit into the build
A source generator is a class that implements ISourceGenerator and is packaged as an analyzer-style NuGet dependency. When the Roslyn compiler loads your project, it instantiates the generator, passes a GeneratorExecutionContext containing the syntax trees and semantic model, and the generator writes new SyntaxTree objects into the context. Those trees become part of the compilation unit—no separate build step, no post-processing.
Because generators execute inside the compiler process, they see the same symbols IntelliSense sees. Errors they report appear as standard compiler diagnostics (CSxxxx), so they show up in the Error List and fail the build just like a missing semicolon.
Worked example: a tiny DTO-to-entity mapper
Suppose you have a simple DTO and an entity:
// UserDto.cs
public record UserDto(string Email, string DisplayName);
// User.cs
public class User {
public string Email { get; set; }
public string DisplayName { get; set; }
}Instead of writing User Map(UserDto d) => new User { Email = d.Email, DisplayName = d.DisplayName }; by hand, annotate the DTO with a marker attribute and let a generator produce the mapper.
// GenerateMapperAttribute.cs
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Record, Inherited = false)]
public sealed class GenerateMapperAttribute : Attribute { }
// UserDto.cs (updated)
[GenerateMapper]
public partial record UserDto(string Email, string DisplayName);The generator (simplified) looks like this:
// MapperGenerator.cs
[Generator]
public class MapperGenerator : ISourceGenerator {
public void Initialize(GeneratorInitializationContext ctx) {}
public void Execute(GeneratorExecutionContext ctx) {
var mapperAttr = ctx.Compilation.GetTypeByMetadataName("GenerateMapperAttribute");
if (mapperAttr is null) return;
foreach (var syntaxTree in ctx.Compilation.SyntaxTrees) {
var model = ctx.Compilation.GetSemanticModel(syntaxTree);
var root = syntaxTree.GetRoot();
var candidates = root.DescendantNodes()
.OfType()
.Where(t => t.AttributeLists.SelectMany(a => a.Attributes)
.Any(attr => model.GetSymbolInfo(attr).Symbol?.ContainingType.Equals(mapperAttr, SymbolEqualityComparer.Default) == true));
foreach (var candidate in candidates) {
var typeSymbol = model.GetDeclaredSymbol(candidate) as INamedTypeSymbol;
if (typeSymbol is null) continue;
var props = typeSymbol.GetMembers().OfType().Where(p => !p.IsStatic);
var assignments = string.Join(", ", props.Select(p => $"{p.Name} = src.{p.Name}"));
var targetName = typeSymbol.Name.Replace("Dto", "");
var source = $$"
//
using System;
namespace {{typeSymbol.ContainingNamespace.ToDisplayString()}} {
public static partial class {{typeSymbol.Name}}Mapper {
public static {{targetName}} Map({{typeSymbol.Name}} src) =>
new {{targetName}} { {{assignments}} };
}
}
";
ctx.AddSource($"{typeSymbol.Name}Mapper.g.cs", SourceText.From(source, Encoding.UTF8));
}
}
}
}
}Add the generator project as a ProjectReference with OutputItemType="Analyzer" and ReferenceOutputAssembly="false" in the consumer csproj. After build, you’ll find UserDtoMapper.g.cs under obj/Debug/net8.0/generated/ (path varies by TFM). The mapper is now callable:
var user = UserDtoMapper.Map(new UserDto("[contact removed]", "Alice"));No reflection, no runtime library, and the generated method appears in IntelliSense.
Trade-offs you’ll actually feel
- Build time: Each generator runs for every project that references it. A generator that emits thousands of files can add seconds to incremental builds. Profile with
dotnet build -clp:PerformanceSummary. - Debugging generated code: The
.g.csfiles are not in your source tree. Set<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>and<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>to materialize them in a folder you can open and breakpoint. - Version coupling: A breaking change in the generator requires all consumers to rebuild. Ship generators as separate NuGet packages with clear semantic versioning.
- No runtime flexibility: Generated code is fixed at compile time. If you need dynamic mapping based on configuration, keep a reflection fallback for those paths.
When to adopt and how to verify
Adopt source generators when you have repetitive, structurally similar code that can be described declaratively (attributes, interfaces, naming conventions). Start small: pick one mapper or DTO set, write a generator, and measure the diff in lines of hand-written code and build time.
Verification checklist:
- Build the consumer project; confirm
obj/**/generated/*.g.csappears. - Open a generated file, set a breakpoint, run under the debugger—execution should stop there.
- Run
dotnet build --no-incrementaltwice; the second run should be noticeably faster if the generator is incremental (implementsIIncrementalGenerator). - Introduce a deliberate syntax error in the generator output; the build must fail with a clear diagnostic pointing to the generated file.
If all four pass, the generator is safe to promote to shared infrastructure.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.