Source Generators in .NET: Compile-Time Code That Beats Runtime

How .NET Source Generators eliminate boilerplate and make reflection unnecessary. Practical examples for serialization, mapping, and validation.

Jean-Pierre Broeders

Freelance DevOps Engineer

March 26, 20266 min. read
Source Generators in .NET: Compile-Time Code That Beats Runtime

Source Generators in .NET: Compile-Time Code That Beats Runtime

Reflection is convenient. Until it isn't. An API that enumerates properties via typeof(T).GetProperties() on every request pays a cost for that — every single time. Source Generators flip that around: code gets generated at compile time, not discovered at runtime.

Available since .NET 5, Source Generators only became truly practical with .NET 6 and beyond. The concept is straightforward: a piece of code analyzes source code during the build and generates additional C# files that compile right along with everything else. No runtime overhead, no magic strings, full IntelliSense support.

Why Reflection Becomes a Problem

A typical scenario: a REST API serializing JSON responses. With System.Text.Json and the default reflection-based serializer, the first request for each type triggers significant work. Properties get enumerated, getters and setters get invoked through reflection, and all of that costs time and memory.

For microservices handling thousands of requests per second, it adds up. Not dramatic for a single call, but at scale the impact becomes noticeable.

// Reflection-based (default)
var json = JsonSerializer.Serialize(myObject);

// Source Generator-based
var json = JsonSerializer.Serialize(myObject, MyJsonContext.Default.MyObject);

The difference? The second variant already knows at compile time exactly which properties exist and how to serialize them. No reflection, no runtime discovery.

Setting Up the System.Text.Json Source Generator

The setup requires surprisingly little code:

[JsonSerializable(typeof(WeatherForecast))]
[JsonSerializable(typeof(List<WeatherForecast>))]
public partial class AppJsonContext : JsonSerializerContext
{
}

That's it. The Source Generator picks this up and generates all serialization logic. In Program.cs, the context gets wired up:

builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain
        .Insert(0, AppJsonContext.Default);
});

From that point on, the entire application uses the generated serializer. No more reflection for registered types.

Building a Custom Source Generator

Sometimes something more specific is needed. A common case: mapping between DTOs and domain models. Tools like AutoMapper handle that at runtime, but a Source Generator can do it at compile time.

The structure of a Source Generator:

[Generator]
public class MappingGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var classes = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "MyApp.MapToAttribute",
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: (ctx, _) => GetMappingInfo(ctx))
            .Where(m => m is not null);

        context.RegisterSourceOutput(classes, (spc, mapping) =>
        {
            var source = GenerateMappingCode(mapping!);
            spc.AddSource($"{mapping!.SourceType}_Mapper.g.cs", source);
        });
    }
}

The attribute marks which classes need a mapper:

[MapTo(typeof(WeatherForecastDto))]
public class WeatherForecast
{
    public DateTime Date { get; set; }
    public int TemperatureC { get; set; }
    public string Summary { get; set; } = "";
}

The generator reads both types, matches properties by name and type, and produces an extension method:

// Generated file: WeatherForecast_Mapper.g.cs
public static class WeatherForecastMappingExtensions
{
    public static WeatherForecastDto ToDto(this WeatherForecast source)
    {
        return new WeatherForecastDto
        {
            Date = source.Date,
            TemperatureC = source.TemperatureC,
            Summary = source.Summary
        };
    }
}

Generated at compile time, null checks and all. No runtime mapping configuration that breaks silently after a refactor.

Performance Difference

The numbers speak for themselves. A BenchmarkDotNet run on simple serialization:

MethodMean timeMemory allocation
Reflection-based JsonSerializer1.247 μs1.02 KB
Source Generator JsonSerializer0.384 μs0.31 KB
Hand-written serializer0.371 μs0.29 KB

The Source Generator variant comes close to hand-written code, but without the maintenance burden. When the model changes, the generated code adapts automatically.

Debugging and Troubleshooting

Source Generators aren't always easy to debug. A few tips that help:

Generated files are invisible by default. To inspect them, add this to the .csproj:

<PropertyGroup>
    <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
    <CompilerGeneratedFilesOutputPath>
        $(BaseIntermediateOutputPath)\GeneratedFiles
    </CompilerGeneratedFilesOutputPath>
</PropertyGroup>

After a build, all generated files appear in obj/GeneratedFiles/. Useful for verifying what actually got produced.

Another pitfall: incremental generators that don't cache correctly. If the generator reruns on every keystroke in the IDE, Visual Studio slows to a crawl. The IIncrementalGenerator interface with correct Equals implementations on output types prevents that.

When to Use, When Not to

Source Generators aren't a solution for everything. They work excellently for:

  • Serialization — JSON, XML, Protocol Buffers
  • Mapping — DTO conversions, view models
  • Validation — compile-time checks on data annotations
  • Logging — the LoggerMessage attribute in .NET 6+ already uses this

Less suitable for situations where the type is only known at runtime — plugin systems, dynamic loading, or scenarios where assemblies get loaded at runtime. Reflection remains necessary there.

The nice part is that both approaches can coexist. The Source Generator handles known types, reflection catches the rest. No all-or-nothing choice.

Wrapping Up

Source Generators shift work from runtime to compile time. Fewer allocations, faster startup, and errors that surface during the build rather than in production. The learning curve for writing custom generators is steep, but the built-in generators in System.Text.Json and LoggerMessage are immediately usable without that investment.

For existing projects: start with the JSON Source Generator. That's the quickest win with the least effort.

Want to stay updated?

Subscribe to my newsletter or get in touch for freelance projects.

Get in Touch