Roslyn source generators and analyzers are compiler extensions: small .NET assemblies that the C# compiler loads to inspect your code while it builds, report diagnostics, suggest fixes and add new source files to the compilation. They move work from runtime reflection to compile time, which is why System.Text.Json, logging, regular expressions, configuration binding and ASP.NET Core Minimal APIs all use them to become faster and compatible with Native AOT. This guide is for experienced developers who want to write their own: you will build an incremental generator with ForAttributeWithMetadataName, design cache-friendly models, write an analyzer with a code fix, test and debug everything, package it for NuGet, and understand where interceptors stand today.
What Are Roslyn Source Generators and Analyzers?#
The .NET Compiler Platform (Roslyn) exposes several extension points, all loaded from analyzer assemblies referenced by a project:
- Analyzers (
DiagnosticAnalyzer) inspect syntax, symbols and operations and report diagnostics such as warnings or errors. - Code fixes (
CodeFixProvider) run in the IDE and offer edits that resolve a diagnostic, often as a light bulb action. - Suppressors (
DiagnosticSuppressor) can suppress diagnostics from other analyzers when they know a case is a false positive. - Source generators (
IIncrementalGenerator) read the compilation, plus additional files and MSBuild properties, and add new C# source files to it.
Generators are strictly additive: they can add code, but they cannot modify or delete the code you wrote. That is why most generator-driven APIs use partial classes and methods: you declare the shape, and the generator supplies the implementation. The one exception is interceptors, covered at the end of this guide, which let generated code replace specific call sites.
The original ISourceGenerator API is deprecated in favor of incremental generators. The older API re-ran the whole generator on every change, which made editors sluggish in large solutions; the incremental model caches every step and reruns only what actually changed.
How Incremental Source Generators Work#
An incremental generator declares a pipeline once, in Initialize, and the compiler host executes that pipeline on every compilation: on each build and, in the IDE, very frequently while you type. The pipeline is built from providers, transformations and outputs:
| Building block | Examples | Purpose |
|---|---|---|
| Input providers | SyntaxProvider, CompilationProvider, AdditionalTextsProvider, AnalyzerConfigOptionsProvider | Sources of data from the compilation and project |
| Transformations | Select, Where, SelectMany, Collect, Combine | Turn inputs into the data your generator needs |
| Outputs | RegisterSourceOutput, RegisterImplementationSourceOutput, RegisterPostInitializationOutput | Add source files and report diagnostics |
| Diagnostics for tests | WithTrackingName, WithComparer | Name steps for testing and control equality |
The key idea is caching. After each step, the driver compares the new output with the previous one, using EqualityComparer<T>.Default unless you supply a comparer. If a step produces equal values, everything downstream is skipped and the cached source is reused. A generator is only as incremental as its data model is equatable, which is the single most important design rule in this guide.
RegisterPostInitializationOutput runs once, before anything else, and cannot see user code. It is the right place to emit marker attributes. RegisterImplementationSourceOutput produces code that does not affect the semantic model users see, so the IDE may skip it. Everything else goes through RegisterSourceOutput.
Getting Started: Setting Up a Generator Project#
Compiler extensions must target netstandard2.0 (analyzer rule RS1041) and may only reference the compiler packages, not the Workspaces layer (rule RS1038), because they have to load in every host: the command-line compiler, Visual Studio, VS Code and the .NET SDK. Setting LangVersion to latest lets you write modern C# inside that old target:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<PackageId>Contoso.Generators</PackageId>
</PropertyGroup>
<ItemGroup>
<!-- The version you compile against is the minimum compiler your users need. -->
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0"
PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="4.14.0"
PrivateAssets="all" />
</ItemGroup>
<ItemGroup>
<!-- Ship the assembly in the analyzer folder instead of as a library reference. -->
<None Include="$(OutputPath)\$(AssemblyName).dll" Pack="true"
PackagePath="analyzers/dotnet/cs" Visible="false" />
</ItemGroup>
</Project>EnforceExtendedAnalyzerRules turns on checks such as RS1035, which bans APIs like file system access that are unsafe inside the compiler. IsRoslynComponent enables component debugging support in Visual Studio. Roslyn 4.14 corresponds to Visual Studio 2022 17.14 and .NET 9, while Roslyn 5.0 shipped with .NET 10 and C# 14. Choose the oldest version that has the APIs you need.
A project in the same solution consumes the generator through a project reference marked as an analyzer:
<ItemGroup>
<ProjectReference Include="..\Contoso.Generators\Contoso.Generators.csproj"
OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<PropertyGroup>
<!-- Persist generated files under obj/.../generated so you can read and diff them. -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
</PropertyGroup>Writing an Incremental Generator with ForAttributeWithMetadataName#
The following generator gives every enum marked with [EnumExtensions] a fast ToStringFast() extension method that uses a switch instead of reflection-based formatting. It shows the canonical shape: emit the marker attribute, find attributed declarations with ForAttributeWithMetadataName, convert symbols into an equatable model immediately, and render source only from that model.
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Contoso.Generators;
[Generator(LanguageNames.CSharp)]
public sealed class EnumExtensionsGenerator : IIncrementalGenerator
{
private const string AttributeName = "Contoso.Generators.EnumExtensionsAttribute";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// 1. Add the marker attribute to every consuming compilation.
context.RegisterPostInitializationOutput(static output =>
{
output.AddEmbeddedAttributeDefinition(); // Roslyn 4.14 and later
output.AddSource("EnumExtensionsAttribute.g.cs", """
namespace Contoso.Generators
{
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]
[global::System.AttributeUsage(global::System.AttributeTargets.Enum)]
internal sealed class EnumExtensionsAttribute : global::System.Attribute { }
}
""");
});
// 2. Find attributed top-level enums and extract an equatable model right away.
IncrementalValuesProvider<EnumModel> enums = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeName,
predicate: static (node, _) => node is EnumDeclarationSyntax
{
Parent: BaseNamespaceDeclarationSyntax or CompilationUnitSyntax
},
transform: static (ctx, _) => EnumModel.From((INamedTypeSymbol)ctx.TargetSymbol))
.WithTrackingName("EnumModels");
// 3. Render only when a model actually changed.
context.RegisterSourceOutput(enums, static (output, model) =>
output.AddSource($"{model.HintName}.EnumExtensions.g.cs", model.Render()));
}
}ForAttributeWithMetadataName is the most important API for generator performance. Roslyn keeps a cheap index of attribute names, so it can skip almost every syntax node without running your predicate or touching the semantic model. The Roslyn team reports it as at least 99 times more efficient than a hand-written CreateSyntaxProvider pipeline. Design your generator around a marker attribute whenever you can, and pass the metadata name, including a backtick arity suffix for generic attributes.
The EmbeddedAttribute on the generated marker matters in real solutions. Without it, projects that use InternalsVisibleTo would see duplicate internal attribute types from each other and get warnings. With it, the compiler ignores the type when it comes from another assembly.
Caching and Equatable Models#
The transform above returns an EnumModel rather than the INamedTypeSymbol it received. That is deliberate: symbols are never equal across compilations, and holding onto them can keep entire old compilations alive in memory. The same applies to SyntaxNode, Location and SemanticModel. Extract strings, numbers and small records instead:
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
namespace Contoso.Generators;
internal sealed record EnumModel(string? Namespace, string Name, EquatableArray<string> Members)
{
public string HintName => Namespace is null ? Name : $"{Namespace}.{Name}";
public static EnumModel From(INamedTypeSymbol symbol) => new(
symbol.ContainingNamespace.IsGlobalNamespace
? null
: symbol.ContainingNamespace.ToDisplayString(),
symbol.Name,
new EquatableArray<string>(symbol.GetMembers()
.OfType<IFieldSymbol>()
.Where(static f => f.HasConstantValue)
.GroupBy(static f => f.ConstantValue) // skip aliases such as Default = Low
.Select(static g => g.First().Name)
.ToImmutableArray()));
public string Render()
{
var code = new StringBuilder("// <auto-generated/>\n#nullable enable\n");
if (Namespace is not null) code.Append("namespace ").Append(Namespace).Append("\n{\n");
code.Append("internal static class ").Append(Name).Append("Extensions\n{\n")
.Append(" public static string ToStringFast(this ").Append(Name)
.Append(" value) => value switch\n {\n");
foreach (var member in Members)
{
code.Append(" ").Append(Name).Append('.').Append(member)
.Append(" => nameof(").Append(Name).Append('.').Append(member).Append("),\n");
}
code.Append(" _ => value.ToString(),\n };\n}\n");
if (Namespace is not null) code.Append("}\n");
return code.ToString();
}
}
// ImmutableArray<T> compares by reference; this wrapper adds value equality.
internal readonly struct EquatableArray<T>(ImmutableArray<T> items)
: IEquatable<EquatableArray<T>> where T : IEquatable<T>
{
private readonly ImmutableArray<T> _items = items;
public ImmutableArray<T>.Enumerator GetEnumerator() => _items.GetEnumerator();
public bool Equals(EquatableArray<T> other) => _items.SequenceEqual(other._items);
public override bool Equals(object? obj) => obj is EquatableArray<T> other && Equals(other);
public override int GetHashCode()
{
var hash = 17;
foreach (var item in _items)
{
hash = unchecked(hash * 31 + EqualityComparer<T>.Default.GetHashCode(item));
}
return hash;
}
}One netstandard2.0 detail trips up almost everyone: records and init accessors need the System.Runtime.CompilerServices.IsExternalInit type, which that target does not define. Add an internal, empty static class IsExternalInit in that namespace, in its own file, and the compiler will use it.
The rules behind this design come straight from the Roslyn cookbook:
- Use records for models so value equality is generated, and never store symbols, syntax nodes or locations in them.
- Wrap collections. Arrays,
List<T>andImmutableArray<T>compare by reference, so a new-but-identical list would defeat caching. - Extract early and combine late. Combining with
CompilationProviderdirectly reruns your output on every keystroke; select the small piece you need, such as the assembly name, and combine that. - Split work into several steps. Each
Selectis a checkpoint where the driver can stop if nothing changed. - Avoid scanning for indirect markers. Finding every type that implements an interface or derives from a base class cannot be done incrementally and hurts IDE performance badly.
- Build output with a string builder, not syntax trees;
NormalizeWhitespaceis expensive and unnecessary.
Source Generators in the .NET Libraries#
You already depend on source generators. The .NET SDK and ASP.NET Core ship several that replace reflection or runtime code generation, and most matter for trimming and Native AOT, as the Native AOT guide explains:
| Generator | Trigger | Introduced | Replaces |
|---|---|---|---|
| System.Text.Json | [JsonSerializable] on a JsonSerializerContext | .NET 6 | Reflection-based serialization metadata |
| Logging | [LoggerMessage] on partial methods | .NET 6 | Hand-written LoggerMessage.Define code |
| Regular expressions | [GeneratedRegex] on partial methods, and properties since .NET 9 | .NET 7 | Runtime IL emission with RegexOptions.Compiled |
| P/Invoke | [LibraryImport] on partial methods | .NET 7 | Runtime-generated marshalling stubs |
| Configuration binding | EnableConfigurationBindingGenerator property | .NET 8 | Reflection-based ConfigurationBinder |
| Options validation | [OptionsValidator] on a partial class | .NET 8 | Reflection-based data annotation validation |
| Minimal API request delegates | Native AOT, trimming or EnableRequestDelegateGenerator | .NET 8 | Request delegates generated at startup |
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
public static partial class OrderRules
{
// Compiled to C# at build time; partial properties need .NET 9 and C# 13.
[GeneratedRegex(@"^[A-Z]{3}-\d{4}$", RegexOptions.CultureInvariant)]
public static partial Regex OrderNumber { get; }
}
public static partial class OrderLog
{
// No boxing and no template parsing at runtime.
[LoggerMessage(EventId = 1001, Level = LogLevel.Warning,
Message = "Order {OrderId} exceeded the credit limit by {Amount}")]
public static partial void CreditLimitExceeded(ILogger logger, string orderId, decimal amount);
}
// Serialization metadata generated at compile time: trimming- and AOT-friendly.
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(OrderDto))]
public partial class AppJsonContext : JsonSerializerContext { }
public sealed record OrderDto(string Number, decimal Total);Reading the generated code for these generators, under Dependencies, Analyzers in Visual Studio's Solution Explorer or through EmitCompilerGeneratedFiles, is one of the best ways to learn idiomatic generator output. The System.Text.Json guide covers the serializer's source generation modes in depth.
Writing an Analyzer and a Code Fix#
Generators pair naturally with analyzers: the marker attribute signals intent, so an analyzer can guide users toward the generated API. This analyzer flags ToString() calls on enums marked with [EnumExtensions]:
using System.Collections.Immutable;
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Operations;
namespace Contoso.Generators;
[DiagnosticAnalyzer(LanguageNames.CSharp)]
public sealed class UseToStringFastAnalyzer : DiagnosticAnalyzer
{
public const string DiagnosticId = "CONTOSO001";
private static readonly DiagnosticDescriptor Rule = new(
id: DiagnosticId,
title: "Use the generated ToStringFast method",
messageFormat: "Use '{0}.ToStringFast()' instead of 'ToString()'",
category: "Performance",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics =>
ImmutableArray.Create(Rule);
public override void Initialize(AnalysisContext context)
{
context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
context.EnableConcurrentExecution();
context.RegisterCompilationStartAction(static start =>
{
// Resolve the marker once per compilation instead of once per invocation.
var marker = start.Compilation.GetTypeByMetadataName(
"Contoso.Generators.EnumExtensionsAttribute");
if (marker is not null)
{
start.RegisterOperationAction(
ctx => AnalyzeInvocation(ctx, marker), OperationKind.Invocation);
}
});
}
private static void AnalyzeInvocation(OperationAnalysisContext context, INamedTypeSymbol marker)
{
var invocation = (IInvocationOperation)context.Operation;
if (invocation.TargetMethod.Name != "ToString" || invocation.Arguments.Length != 0)
{
return;
}
var receiver = invocation.Instance is IConversionOperation conversion
? conversion.Operand
: invocation.Instance;
if (receiver?.Type is { TypeKind: TypeKind.Enum } enumType &&
enumType.GetAttributes().Any(a =>
SymbolEqualityComparer.Default.Equals(a.AttributeClass, marker)))
{
context.ReportDiagnostic(
Diagnostic.Create(Rule, invocation.Syntax.GetLocation(), enumType.Name));
}
}
}Several conventions are visible here. EnableConcurrentExecution and ConfigureGeneratedCodeAnalysis are expected on every analyzer (rules RS1026 and RS1025). Symbols are compared with SymbolEqualityComparer (RS1024). Analyzing IOperation rather than syntax makes the rule language-agnostic and resilient to syntactic variations such as this qualifiers or conditional access.
The code fix must live in a separate assembly that references Microsoft.CodeAnalysis.CSharp.Workspaces, because the compiler itself cannot load Workspaces:
using System.Collections.Immutable;
using System.Composition;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
namespace Contoso.Generators.CodeFixes;
[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(UseToStringFastCodeFix)), Shared]
public sealed class UseToStringFastCodeFix : CodeFixProvider
{
public override ImmutableArray<string> FixableDiagnosticIds =>
ImmutableArray.Create(UseToStringFastAnalyzer.DiagnosticId);
public override FixAllProvider GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer;
public override async Task RegisterCodeFixesAsync(CodeFixContext context)
{
var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken)
.ConfigureAwait(false);
if (root is null ||
root.FindNode(context.Span, getInnermostNodeForTie: true) is not
InvocationExpressionSyntax { Expression: MemberAccessExpressionSyntax access })
{
return;
}
context.RegisterCodeFix(
CodeAction.Create(
title: "Use ToStringFast()",
createChangedDocument: _ => Task.FromResult(context.Document.WithSyntaxRoot(
root.ReplaceNode(access.Name, SyntaxFactory.IdentifierName("ToStringFast")))),
equivalenceKey: nameof(UseToStringFastCodeFix)),
context.Diagnostics);
}
}Supplying a stable equivalenceKey and a FixAllProvider lets users fix every occurrence in a document, project or solution at once. The code quality guide shows how to configure analyzer severities with .editorconfig once your rules ship.
Testing Source Generators and Analyzers#
Unit tests are the fastest way to develop compiler extensions, and they are far easier to debug than a build. For generators, drive the compiler directly with CSharpGeneratorDriver and turn on step tracking to assert that your pipeline really caches:
using System.Linq;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Xunit;
public class EnumExtensionsGeneratorTests
{
private const string Source = """
namespace Demo;
[Contoso.Generators.EnumExtensions]
public enum Color { Red, Green, Blue }
""";
[Fact]
public void Generates_code_and_caches_across_unrelated_edits()
{
var compilation = CSharpCompilation.Create("Tests",
[CSharpSyntaxTree.ParseText(Source)],
[MetadataReference.CreateFromFile(typeof(object).Assembly.Location)],
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
GeneratorDriver driver = CSharpGeneratorDriver.Create(
[new EnumExtensionsGenerator().AsSourceGenerator()],
driverOptions: new GeneratorDriverOptions(
IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true));
driver = driver.RunGeneratorsAndUpdateCompilation(
compilation, out var output, out var diagnostics);
Assert.Empty(diagnostics);
Assert.DoesNotContain(output.GetDiagnostics(), d => d.Severity == DiagnosticSeverity.Error);
// An unrelated edit must not invalidate the cached model.
driver = driver.RunGenerators(
compilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText("class Unrelated { }")));
var outputs = driver.GetRunResult().Results[0].TrackedSteps["EnumModels"]
.SelectMany(step => step.Outputs);
Assert.All(outputs, o => Assert.True(
o.Reason is IncrementalStepRunReason.Cached or IncrementalStepRunReason.Unchanged));
}
}Many teams add snapshot testing of the generated source on top of this, so that any change in output shows up as a reviewable diff. For analyzers and code fixes, the Microsoft.CodeAnalysis.CSharp.Analyzer.Testing, CodeFix.Testing and SourceGenerators.Testing packages provide verifiers that compile test code, compare diagnostics marked with [|...|] and check the fixed output, including Fix All. The older test-framework-specific packages with .XUnit, .NUnit or .MSTest suffixes are obsolete; use the generic packages with DefaultVerifier:
using System.Threading.Tasks;
using Xunit;
using Verify = Microsoft.CodeAnalysis.CSharp.Testing.CSharpCodeFixVerifier<
Contoso.Generators.UseToStringFastAnalyzer,
Contoso.Generators.CodeFixes.UseToStringFastCodeFix,
Microsoft.CodeAnalysis.Testing.DefaultVerifier>;
public class UseToStringFastTests
{
private const string Prelude = """
namespace Contoso.Generators
{
internal sealed class EnumExtensionsAttribute : System.Attribute { }
}
[Contoso.Generators.EnumExtensions]
public enum Color { Red, Green }
public static class ColorExtensions
{
public static string ToStringFast(this Color value) =>
value == Color.Red ? "Red" : "Green";
}
""";
[Fact]
public Task Replaces_ToString_with_ToStringFast() =>
Verify.VerifyCodeFixAsync(
Prelude + "class C { string M(Color c) => [|c.ToString()|]; }",
Prelude + "class C { string M(Color c) => c.ToStringFast(); }");
}Packaging Generators and Analyzers in NuGet#
A NuGet package delivers compiler extensions through its analyzers/dotnet/cs folder; the project file shown earlier puts the generator there and sets IncludeBuildOutput to false so consumers do not reference the DLL at runtime. A few details separate a robust package from a fragile one:
- Dependencies must travel with the generator. A generator cannot rely on NuGet to restore its own dependencies. Reference them with
PrivateAssets="all"and pack their DLLs into the same analyzer folder. - Pick the Roslyn version carefully. Users on older SDKs or IDEs cannot load a generator built against a newer compiler. Packages can include several builds in folders such as
analyzers/dotnet/roslyn4.0/csandanalyzers/dotnet/roslyn4.4/cs, which is exactly how the System.Text.Json package ships its generator. - Expose configuration through MSBuild. Add
CompilerVisiblePropertyitems in a.propsfile shipped in the package'sbuildfolder; the generator then reads values such asbuild_property.MyGenerator_EnableLoggingfromAnalyzerConfigOptionsProvider. - Ship analyzers alongside libraries. A library package can include analyzers that enforce its own usage rules, so every consumer gets guidance automatically.
Debugging Source Generators#
Generators run inside the compiler, which makes the usual F5 experience less obvious. These techniques cover most situations:
- Debug through unit tests. The
CSharpGeneratorDrivertest above runs the generator in-process, so breakpoints just work. This is the most productive loop. - Inspect the output.
EmitCompilerGeneratedFileswrites generated files underobj/<configuration>/<tfm>/generatedby default; you can also browse them in the IDE's Solution Explorer. - Use component debugging. With
IsRoslynComponentset, Visual Studio can launch the compiler against a target project and hit breakpoints in your generator. - Watch for stale assemblies. The IDE and the compiler server can keep an old generator build loaded. If changes seem to be ignored, restart the IDE or run
dotnet build-server shutdown. - Report problems as diagnostics. Instead of throwing, report a descriptive diagnostic through
SourceProductionContext.ReportDiagnostic; an unhandled exception disables the generator with only a generic warning.
Interceptors: Status and Usage#
Interceptors let a generator replace a specific method call in user code with a call to a generated method. They first shipped as an experimental feature in .NET 8 and have been stable since the .NET 9.0.2xx SDK. They are how the ASP.NET Core Request Delegate Generator and the configuration binding generator substitute reflection-based calls such as MapGet and Bind with generated, AOT-friendly code, without changing the code you wrote.
The mechanics are deliberately constrained:
- A generator obtains an
InterceptableLocationfromSemanticModel.GetInterceptableLocation(invocation). The location is equatable, so it is safe to put in a pipeline model, andGetInterceptsLocationAttributeSyntax()renders the attribute for it. - The interceptor method carries
[InterceptsLocation(version, data)], wheredatais an opaque encoding of a checksum and position of the call. Hand-written locations are not meant to be stable. - Only calls to ordinary methods can be intercepted; constructors, properties, operators and delegate invocations cannot.
- Consuming projects must opt in with the
InterceptorsNamespacesMSBuild property, listing the namespaces allowed to contain interceptors. The olderInterceptorsPreviewNamespacesname still works as an alias.
// <auto-generated/> Shape of a generator's output that intercepts one call site.
namespace Contoso.Generated
{
file static class MessageBusInterceptors
{
// Rendered with InterceptableLocation.GetInterceptsLocationAttributeSyntax().
[global::System.Runtime.CompilerServices.InterceptsLocation(1, "opaque-location-data")]
public static void Publish(
this global::Contoso.MessageBus bus, global::Contoso.Message message)
{
global::System.Console.WriteLine($"Publishing {message.Id}");
bus.Publish(message); // a different call site, so it is not intercepted again
}
}
}
namespace System.Runtime.CompilerServices
{
// File-local, so several generators can each declare it without conflicts.
[global::System.AttributeUsage(global::System.AttributeTargets.Method, AllowMultiple = true)]
file sealed class InterceptsLocationAttribute : global::System.Attribute
{
public InterceptsLocationAttribute(int version, string data) { }
}
}Treat interceptors as a tool for framework authors. They make code harder to reason about, because what you read at a call site is not what runs, and IDE traceability is limited. Prefer ordinary partial members whenever the API can be designed around them.
Best Practices#
- Drive generators with marker attributes and
ForAttributeWithMetadataName; avoid scanning for base types or interfaces. - Keep models equatable and small. Records, strings and wrapped arrays only; no symbols, syntax nodes or locations.
- Emit
// <auto-generated/>and#nullable enable. Generated files are nullable-disabled by default, so opt back in when your output is annotated. - Use fully qualified
global::names in generated code to avoid clashes with user types. - Respect cancellation tokens in transforms and outputs, because the IDE cancels runs constantly.
- Pair generators with analyzers that explain misuse, such as a missing
partialmodifier, with actionable diagnostics. - Test caching explicitly with tracked steps, not just output correctness.
Common Pitfalls#
- Passing
Compilationor symbols through the pipeline. Every keystroke then reruns everything, and memory usage grows. - Using
ImmutableArray<T>in models without a wrapper. Reference equality silently disables caching. - Targeting a framework other than
netstandard2.0. The generator may fail to load in some hosts. - Referencing Workspaces from an analyzer or generator. Keep code fixes in a separate assembly.
- Forgetting dependency packaging. A generator that works locally fails for consumers when its dependencies are missing from the package.
- Duplicate hint names.
AddSourcerequires unique hint names per generator, so include the namespace and type name.
When to Use Source Generators#
| Approach | Runs at | Native AOT and trimming | Best for |
|---|---|---|---|
| Source generators | Build time, inside the compiler | Fully compatible | Serializers, mappers, logging, boilerplate from attributes |
| Analyzers and code fixes | Build time and in the IDE | Not applicable | Enforcing rules, guiding API usage, automated refactoring |
| Reflection | Runtime | Needs care or annotations | Truly dynamic scenarios such as plug-ins |
| Reflection.Emit and compiled expressions | Runtime | Not available under Native AOT | Hot paths in JIT-only apps |
| IL weaving after build | Build time, post-compilation | Depends on the tool | Cross-cutting changes to existing methods |
Frequently Asked Questions#
What is the difference between ISourceGenerator and IIncrementalGenerator?#
ISourceGenerator is the original, now deprecated API that reruns the entire generator whenever anything changes. IIncrementalGenerator defines a cached pipeline of steps, so unchanged inputs and equal intermediate results skip work. All new generators should implement IIncrementalGenerator.
Why must a source generator target netstandard2.0?#
Generators and analyzers load into every compiler host, including Visual Studio on .NET Framework and the .NET SDK on modern .NET. Targeting netstandard2.0 is the only way to be loadable everywhere, which is why analyzer rule RS1041 enforces it. Set LangVersion to latest to keep using modern C# syntax.
Can a source generator modify existing code?#
No. Generators can only add new source files. APIs that appear to change behavior rely on partial declarations that the generator completes, or on interceptors, which redirect specific calls to generated methods.
Are C# interceptors still experimental?#
No. Interceptors shipped experimentally in .NET 8 and have been stable since the .NET 9.0.2xx SDK. Consumers opt in with the InterceptorsNamespaces MSBuild property, and generators should obtain locations through GetInterceptableLocation rather than hand-written file paths.
How do I see the code a source generator produced?#
Set EmitCompilerGeneratedFiles to true to write generated files under the obj folder, or expand the generator under Dependencies, Analyzers in Visual Studio's Solution Explorer. Unit tests can also read the generated trees from GeneratorDriverRunResult.
Summary#
- Source generators add code at compile time; analyzers and code fixes report and correct problems. All are loaded from analyzer assemblies targeting
netstandard2.0. - Implement
IIncrementalGenerator, drive it withForAttributeWithMetadataName, and keep pipeline models small and value-equatable. - The .NET libraries already rely on generators for JSON, logging, regex, interop, configuration and Minimal APIs, largely for Native AOT.
- Test with
CSharpGeneratorDriverand theMicrosoft.CodeAnalysis.Testingpackages, including step tracking for caching. - Package into
analyzers/dotnet/cs, choose the Roslyn version deliberately, and reserve interceptors for framework-level scenarios.