As .NET developers, we’re obsessed with performance and productivity. We build robust systems, but we often hit a wall with repetitive, boilerplate code. Whether it’s mapping DTOs, implementing INotifyPropertyChanged, or wiring up API clients, we find ourselves writing the same tedious patterns over and over.
For years, our primary tools for this “metaprogramming” were Reflection and T4 templates. Reflection is powerful, but it’s slow. It runs at runtime, it’s string-based, and it’s notoriously unfriendly to AOT (Ahead-of-Time) compilation and linkers. T4 templates are better, as they run before compilation, but they are “dumb.” They are just text files that don’t understand your code’s semantics.
What if we could have the best of both worlds? What if we could write code that runs during compilation, understands our code’s syntax and semantics, and generates new, high-performance C# files on the fly?
That’s exactly what .NET Source Generators do. They are a Roslyn compiler feature that lets you inspect a user’s code and add new source files to the compilation. In this post, I’ll show you what they are, why they’re a massive improvement over older techniques, and how to build a simple, high-performance generator from scratch.
Why Source Generators Matter
A Source Generator is a piece of code, packaged as a .NET library, that the C# compiler executes during compilation. It gets access to the full semantic and syntactic model of your code (just like a Roslyn Analyzer) but with one key difference: it can add new C# source files to the compilation as if you had written them yourself.
This is a game-changer for several reasons:
- Performance: Code generation occurs at compile time, not at runtime. This means your application’s startup is blazing fast because it doesn’t need to perform expensive reflection to discover types or wire up dependencies.
- Type Safety: Because the generated code is compiled with your code, you get full IntelliSense, compile-time error checking, and strong typing. You’re no longer relying on “magic strings” that break at runtime.
- AOT & Linker Friendly: The generated code is just standard C#. It’s fully visible to AOT compilers and linkers, allowing them to trim and optimize your application effectively. This is something runtime reflection actively prevents.
- Productivity: You can automate vast amounts of boilerplate. Think of the
System.Text.Jsonsource generator, which creates optimized serialization logic at compile-time, or the high-performance logging generators inMicrosoft.Extensions.Logging.
It’s important to understand where Source Generators fit.
- Reflection: Reflection runs at runtime. It inspects compiled assemblies. Source Generators run at compile-time. They inspect source code (syntax trees and semantic models).
- T4 Templates: T4 is a pre-build step. It’s a text templating engine that knows nothing about your C# code’s structure. Source Generators are an in-compiler step. They have full access to the Roslyn semantic model, so they understand your code.
- Roslyn Analyzers and Code Fixers: This is the closest comparison. Analyzers inspect code and produce diagnostics (errors, warnings, suggestions). The code fixers, on the other hand, modify existing code to fix those diagnostics. However, Source Generators inspect code and produce new code. In fact, they are both “Roslyn components” and are even packaged and deployed in the same way (as “Analyzers” in a NuGet package).
Incremental Generators
When Source Generators first launched, they were based on the ISourceGenerator interface. This was great, but it had a performance problem. It ran every single time a compilation changed, often regenerating code unnecessarily.
The .NET team quickly solved this with Incremental Generators (using the IIncrementalGenerator interface), which were introduced in .NET 6. You should always use this new version.
Incremental Generators are incredibly efficient. They work by building a “pipeline” of data transformations. The compiler caches the output of each step, and the generator reruns only the specific parts of the pipeline whose inputs have changed. This results in a near-instantaneous developer experience in your IDE, even in large solutions.
Setting Up a Minimal Source Generator Project
Let’s build one. A Source Generator is just a .NET class library, but it needs a specific project configuration to tell the compiler to treat it as a generator.
You will need two projects:
MyApiGenerator: The generator project itself.SampleApp: A console application that will consume our generator.
1. The Generator Project
Create a new .NET Standard 2.0 class library. Why netstandard2.0? This ensures your generator can run in the widest possible range of .NET environments (including Visual Studio, dotnet build, .NET Framework, and .NET 6+).
Here is the .csproj file. This configuration is critical:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<IsRoslynComponent>true</IsRoslynComponent>
<IncludeBuildOutput>false</IncludeBuildOutput>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp"
Version="5.0.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers"
Version="3.11.0" PrivateAssets="all" />
</ItemGroup>
</Project>
The key parts are:
IsRoslynComponent: Setting this property totrueflags your project as a Roslyn Component. This means it’s not a standard class library intended for runtime use. Instead, it’s a compiler plugin, like a Source Generator or an Analyzer. This setting also enables all the special build logic needed to package your generator. When you build a NuGet package, for example, this flag ensures your DLL is placed in the correctanalyzers/dotnet/csfolder. Without this, the compiler would never find or load your generator.IncludeBuildOutput=false: By default, when you build a Roslyn Component project, the build output (the compiled DLL) is included in the NuGet package. However, for Source Generators, you typically don’t want to include the build output directly because the generator is meant to be consumed by other projects at compile-time, not runtime. Setting this property tofalseprevents the DLL from being included in the package’s main lib folder, ensuring it’s only available as an analyzer.
ℹ️ The
IsRoslynComponentproperty is also used to enable source generator debugging in Visual Studio. When you set this property totrue, Visual Studio knows to load your generator into the IDE’s compilation process, allowing you to test and debug it directly as you develop.
2. The Consumer Project
Create a new Console App. To reference our local generator project, we add a ProjectReference but with two special properties:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\MyApiGenerator\MyApiGenerator.csproj"
OutputItemType="Analyzer"
ReferenceOutputAssembly="false" />
</ItemGroup>
</Project>
When you package your generator as a NuGet package, the consumer adds a PackageReference, and the package is installed automatically.
When referencing a local project, the OutputItemType="Analyzer" and ReferenceOutputAssembly="false" properties ensure that the generator is treated as a compile-time analyzer rather than a runtime assembly.
Building a Simple Source Generator
Let’s build a generator that finds an interface annotated with a special attribute and automatically generates a strongly-typed HttpClient client for it.
Step 1: The Marker Attribute
Our generator needs to know which interfaces to scan. We’ll use a “marker attribute.” A common pattern is to have the generator emit its own attribute, so the consumer doesn’t need to define it.
In the MyApiGenerator project, create the generator class:
using Microsoft.CodeAnalysis;
namespace MyApiGenerator;
[Generator]
public class ApiClientGenerator : IIncrementalGenerator
{
[StringSyntax("csharp")]
private const string AttributeSourceCode =
"""
using System;
namespace MyApiGenerator
{
[AttributeUsage(AttributeTargets.Interface)]
[global::Microsoft.CodeAnalysis.EmbeddedAttribute]
internal class GenerateApiClientAttribute : Attribute
{
public GenerateApiClientAttribute(string baseUrl)
{
BaseUrl = baseUrl;
}
public string BaseUrl { get; }
}
}
""";
public void Initialize(IncrementalGeneratorInitializationContext context)
{
// Step 1: Emit the marker attribute
context.RegisterPostInitializationOutput(ctx =>
{
ctx.AddEmbeddedAttributeDefinition();
ctx.AddSource("GenerateApiClientAttribute.g.cs", AttributeSourceCode);
});
}
}The [Generator] attribute marks this class as a generator. We implement IIncrementalGenerator and its single Initialize method.
Inside the Initialize method, we use RegisterPostInitializationOutput to:
- Add the
EmbeddedAttributedefinition. This is required for any attribute defined in a source generator to avoidCSO436error. For more details, see Andrew Lock’s post Solving the source generator ‘marker attribute’ problem in .NET 10. - Add our attribute’s source code to the compilation.
The RegisterPostInitializationOutput method runs once at the beginning and allows our SampleApp to use the GenerateApiClient even though it hasn’t defined it.
Step 2: Finding Annotated Interfaces (Target)
Next, we need to build the pipeline. We’ll tell the generator to find all interfaces that are decorated with our new attribute.
We add this code inside the Initialize method, after the post-init step:
// ... inside Initialize(IncrementalGeneratorInitializationContext context)
// Step 2: Create the pipeline
IncrementalValueProvider<ImmutableArray<InterfaceDeclarationSyntax>> interfaceProvider = context.SyntaxProvider.ForAttributeWithMetadataName(
"ApiClientGenerator.GenerateApiClientAttribute",
// A. Predicate: Filter syntax nodes quickly
predicate: static (node, _) => node is InterfaceDeclarationSyntax,
// B. Transform: Get the ones we care about
transform: (ctx, _) =>
{
var ifaceSyntax = (InterfaceDeclarationSyntax)ctx.Node;
// Check if it has our attribute
foreach (var attrList in ifaceSyntax.AttributeLists)
{
foreach (var attr in attrList.Attributes)
{
var attrType = ctx.SemanticModel.GetTypeInfo(attr).Type as INamedTypeSymbol;
var targetAttr = ctx.SemanticModel.Compilation
.GetTypeByMetadataName("MyApiGenerator.GenerateApiClientAttribute");
if (attrType is not null && SymbolEqualityComparer.Default.Equals(attrType, targetAttr))
{
return ifaceSyntax;
}
}
}
return null;
})
.Where(x => x is not null)
.Collect();This is the heart of an incremental generator.
ForAttributeWithMetadataNameis the efficient entry point.- The
predicateis a fast, purely syntactical filter. It runs on every keystroke. We’re telling it to only look at interfaces that haveGenerateApiClientAttributeattribute. - The
transformis the more expensive semantic check. It runs only on nodes that passed the predicate. Here, we check if the attribute is our attribute. - We filter out
nullsandCollectthe results into an array.
Step 3: Generating the Code
Finally, we register the “source output” step. This is the code that will actually generate our new file. It will only run if the interfaceProvider pipeline produces a new value.
Add this code at the end of the Initialize method:
// ... inside Initialize(IncrementalGeneratorInitializationContext context)
// Step 3: Register the source output
context.RegisterSourceOutput(interfaceProvider, (spc, interfaces) =>
{
if (interfaces.IsDefaultOrEmpty)
{
return;
}
foreach (var iface in interfaces)
{
// We need the semantic model to get full type info
var semanticModel = spc.Compilation.GetSemanticModel(iface.SyntaxTree);
var interfaceSymbol = semanticModel.GetDeclaredSymbol(iface);
if (interfaceSymbol is null) continue;
// "IUserApi" -> "UserApiClient"
var className = $"{interfaceSymbol.Name.Substring(1)}Client";
var namespaceName = interfaceSymbol.ContainingNamespace.ToDisplayString();
// This is a simple (and naive) way to get the attribute value.
// A real generator would be more robust.
var attribute = interfaceSymbol.GetAttributes().FirstOrDefault(
a => a.AttributeClass?.Name == "GenerateApiClientAttribute");
var baseUrl = attribute?.ConstructorArguments[0].Value?.ToString()
?? "https://api.example.com";
// For simplicity, we'll only generate GET methods
var getMethods = interfaceSymbol.GetMembers()
.OfType<IMethodSymbol>()
.Where(m => m.Name.StartsWith("Get"));
var methodsBuilder = new StringBuilder();
foreach (var method in getMethods)
{
// e.g., Task<User> GetUserByIdAsync(int id)
var returnType = (method.ReturnType as INamedTypeSymbol)
?.TypeArguments[0] ?? "object";
var parameter = method.Parameters.FirstOrDefault();
methodsBuilder.Append(
$$"""
public async Task<{{returnType}}> {{method.Name}}Async({{parameter?.Type}} {{parameter?.Name}})
{
// Naive implementation: assumes one parameter for the URL
return await _httpClient.GetFromJsonAsync<{{returnType}}>($"{{method.Name.Replace("Async", string.Empty)}}/{{parameter?.Name}}");
}
""");
}
// Generate the final client class
var source =
$$"""
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
namespace {{namespaceName}}
{
public partial class {{className}} : {{interfaceSymbol.Name}}
{
private readonly HttpClient _httpClient;
public {{className}}(HttpClient httpClient)
{
_httpClient = httpClient;
_httpClient.BaseAddress = new System.Uri("{{baseUrl}}");
}
{{methodsBuilder}}
}
}
""";
spc.AddSource($"{className}.g.cs", source);
}
});This code gets the list of interfaces, inspects their methods (using the powerful IMethodSymbol from the semantic model), and generates a new class file using a C# string. It uses spc.AddSource to add the new file to the compilation.
Step 4: Using the Generator
Now, in our SampleApp, we can just write the interface and use the generated class.
First, define the interface in Program.cs (or any file):
using MyApiGenerator; // This namespace now exists!
namespace SampleApp;
[GenerateApiClient("https://jsonplaceholder.typicode.com")]
public interface IUserApi
{
Task<User> GetUserByIdAsync(int id);
}
public class User
{
public int Id { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}Now, in Program.cs, we can just use the class UserApiClient… even though we never wrote it.
// In Program.cs
using SampleApp;
using System.Net.Http;
var httpClient = new HttpClient();
var client = new UserApiClient(httpClient);
Console.WriteLine("Fetching user 1...");
var user = await client.GetUserByIdAsync(1);
Console.WriteLine($"Got user: {user.Name} ({user.Email})");
// The generated file "UserApiClient.g.cs" will be visible
// in your IDE under Dependencies > AnalyzersWhen you run this, it just works. The compiler found your interface, ran your generator, compiled the new UserApiClient.g.cs file, and then compiled your Program.cs against it. All with zero runtime reflection.
Conclusion
Source Generators are one of the most powerful features to enter the .NET ecosystem in years. By shifting code generation from a slow, brittle runtime process to a robust, type-safe, compile-time step, they unlock new levels of performance and productivity.
My advice? The next time you find yourself writing repetitive boilerplate or reaching for System.Reflection to wire something up, stop and ask: “Can I do this with a Source Generator?” The answer is increasingly, and resoundingly, “yes.”

