Real-World C# Source Generator Examples: ToString, Mapping, and Serialization
If you have spent any time writing .NET code, you know the drill. You write a class, then you write the boilerplate -- the ToString() override, the mapping method, the serialization configuration. Multiply that across dozens of types and it quietly becomes a maintenance liability. C# source generator examples are the antidote: compile-time code generation that produces zero-reflection, AOT-safe, always-in-sync boilerplate directly from your type definitions, with no runtime cost.
This article is not about what source generators are in theory. It is about putting them to work on three patterns every .NET developer encounters repeatedly. By the end, you will have working implementations for auto-generating ToString(), compile-time object mapping, and serialization context registration -- all built on IIncrementalGenerator and targeting .NET 10 / C# 13.
Each example follows the same walkthrough structure: the problem it solves, the marker attribute, the generator implementation, the generated output, and how you consume it. You will also see where these patterns compose with each other and what lessons transfer across all three.
Why These Three Examples?
Picking good examples for source generators means picking problems that are genuinely painful without generation. ToString, mapping, and serialization qualify on all three counts.
ToString() overrides are tedious and drift-prone. Every time a new property is added to a class, the manual ToString() implementation silently becomes stale. Unit tests rarely catch it because you have to remember to write the assertion in the first place. A generator solves this structurally -- the output regenerates at every build automatically, no discipline required.
Object mapping -- converting between domain types and DTOs -- is a different kind of pain. Reflection-based mappers work at runtime, which means mapping misconfiguration is discovered late, AOT compilation breaks, and startup time pays a tax while the mapper builds its internal type metadata tables. Compile-time mapping eliminates all of that before the program even runs.
Serialization context is the third piece. Since System.Text.Json introduced source generation for JSON, it has become the recommended approach for NativeAOT scenarios. Understanding how the pattern works by building a simplified version gives you both the skill and the insight to extend it for your own use cases.
These three C# source generator examples also represent a natural progression in complexity -- from straightforward string interpolation to method body synthesis to attribute-driven type registration. Working through all three builds the muscle memory you need for generator design in general.
Example 1: Auto-Generating ToString()
The ToString() generator is the simplest of the three examples and makes a clean introduction to the incremental generator API. It covers the core concepts -- attribute injection, syntax-to-symbol transformation, and source emission -- without requiring complex method analysis or multi-type coordination. Work through this one first and the patterns in the next two examples will come naturally.
The Problem
Manual ToString() overrides in C# look something like this:
public override string ToString() =>
$"Order {{ Id = {Id}, CustomerName = {CustomerName}, Total = {Total} }}";
That is fine for one class. For a domain with fifty types, each carrying six to twelve properties, it is fifty methods to write, keep updated when properties change, and remember to test. The friction is low per instance but compounds fast across a real codebase.
The Marker Attribute
The generator injects the attribute itself via RegisterPostInitializationOutput, which means your consuming project does not need to reference a separate attribute library. The generator adds it directly to the compilation.
// Injected by the generator -- no NuGet reference required in the consumer
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
internal sealed class GenerateToStringAttribute : Attribute { }
Apply it to any partial class or struct:
[GenerateToString]
public partial class Order
{
public int Id { get; init; }
public required string CustomerName { get; init; }
public decimal Total { get; init; }
}
The Generator Implementation
The generator uses ForAttributeWithMetadataName (available since Roslyn 4.3.1 / .NET 7 SDK -- if your build toolchain uses the .NET 6 SDK, fall back to CreateSyntaxProvider with a manual attribute name check until you upgrade) -- the incremental API designed specifically for attribute-driven generation. It is faster than a syntax-only provider because Roslyn computes incremental diffs and skips regeneration when nothing relevant has changed since the last build.
using System.Collections.Immutable;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
[Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
private const string AttributeFullName = "ToStringGenerator.GenerateToStringAttribute";
private const string AttributeSource = """
using System;
namespace ToStringGenerator;
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Struct)]
internal sealed class GenerateToStringAttribute : Attribute { }
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
"GenerateToStringAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
var typeDeclarations = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeFullName,
predicate: static (node, _) =>
node is ClassDeclarationSyntax or StructDeclarationSyntax,
transform: static (ctx, ct) => GetTypeModel(ctx, ct))
.Where(static m => m is not null);
context.RegisterSourceOutput(typeDeclarations,
static (spc, model) => Emit(spc, model!));
}
private static TypeModel? GetTypeModel(
GeneratorAttributeSyntaxContext ctx,
CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol typeSymbol)
{
return null;
}
var properties = typeSymbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetMethod is not null && !p.IsStatic)
.Select(p => p.Name)
.ToImmutableArray();
return new TypeModel(
typeSymbol.ContainingNamespace.ToDisplayString(),
typeSymbol.Name,
properties,
typeSymbol.TypeKind == TypeKind.Struct);
}
private static void Emit(SourceProductionContext spc, TypeModel model)
{
var props = string.Join(", ",
model.Properties.Select(p => $"{p} = {{{p}}}"));
var source = $$$$"""
namespace {{{{model.Namespace}}}};
partial {{{{(model.IsStruct ? "struct" : "class")}}}} {{{{model.TypeName}}}}
{
public override string ToString() =>
$"{{{{model.TypeName}}}} {{ {{{{props}}}} }}";
}
""";
spc.AddSource(
$"{model.TypeName}.ToString.g.cs",
SourceText.From(source, Encoding.UTF8));
}
private sealed record TypeModel(
string Namespace,
string TypeName,
ImmutableArray<string> Properties,
bool IsStruct);
}
The Generated Output
For the Order class shown above, the generator emits:
// Order.ToString.g.cs (generated -- do not edit)
namespace YourApp.Domain;
partial class Order
{
public override string ToString() =>
$"Order {{ Id = {Id}, CustomerName = {CustomerName}, Total = {Total} }}";
}
Add a property to Order? The generated file updates at the next build automatically. Remove a property? Same thing. The output always mirrors the current shape of the type.
This pattern integrates cleanly with the factory method pattern -- if your factories log the objects they create, source-generated ToString() keeps those log lines accurate without any manual maintenance effort.
Example 2: Compile-Time Object Mapper
The object mapper generator is a step up in complexity from the ToString generator. Instead of emitting a single method on the annotated type, it must parse partial method signatures declared by the consumer, resolve both the source and target types via the semantic model, and emit complete method bodies that assign properties by name and type compatibility. The result is a zero-reflection, AOT-compatible mapper that competes directly with hand-authored code.
The Problem
Reflection-based object mappers are convenient until they are not. Runtime mapping configuration means misconfigured maps surface as exceptions in production rather than compile errors at build time. AOT compilation either breaks entirely or requires complex trimming annotations. And at startup, reflection-based mappers build internal type metadata tables, which adds latency you pay on every cold start.
Compile-time mapping eliminates all of these concerns. The builder pattern provides a useful conceptual parallel: just as a builder captures step-by-step construction logic that resolves at call time, a compile-time mapper captures field-by-field transformation as static code that resolves at compile time -- fully typed, fully visible, fully AOT-compatible.
The Marker Attribute and Partial Method Contract
// Injected by the generator
[AttributeUsage(AttributeTargets.Class)]
internal sealed class GenerateMapperAttribute : Attribute { }
The consumer declares a partial class with partial method signatures. The generator fills in the implementations. This is the same partial-class contract that System.Text.Json's built-in generator uses for JsonSerializerContext.
// Consumer-authored -- your mapping contract
[GenerateMapper]
public static partial class UserMapper
{
public static partial UserDto MapToDto(User source);
public static partial User MapFromDto(UserDto source);
}
Your domain types use C# 11 required members:
public sealed class User
{
public required int Id { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
}
public sealed class UserDto
{
public required int Id { get; init; }
public required string Name { get; init; }
public required string Email { get; init; }
}
The Generator Implementation
This generator is more involved than the ToString generator because it must parse partial method signatures, resolve the parameter and return types, and match properties by name and type compatibility.
using System.Collections.Immutable;
[Generator]
public sealed class MapperGenerator : IIncrementalGenerator
{
private const string AttributeFullName = "MapperGenerator.GenerateMapperAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx => ctx.AddSource(
"GenerateMapperAttribute.g.cs",
SourceText.From(AttributeSource, Encoding.UTF8)));
var mapperClasses = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeFullName,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => GetMapperModel(ctx, ct))
.Where(static m => m is not null);
context.RegisterSourceOutput(mapperClasses,
static (spc, model) => EmitMapper(spc, model!));
}
private static MapperModel? GetMapperModel(
GeneratorAttributeSyntaxContext ctx,
CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol classSymbol)
{
return null;
}
var methods = classSymbol.GetMembers()
.OfType<IMethodSymbol>()
.Where(m => m.IsPartialDefinition && m.IsStatic && m.Parameters.Length == 1)
.Select(m => new MappingMethod(
m.Name,
m.Parameters[0].Type.ToDisplayString(),
m.Parameters[0].Name,
m.ReturnType.ToDisplayString(),
GetMatchedProperties(m.Parameters[0].Type, m.ReturnType)))
.ToImmutableArray();
return new MapperModel(
classSymbol.ContainingNamespace.ToDisplayString(),
classSymbol.Name,
methods);
}
private static ImmutableArray<string> GetMatchedProperties(ITypeSymbol source, ITypeSymbol target)
{
var sourceProps = source.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetMethod is not null && !p.IsStatic)
.ToDictionary(p => p.Name, p => p.Type.ToDisplayString());
return target.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.SetMethod is not null && !p.IsStatic)
.Where(p => sourceProps.TryGetValue(p.Name, out var srcType)
&& srcType == p.Type.ToDisplayString())
.Select(p => p.Name)
.ToImmutableArray();
}
private static void EmitMapper(SourceProductionContext spc, MapperModel model)
{
var methodBodies = new StringBuilder();
foreach (var method in model.Methods)
{
var assignments = string.Join(",
",
method.MatchedProperties.Select(p =>
$"{p} = {method.SourceParamName}.{p}"));
methodBodies.AppendLine($$"""
public static partial {{method.ReturnType}} {{method.Name}}(
{{method.SourceType}} {{method.SourceParamName}}) =>
new {{method.ReturnType}}
{
{{assignments}}
};
""");
}
var source = $$"""
namespace {{model.Namespace}};
static partial class {{model.ClassName}}
{
{{methodBodies}}
}
""";
spc.AddSource(
$"{model.ClassName}.Mapper.g.cs",
SourceText.From(source, Encoding.UTF8));
}
private sealed record MapperModel(
string Namespace,
string ClassName,
ImmutableArray<MappingMethod> Methods);
private sealed record MappingMethod(
string Name,
string SourceType,
string SourceParamName,
string ReturnType,
ImmutableArray<string> MatchedProperties);
private const string AttributeSource = """
using System;
namespace MapperGenerator;
[AttributeUsage(AttributeTargets.Class)]
internal sealed class GenerateMapperAttribute : Attribute { }
""";
}
Generated Code Walkthrough
For UserMapper, the generator emits:
// UserMapper.Mapper.g.cs (generated -- do not edit)
namespace YourApp.Mapping;
static partial class UserMapper
{
public static partial UserDto MapToDto(User source) =>
new UserDto
{
Id = source.Id,
Name = source.Name,
Email = source.Email
};
public static partial User MapFromDto(UserDto source) =>
new User
{
Id = source.Id,
Name = source.Name,
Email = source.Email
};
}
This is plain C# with no reflection, no runtime type inspection, and no dynamic invocation. The output is type-safe, AOT-compatible, and as fast as hand-written code -- because it effectively is hand-written code, just authored by the generator at compile time.
Consumer Usage
var user = new User { Id = 1, Name = "Alice", Email = "[email protected]" };
UserDto dto = UserMapper.MapToDto(user);
User roundTripped = UserMapper.MapFromDto(dto);
IntelliSense sees the generated partial methods as complete implementations. Rename a property in either type and the generator updates both mapping methods at the next build. If a property name or type mismatch means no match is found, the generator omits that assignment. For target types without required members, this is a silent omission. For target types with required properties, an unmatched required property will cause a compile error -- which is actually the desired behavior (a hard, visible signal rather than a runtime mapping gap). In a production generator, add a ReportDiagnostic warning for each unmatched property so the developer sees it before the compile error.
Example 3: JSON Serialization Context
The serialization context generator demonstrates a third pattern: attribute-driven type registration that builds a static lookup table at compile time. This is the same mechanism that System.Text.Json's own built-in generator uses for JsonSerializerContext, and working through a simplified version of it demystifies the approach. You come away with both the implementation and the mental model for building your own attribute-driven registration generators in any domain.
The Problem
System.Text.Json default serialization uses reflection to discover properties at runtime. That works well in a classic hosted application, but it breaks in two important scenarios: NativeAOT compilation (where reflection is either stripped by the trimmer or prohibitively costly) and Blazor WebAssembly (where bundle size budgets make reflection metadata a liability).
The decorator pattern applied to serialization -- wrapping types with logging or validation before they reach the serializer -- also becomes fragile when the serializer's own behavior is runtime-determined. A compile-time-registered type table makes the entire serialization stack predictable and auditable.
The JsonSerializerContext Pattern as Inspiration
System.Text.Json already ships a source generator. You opt in with [JsonSerializable] on a JsonSerializerContext subclass, and the generator emits type metadata without reflection. Understanding how to build something analogous teaches you how to design attribute-driven registration generators that produce static lookup tables.
The three-step pattern:
- The user annotates a
partialcontext class with attributes naming the types to register. - The generator discovers those attributes and inspects the named types via the semantic model.
- The generator emits registration code -- type metadata, property lists, and property accessor delegates.
The Custom Serialization Generator
For this example, the generated output produces a static registry with per-type metadata entries that a hand-rolled, reflection-free serializer can consume.
// Consumer-authored context declaration
[SerializationContext]
[IncludeType(typeof(ProductCatalog))]
[IncludeType(typeof(Product))]
public partial class AppSerializationContext { }
The attributes are injected by the generator:
// Injected -- SerializationContextAttribute
[AttributeUsage(AttributeTargets.Class)]
internal sealed class SerializationContextAttribute : Attribute { }
// Injected -- IncludeTypeAttribute (AllowMultiple so many types can be registered)
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
internal sealed class IncludeTypeAttribute : Attribute
{
public IncludeTypeAttribute(Type type) { }
}
The generator collects all IncludeType attribute arguments, resolves the referenced types via the semantic model, and emits a registration table:
using System.Collections.Immutable;
[Generator]
public sealed class SerializationContextGenerator : IIncrementalGenerator
{
private const string ContextAttrFullName =
"SerializationGenerator.SerializationContextAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(ctx =>
{
ctx.AddSource("SerializationContextAttribute.g.cs",
SourceText.From(ContextAttributeSource, Encoding.UTF8));
ctx.AddSource("IncludeTypeAttribute.g.cs",
SourceText.From(IncludeTypeAttributeSource, Encoding.UTF8));
ctx.AddSource("TypeMetadata.g.cs",
SourceText.From(TypeMetadataSource, Encoding.UTF8));
});
var contextClasses = context.SyntaxProvider
.ForAttributeWithMetadataName(
ContextAttrFullName,
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, ct) => GetContextModel(ctx, ct))
.Where(static m => m is not null);
context.RegisterSourceOutput(contextClasses,
static (spc, model) => EmitContext(spc, model!));
}
private static ContextModel? GetContextModel(
GeneratorAttributeSyntaxContext ctx,
CancellationToken ct)
{
if (ctx.TargetSymbol is not INamedTypeSymbol contextSymbol)
{
return null;
}
const string includeAttrName = "SerializationGenerator.IncludeTypeAttribute";
var includedTypes = contextSymbol.GetAttributes()
.Where(a => a.AttributeClass?.ToDisplayString() == includeAttrName)
.Select(a => a.ConstructorArguments.FirstOrDefault().Value as INamedTypeSymbol)
.Where(t => t is not null)
.Cast<INamedTypeSymbol>()
.Select(t => new SerializableType(
t.ToDisplayString(),
t.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.GetMethod is not null
&& p.SetMethod is not null
&& !p.IsStatic)
.Select(p => new PropertyEntry(p.Name, p.Type.ToDisplayString()))
.ToImmutableArray()))
.ToImmutableArray();
return new ContextModel(
contextSymbol.ContainingNamespace.ToDisplayString(),
contextSymbol.Name,
includedTypes);
}
private static void EmitContext(SourceProductionContext spc, ContextModel model)
{
var registrations = new StringBuilder();
foreach (var type in model.Types)
{
var propEntries = string.Join(",
",
type.Properties.Select(p =>
$"""new PropertyMetadata("{p.Name}", typeof({p.TypeName}), obj => (({type.FullName})obj).{p.Name})"""));
registrations.AppendLine($$"""
Register(new TypeMetadata(
typeof({{type.FullName}}),
[
{{propEntries}}
]));
""");
}
var source = $$"""
using System;
using System.Collections.Generic;
namespace {{model.Namespace}};
partial class {{model.ContextName}}
{
private static readonly Dictionary<Type, TypeMetadata> _registry = [];
public static TypeMetadata? GetMetadata(Type type) =>
_registry.TryGetValue(type, out var meta) ? meta : null;
static {{model.ContextName}}()
{
{{registrations}}
}
private static void Register(TypeMetadata meta) =>
_registry[meta.Type] = meta;
}
""";
spc.AddSource(
$"{model.ContextName}.SerializationContext.g.cs",
SourceText.From(source, Encoding.UTF8));
}
private sealed record ContextModel(
string Namespace,
string ContextName,
ImmutableArray<SerializableType> Types);
private sealed record SerializableType(
string FullName,
ImmutableArray<PropertyEntry> Properties);
private sealed record PropertyEntry(string Name, string TypeName);
// Attribute source constants elided for brevity -- follow same pattern as Examples 1 and 2
private const string ContextAttributeSource = """
using System;
namespace SerializationGenerator;
[AttributeUsage(AttributeTargets.Class)]
internal sealed class SerializationContextAttribute : Attribute { }
""";
private const string IncludeTypeAttributeSource = """
using System;
namespace SerializationGenerator;
[AttributeUsage(AttributeTargets.Class, AllowMultiple = true)]
internal sealed class IncludeTypeAttribute : Attribute
{
public IncludeTypeAttribute(Type type) { }
}
""";
private const string TypeMetadataSource = """
using System;
namespace SerializationGenerator;
internal sealed record TypeMetadata(Type Type, PropertyMetadata[] Properties);
internal sealed record PropertyMetadata(string Name, Type PropertyType, Func<object, object?> Getter);
""";
}
Generated Code Walkthrough
For AppSerializationContext, the generator emits a static constructor that registers each type's property metadata without a single call to Type.GetProperties():
// AppSerializationContext.SerializationContext.g.cs (generated -- do not edit)
using System;
using System.Collections.Generic;
namespace YourApp.Serialization;
partial class AppSerializationContext
{
private static readonly Dictionary<Type, TypeMetadata> _registry = [];
public static TypeMetadata? GetMetadata(Type type) =>
_registry.TryGetValue(type, out var meta) ? meta : null;
static AppSerializationContext()
{
Register(new TypeMetadata(
typeof(ProductCatalog),
[
new PropertyMetadata("Name", typeof(string), obj => ((ProductCatalog)obj).Name),
new PropertyMetadata("Products", typeof(List<Product>), obj => ((ProductCatalog)obj).Products)
]));
Register(new TypeMetadata(
typeof(Product),
[
new PropertyMetadata("Id", typeof(int), obj => ((Product)obj).Id),
new PropertyMetadata("Price", typeof(decimal), obj => ((Product)obj).Price)
]));
}
private static void Register(TypeMetadata meta) =>
_registry[meta.Type] = meta;
}
At runtime, your serializer calls AppSerializationContext.GetMetadata(typeof(Product)) and walks the property list. No reflection required. The strategy pattern applies naturally here -- your serializer accepts a TypeMetadata and applies whatever serialization strategy is currently in play (JSON, MessagePack, custom binary) all on top of compile-time-generated property metadata. Swap the strategy, not the registry.
Combining Generators: Composable Code Generation
These three C# source generator examples do not have to live in isolation. A realistic project might apply all three to the same domain types simultaneously:
[GenerateToString]
public partial class Product
{
public required int Id { get; init; }
public required string Name { get; init; }
public required decimal Price { get; init; }
}
And separately declare a mapper and a serialization context:
[GenerateMapper]
public static partial class ProductMapper
{
public static partial ProductDto MapToDto(Product source);
public static partial Product MapFromDto(ProductDto source);
}
[SerializationContext]
[IncludeType(typeof(Product))]
[IncludeType(typeof(ProductDto))]
public partial class AppSerializationContext { }
Each generator runs independently in its own incremental pipeline. Adding [GenerateToString] to Product does not affect the serialization generator's pipeline at all, and vice versa. Roslyn's incremental API is designed for exactly this composability -- each generator gets its own isolated cache and only reruns when its specific inputs change.
Source generators also integrate naturally with plugin architectures. When modules register themselves into a DI container at startup, a generator can discover all types marked with [RegisterService] and emit the registration calls -- the same approach that Needlr uses for module-level service wiring, where each plugin's services are discovered and registered without any manual bootstrapping code.
For cross-cutting concerns like logging, caching, and validation, generators can emit decorator classes that wrap the original type without requiring the developer to maintain those wrappers by hand. Mark an interface with [GenerateLoggingDecorator] and the generator outputs a class that delegates every method call to the inner implementation, adding structured log entries before and after each call. The result is the same as a hand-authored decorator, produced without any manual effort, and always in sync with the interface.
Lessons Learned from These Examples
Working through these three generators surfaces a set of patterns that apply broadly across any generator you write.
Use ForAttributeWithMetadataName consistently. The older CreateSyntaxProvider plus a manual attribute name check is slower, more error-prone, and significantly harder to read. ForAttributeWithMetadataName is the correct API for attribute-driven generation in Roslyn today and handles multi-assembly scenarios that naive string matching misses entirely. ForAttributeWithMetadataName (available since Roslyn 4.3.1 / .NET 7 SDK -- if your build toolchain uses the .NET 6 SDK, fall back to CreateSyntaxProvider with a manual attribute name check until you upgrade).
Inject attributes via RegisterPostInitializationOutput. This removes the need for a separate attribute NuGet package in the consuming project. The generator is self-contained, the marker attribute is always in sync with the generator that reads it, and the consumer's project file stays clean.
Use records (or any type with correct structural equality) for all model objects in your transform stage. The incremental pipeline caches step results and compares them with equality checks to decide whether to re-run downstream steps. If your model type does not implement structural equality -- meaning it is not a record or does not override Equals -- Roslyn cannot cache the result and the generator reruns on every keystroke in the IDE. This is the single most common cause of generator-induced IDE slowdowns in heavily annotated solutions. Records give you structural equality by default -- make sure every field is also equality-comparable (use ImmutableArray<T>, not T[]).
Test generators with Microsoft.CodeAnalysis.CSharp.Testing. The testing library lets you assert on both the generated source files and any emitted diagnostics. You provide input C# source, run the generator, and assert on the produced files. Treat these tests with the same rigor you apply to production unit tests. A generator that silently produces wrong output is worse than one that fails loudly.
Design generated code to be readable. Many teams set <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> in their project files, which writes generated files to obj/Generated/ and makes them visible in version control diffs. If a developer has to trace a bug that originates in generated code, readable output with consistent formatting is not a luxury -- it is the difference between a five-minute investigation and a two-hour one.
FAQ
These questions come up regularly when teams first start working with source generators -- especially when making the transition from older reflection-based tools or deciding how to structure a generator project for distribution. The answers below address the most common sticking points across all three C# source generator examples covered in this article.
What is the difference between ISourceGenerator and IIncrementalGenerator?
ISourceGenerator is the original API introduced in .NET 5. It runs your generator on every compilation pass, which makes it slow in large solutions and in the IDE where compilations happen constantly as you type. IIncrementalGenerator -- introduced in .NET 6 -- uses a pipeline model where each stage caches its output and only reruns when its inputs have actually changed. The incremental API is dramatically faster in the IDE and is the only API you should use for any new generator, regardless of the consuming project's target framework. For any new generator targeting a modern .NET SDK (6+), there is no practical reason to use ISourceGenerator. The incremental API is faster, safer, and IDE-friendly. The only exception is if you're constrained to a Roslyn host older than 4.0, which is rare in active projects.
Do source generators work with NativeAOT and Blazor WebAssembly?
Yes -- that is one of the primary motivations for the technology. Because generators produce plain C# at compile time, the output is fully compatible with NativeAOT trimming and WASM bundling. The serialization example in this article exists precisely because runtime reflection is unreliable in these environments. Using a generator-backed type registry means zero runtime reflection, full AOT compatibility, and predictable startup behavior regardless of deployment target.
Can I debug a source generator during development?
Yes. Add System.Diagnostics.Debugger.Launch() inside your Initialize method guarded by #if DEBUG, rebuild the consuming project, and the JIT debugger attaches to the Roslyn compiler host process. Alternatively -- and more practically for day-to-day work -- use the Microsoft.CodeAnalysis.CSharp.Testing package to write unit tests that exercise your generator logic directly. You can set breakpoints, inspect local variables, and step through the transform logic just like any other C# test.
How do I emit warnings or errors from a generator?
Use SourceProductionContext.ReportDiagnostic with a Diagnostic constructed from a DiagnosticDescriptor. Assign unique IDs with a recognizable prefix (e.g., MYSG001) so consumers can suppress them with #pragma warning disable if needed. Diagnostics can be errors that block compilation, warnings the developer should investigate, or informational messages. For soft failures like the unmatched-property case in the mapper example, prefer warnings over errors so the generator still emits partial output -- a partial implementation is more useful during development than a hard compilation failure.
Should I commit generated files to source control?
It depends on team conventions. Setting <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> writes generated files to obj/Generated/ and makes them addressable for version control. Some teams commit them to make the output visible in code review diffs; others add obj/ to .gitignore and rely on the build to regenerate. For open-source projects, committing the generated output is often useful for contributors who want to understand what the generator produces without running a full local build.
Can source generators replace T4 templates?
For most C# code generation use cases, yes -- and the developer experience is significantly better. T4 templates run outside the normal compilation pipeline, require separate tooling, and produce output that is harder to keep in sync with the type system. Source generators integrate directly into the Roslyn pipeline, have full access to semantic information (type symbols, method signatures, attribute arguments, generic constraints), and benefit from incremental caching. T4 remains useful for generating non-C# content -- SQL scripts, XML config, HTML templates -- where source generators do not apply. For generating C# code that depends on type information, generators are the right tool.
How do I distribute a source generator as a NuGet package?
The generator project's .csproj must reference Microsoft.CodeAnalysis.CSharp and include <IncludeBuildOutput>false</IncludeBuildOutput> to exclude the generator assembly from the package's lib/ folder. The generator assembly itself goes into analyzers/dotnet/cs/ in the NuGet layout. A <PackageDevelopmentDependency>true</PackageDevelopmentDependency> property ensures the package does not become a transitive runtime dependency for consumers. The dotnet pack command handles the packaging automatically when those properties are in place.
Conclusion
These three C# source generator examples -- ToString generation, compile-time object mapping, and serialization context registration -- cover the most impactful boilerplate elimination patterns in a modern .NET codebase. Each is built on IIncrementalGenerator, each injects its own marker attribute via RegisterPostInitializationOutput, and each produces readable, AOT-safe, zero-reflection output that competes directly with hand-authored code.
The deeper lesson is that source generators are not magic. They are Roslyn programs that inspect your syntax tree, build an immutable model, and produce a string. The incremental API makes them fast. The semantic model makes them accurate. The rest is C# string interpolation and knowing which type members to look for.
Start with the ToString generator -- it is the simplest and gives you the core mental model: attribute filter to syntax provider, transform to a record model, emit a source string. Once that clicks, the mapper and serialization generators are logical extensions of the same three-step pattern. From there, generated decorators, DI registrations, dispatch tables, and command handlers all follow the same playbook. The boilerplate is optional. The generators make it so.

