.NET & C# Development · Lesson 15 of 229
Source Generators in C# — Code That Writes Code
Source Generators in C# — Code That Writes Code
Source generators run during compilation and inject new C# files into the build. They eliminate reflection-based runtime code, produce AOT-compatible output, and generate boilerplate that would otherwise be handwritten.
How Source Generators Work
Compilation pipeline:
1. Compiler reads your .cs files
2. Roslyn creates a syntax tree for each file
3. Source generator inspects the syntax tree
4. Generator emits new .cs files into the compilation
5. Final assembly includes both your code and generated code
You see generated files in:
obj/Debug/net9.0/generated//... Incremental Generator (the modern API)
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Collections.Immutable;
using System.Text;
[Generator]
public class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Step 1: select classes marked with [GenerateToString]
IncrementalValuesProvider<ClassDeclarationSyntax> classes = context
.SyntaxProvider
.CreateSyntaxProvider(
predicate: static (node, _) =>
node is ClassDeclarationSyntax cls &&
cls.AttributeLists.Count > 0,
transform: static (ctx, _) =>
(ClassDeclarationSyntax)ctx.Node
)
.Where(static c => c is not null)!;
// Step 2: combine with compilation info for symbol resolution
IncrementalValueProvider<(Compilation, ImmutableArray<ClassDeclarationSyntax>)> combined =
context.CompilationProvider.Combine(classes.Collect());
// Step 3: generate source for each matching class
context.RegisterSourceOutput(combined, static (spc, source) =>
{
var (compilation, classDeclarations) = source;
foreach (var cls in classDeclarations)
GenerateToString(spc, compilation, cls);
});
}
private static void GenerateToString(
SourceProductionContext context,
Compilation compilation,
ClassDeclarationSyntax cls)
{
var model = compilation.GetSemanticModel(cls.SyntaxTree);
var symbol = model.GetDeclaredSymbol(cls) as INamedTypeSymbol;
if (symbol is null) return;
// Check for [GenerateToString] attribute
var attr = symbol.GetAttributes()
.FirstOrDefault(a => a.AttributeClass?.Name == "GenerateToStringAttribute");
if (attr is null) return;
var ns = symbol.ContainingNamespace.ToDisplayString();
var name = symbol.Name;
// Get all public properties
var props = symbol.GetMembers()
.OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public);
var sb = new StringBuilder();
sb.AppendLine($"namespace {ns};");
sb.AppendLine($"partial class {name}");
sb.AppendLine("{");
sb.Append($" public override string ToString() => $\"");
sb.Append(name + " {{ ");
sb.Append(string.Join(", ", props.Select(p => $"{p.Name}={{{p.Name}}}")));
sb.Append(" }}\";");
sb.AppendLine();
sb.AppendLine("}");
context.AddSource($"{name}.g.cs", sb.ToString());
}
}Using the Generator
// Mark your class with the attribute
[GenerateToString]
public partial class Order
{
public int Id { get; init; }
public string Reference { get; init; } = "";
public decimal Total { get; init; }
}
// Generated at compile time (obj/Debug/.../Order.g.cs):
// namespace YourNamespace;
// partial class Order
// {
// public override string ToString() =>
// $"Order {{ Id={Id}, Reference={Reference}, Total={Total} }}";
// }
var order = new Order { Id = 1, Reference = "ORD-001", Total = 99.99m };
Console.WriteLine(order);
// Order { Id=1, Reference=ORD-001, Total=99.99 }Real-World Use Cases
Compile-Time Logging (LoggerMessage)
// This is what .NET itself generates for high-performance logging
// Source generator creates static LoggerMessage delegates — zero allocation
public static partial class Log
{
[LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} created")]
public static partial void OrderCreated(ILogger logger, int orderId);
[LoggerMessage(Level = LogLevel.Error, Message = "Payment failed for {OrderId}: {Reason}")]
public static partial void PaymentFailed(ILogger logger, int orderId, string reason);
}
// Usage:
Log.OrderCreated(logger, 42);
Log.PaymentFailed(logger, 42, "Insufficient funds");
// No string formatting at runtime unless the log level is enabledSystem.Text.Json Source Generation
using System.Text.Json.Serialization;
// Generates fast serialiser/deserialiser at compile time
// No reflection at runtime — AOT compatible
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
public partial class AppJsonContext : JsonSerializerContext { }
// Usage with generated context:
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
var obj = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);Regex Source Generation (C# 11+)
using System.Text.RegularExpressions;
public partial class EmailValidator
{
// Generates a compiled Regex at build time — faster than runtime compilation
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.IgnoreCase)]
private static partial Regex EmailPattern();
public static bool IsValid(string email)
=> EmailPattern().IsMatch(email);
}Setting Up a Generator Project
<!-- YourGenerator.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.x.x" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.x.x" PrivateAssets="all" />
</ItemGroup>
</Project>
<!-- Consumer project references generator as analyzer -->
<!-- YourApp.csproj -->
<ItemGroup>
<ProjectReference Include="..\YourGenerator\YourGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>Interview Answer
"Source generators run as part of the Roslyn compilation pipeline — they inspect syntax trees and emit new .cs files that are compiled alongside your code. The key benefit over reflection is compile-time safety and zero runtime cost (no assembly scanning at startup). The modern API is
IIncrementalGenerator, which uses fine-grained tracking to avoid re-running when unrelated files change. Real uses in the .NET ecosystem:[LoggerMessage]generates zero-allocation log delegates,[JsonSerializable]generates System.Text.Json serialisers without reflection (AOT-compatible), and[GeneratedRegex]compiles Regex at build time. When building your own: useIIncrementalGenerator(not the olderISourceGenerator), test withMicrosoft.CodeAnalysis.Testing, and emitpartialclasses so consumers can add their own members alongside generated ones."