Source generator authoring: IIncrementalGenerator, project setup (netstandard2.0, analyser references), post-initialisation output, debugging, packaging and pitfalls.
A source generator is a class marked [Generator] that implements IIncrementalGenerator (the older ISourceGenerator is deprecated). It runs inside the compiler, reads the user's code through Roslyn and adds new C# files to the compilation; it cannot modify existing code. The generator project targets netstandard2.0, references Microsoft.CodeAnalysis.CSharp (a version no newer than your consumers' compilers) with PrivateAssets="all", and sets EnforceExtendedAnalyzerRules. Consumers reference it as an analyser (OutputItemType="Analyzer" ReferenceOutputAssembly="false"), or you pack it into analyzers/dotnet/cs in a NuGet package. Use RegisterPostInitializationOutput for marker attributes and EmitCompilerGeneratedFiles to see the output.
A source generator is a class implementing IIncrementalGenerator that Roslyn loads during compilation. It builds an incremental pipeline: select the syntax/symbols it cares about (usually via an attribute), transform them into a small equatable model, and emit source with AddSource. The generator lives in its own netstandard2.0 project referencing Microsoft.CodeAnalysis.CSharp, is packaged as an analyzer in NuGet, and must be tested and versioned carefully because it runs inside every developer's IDE.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework> <!-- required: runs inside the compiler/IDE -->
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput> <!-- don't put the DLL in lib/ -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.x" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.x" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>
netstandard2.0 so it loads in Visual Studio (.NET Framework), Rider and the .NET SDK compiler.RegisterPostInitializationOutput.Consuming locally (for development): <ProjectReference Include="..\Gen\Gen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />.
[GenerateToString][Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Emit the marker attribute into every consuming compilation
context.RegisterPostInitializationOutput(static ctx => ctx.AddSource("GenerateToStringAttribute.g.cs", """
namespace Sangam.Generators;
[System.AttributeUsage(System.AttributeTargets.Class)]
internal sealed class GenerateToStringAttribute : System.Attribute { }
"""));
// 2. Find classes with the attribute and build an equatable model (no Roslyn symbols in the model!)
var models = context.SyntaxProvider.ForAttributeWithMetadataName(
"Sangam.Generators.GenerateToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) =>
{
var type = (INamedTypeSymbol)ctx.TargetSymbol;
var props = type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.Select(p => p.Name).ToImmutableArray();
return new TypeModel(type.ContainingNamespace.ToDisplayString(), type.Name, new EquatableArray<string>(props));
});
// 3. Generate source for each model
context.RegisterSourceOutput(models, static (spc, m) =>
{
var body = string.Join(", ", m.Properties.Select(p => $"{p} = {{{p}}}"));
spc.AddSource($"{m.Name}.ToString.g.cs", $$"""
namespace {{m.Namespace}};
partial class {{m.Name}}
{
public override string ToString() => $"{{m.Name}} { {{body}} }";
}
""");
});
}
}
internal sealed record TypeModel(string Namespace, string Name, EquatableArray<string> Properties);
Key points:
ForAttributeWithMetadataName is the fast, recommended way to find attributed declarations (it uses compiler indexes).ImmutableArray<T> uses reference equality) so the pipeline caches results — never store ISymbol, SyntaxNode or Compilation in models.spc.ReportDiagnostic(Diagnostic.Create(...)) (e.g. class not partial)..g.cs.CSharpGeneratorDriver over a test compilation and compare output (Verify.SourceGenerators).Cached/Unchanged (IncrementalStepRunReason) via trackIncrementalGeneratorSteps: true.var driver = CSharpGeneratorDriver.Create(new ToStringGenerator()).RunGenerators(compilation);
var result = driver.GetRunResult();
dotnet pack producing analyzers/dotnet/cs/YourGenerator.dll (and dependencies).partial declarations and diagnostics.We replaced runtime reflection that mapped domain events to integration messages with a generator: [IntegrationEvent("orders.placed.v1")] on records generated a JsonSerializerContext registration, a mapping method and a topic registry. The first version stored ISymbols in the pipeline model and made Visual Studio sluggish on large solutions (regenerating on every keystroke). Switching to ForAttributeWithMetadataName and value-equatable models fixed IDE performance, confirmed by incrementality tests.
The interviewer checks practical authoring experience, not just awareness.
Green flags: IIncrementalGenerator, netstandard2.0, analyser reference and packaging path, post-initialisation output, partial types, EmitCompilerGeneratedFiles, version pinning, testing with the driver.
Red flags: thinks generators can rewrite user code; targets modern TFMs; does I/O in generators.
IncrementalValueProvider<T> (single value) and IncrementalValuesProvider<T> (many).Select, SelectMany, Where, Combine, Collect, WithComparer, WithTrackingName.SyntaxProvider (syntax/attribute based), CompilationProvider, AdditionalTextsProvider (files), AnalyzerConfigOptionsProvider (.editorconfig/MSBuild properties via CompilerVisibleProperty), ParseOptionsProvider, MetadataReferencesProvider.RegisterSourceOutput, RegisterImplementationSourceOutput (not needed for IDE semantics), RegisterPostInitializationOutput (static source like attributes).Each step's output is compared with the previous run using equality; if equal, downstream steps are skipped. Hence models must have value equality. Combining with CompilationProvider invalidates on every edit — avoid it or extract only what you need.
If the generator depends on other libraries (e.g. Scriban for templating), they must be packed into the analyzer folder and loadable in the compiler process; conflicts with other analysers' dependencies can occur. Prefer no dependencies.
Expose options via MSBuild properties: <CompilerVisibleProperty Include="MyGenerator_Namespace" /> in a .props file packed in build/, read via AnalyzerConfigOptionsProvider.
IsRoslynComponent + a launch profile with "commandName": "DebugRoslynComponent" lets you debug the generator in VS against a target project.Debugger.Launch() (temporarily) in Initialize.[GenerateToString] generator and reference it from a sample project.// Gen.csproj: netstandard2.0, Microsoft.CodeAnalysis.CSharp (PrivateAssets=all)
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
[Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Marker attribute added to every consuming compilation
context.RegisterPostInitializationOutput(static ctx => ctx.AddSource("GenerateToStringAttribute.g.cs", """
// <auto-generated/>
namespace Demo
{
[global::System.AttributeUsage(global::System.AttributeTargets.Class)]
internal sealed class GenerateToStringAttribute : global::System.Attribute { }
}
"""));
// 2. Find annotated classes and 3. reduce them to a simple model (strings only)
IncrementalValuesProvider<(string? Namespace, string Name, string Properties)> models =
context.SyntaxProvider.ForAttributeWithMetadataName(
"Demo.GenerateToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) =>
{
var type = (INamedTypeSymbol)ctx.TargetSymbol;
string props = string.Join(",", type.GetMembers().OfType<IPropertySymbol>()
.Where(p => p.DeclaredAccessibility == Accessibility.Public && !p.IsStatic)
.Select(p => p.Name));
string? ns = type.ContainingNamespace.IsGlobalNamespace ? null : type.ContainingNamespace.ToDisplayString();
return (ns, type.Name, props);
});
// 4. Render source from the model
context.RegisterSourceOutput(models, static (spc, m) =>
{
var sb = new StringBuilder("// <auto-generated/>\n#nullable enable\n");
if (m.Namespace is not null) sb.Append($"namespace {m.Namespace};\n");
string body = string.Join(", ", m.Properties.Split(new[] { ',' }, System.StringSplitOptions.RemoveEmptyEntries)
.Select(p => $"{p} = {{{p}}}"));
sb.Append($"partial class {m.Name}\n{{\n public override string ToString() => $\"{m.Name} {{{{ {body} }}}}\";\n}}\n");
spc.AddSource($"{m.Name}.ToString.g.cs", sb.ToString());
});
}
}
// Consumer project
using Demo;
Console.WriteLine(new Order { Id = 7, Status = "Paid" }); // Order { Id = 7, Status = Paid }
[GenerateToString]
partial class Order
{
public int Id { get; init; }
public string Status { get; init; } = "";
}
ISourceGenerator (v1 API)What happens: writing non-incremental generators.
Why it is wrong: runs on every keystroke; slows the IDE; deprecated pattern.
Do instead: IIncrementalGenerator.
What happens: pipeline models contain ISymbol/SyntaxNode.
Why it is wrong: breaks caching and keeps compilations alive.
Do instead: extract strings/values into equatable records.
What happens: generator targets net8.0.
Why it is wrong: won't load in Visual Studio's compiler host.
Do instead: netstandard2.0.
What happens: generator requires the latest Roslyn. Why it is wrong: consumers on older SDKs/IDEs can't load it. Do instead: reference the minimum supported version.
What happens: generator DLL in lib/.
Why it is wrong: not loaded as an analyzer; may be referenced at run time.
Do instead: pack into analyzers/dotnet/cs.
What happens: Combine(context.CompilationProvider) at the start.
Why it is wrong: invalidates on every change.
Do instead: use targeted providers.
What happens: generator skips invalid inputs without diagnostics. Why it is wrong: confusing missing code. Do instead: report diagnostics.
What happens: generator output changes unnoticed. Why it is wrong: breaking changes for consumers. Do instead: snapshot and compile tests in CI.
Generators shift work from run time to build time, with real maintenance cost.
| Benefit | Cost |
|---|---|
| No reflection at run time, trimming and AOT friendly | Generator code is harder to write and test |
| Generated code is visible and debuggable | Build performance depends on generator quality |
| Compile-time diagnostics for misuse | Version compatibility with consumers' compilers |
Seniors use existing generators first (JSON, logging, regex, configuration binding) and write their own only for repeated, stable patterns.
An incremental generator declares a pipeline of transformations (Select, Where, Combine, Collect) over providers such as SyntaxProvider, CompilationProvider and AdditionalTextsProvider. Roslyn caches each step's output and, on every keystroke, reruns a step only if its input changed, comparing values with equality. So the models flowing through the pipeline must be cheap, value-equal types (records of strings, an equatable array wrapper), never ISymbol, SyntaxNode or Compilation, which change every edit and defeat caching. Use ForAttributeWithMetadataName for attribute-driven generators: it is far faster than scanning every syntax node. A slow or non-incremental generator makes typing in the IDE lag.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
An analyser is a class deriving from DiagnosticAnalyzer with [DiagnosticAnalyzer(LanguageNames.CSharp)]. It declares DiagnosticDescriptors (id, title, message, category, default severity) and in Initialize calls EnableConcurrentExecution(), ConfigureGeneratedCodeAnalysis(...) and registers callbacks such as RegisterSymbolAction, RegisterSyntaxNodeAction or RegisterOperationAction, reporting with context.ReportDiagnostic. It targets netstandard2.0 like a generator. Test it with Microsoft.CodeAnalysis.CSharp.Analyzer.Testing using markup such as {|DEMO001:Run|}. Ship it as a project reference with OutputItemType="Analyzer" or a NuGet package, and let teams tune severity in .editorconfig.
How to tell it (STAR)
In [our payments service], logging DTOs used reflection. I wrote a small incremental generator that emits ToString at build time. Logging cost per call dropped by [X%], trimming warnings disappeared, and snapshot tests keep the output stable.
Last updated 31 Oct 2026.