Skip to content
ExponaBLOG
.NET & ASP.NET CORE · DAY 084 / 100

Source generators in C#: real-world lessons and examples

Compile-time code generation that replaces reflection.

Expona Admin7 min read

Getting started with source generators

Source generators in C#: real-world lessons and examples — that's what we're covering today because reflection-based code generation has dominated .NET for decades, but compile-time alternatives are changing how we write performant, maintainable applications. If you've shipped production code that relies on reflection for mapping, validation, or logging, you've paid a runtime cost. Source generators eliminate that cost by shifting work to your build pipeline.

Source generators are programs that run during compilation and emit new C# source files into your project. The compiler then compiles those generated files alongside your hand-written code. No runtime magic, no reflection penalties, no startup-time surprises. The generated code is real, debuggable, and sits right there in your obj folder.

What are source generators — and why they matter

At their core, source generators are .NET tools that implement the ISourceGenerator or IIncrementalGenerator interface. They inspect your project's syntax tree and symbol information, then decide what code to generate based on attributes, patterns, or heuristics you define.

The flow is straightforward: you build your project, the generator runs, new .g.cs files appear in obj/Debug/net8.0/generated, and the compiler treats them as if you'd written them by hand. Your IDE sees them, IntelliSense works, and you can set breakpoints in generated methods.

Why does this matter? Reflection is powerful but expensive. Every typeof() call, every property lookup, every method invocation through MethodInfo carries overhead. In hot paths — logging, serialization, dependency injection — that overhead compounds. Source generators move these decisions from runtime to build time, so your application runs as if you'd written boilerplate by hand.

Source generators you already use

You're likely benefiting from source generators without realizing it. Several major .NET libraries ship their own generators, and they've become the standard approach for performance-critical scenarios.

System.Text.Json and serialization

System.Text.Json uses a source generator to create blazing-fast serialization code. When you decorate a class with [JsonSourceGenerationOptions] and define a context, the generator produces type-specific serializers at compile time. No reflection, no intermediate objects, no per-call overhead.

csharp
public partial class OrderSerializationContext : JsonSerializerContext
{
    public OrderSerializationContext(JsonSerializerOptions options) : base(options)
    {
    }

    public override JsonTypeInfo GetTypeInfo(Type type) => throw new NotImplementedException();
}

// Usage in your code
var context = new OrderSerializationContext(new JsonSerializerOptions());
var json = JsonSerializer.Serialize(order, typeof(Order), context);

The generated code is explicit and predictable. You can inspect it, understand exactly what happens, and tune serialization behavior without magic.

Regex source generation

.NET 7+ lets you generate regex matchers at compile time. This is particularly useful for validation patterns that run frequently. The generator creates an optimized finite state machine instead of interpreting a pattern at runtime.

csharp
public partial class EmailValidator
{
    [GeneratedRegex(@"^[^\s@]+@[^\s@]+\.[^\s@]+$", RegexOptions.IgnoreCase)]
    private static partial Regex EmailPattern();

    public static bool IsValid(string email) => EmailPattern().IsMatch(email);
}

First call is instant because the regex is already compiled. No warmup phase, no JIT overhead for the regex engine.

Minimal API request delegates

ASP.NET Core's minimal APIs use source generators to bind route parameters, query strings, and request bodies. The framework generates type-safe handler delegates at compile time instead of using reflection to invoke your endpoint methods.

csharp
app.MapGet("/orders/{id}", async (int id, OrderService svc) => 
{
    var order = await svc.GetAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
});

Behind the scenes, a generator creates a delegate that extracts id from the route and resolves OrderService from dependency injection. No reflection at request time.

LoggerMessage and structured logging

The logging framework's [LoggerMessage] attribute drives a generator that creates allocation-free logging methods. This is production gold because it eliminates boxing, string allocations, and reflection per log call.

High-performance logging with LoggerMessage

Let's dive deeper into logging because it's where generators provide the most immediate, measurable benefit in production systems.

Without source generators, you write:

csharp
logger.LogInformation("Order {OrderId} shipped to {Address}", orderId, address);

This works, but under the hood the logging framework uses reflection to extract parameter names, formats the message, and allocates objects. In a high-throughput service logging thousands of events per second, this adds up.

With LoggerMessage, you define a partial method and let the generator create the optimized implementation:

csharp
public static partial class Log
{
    [LoggerMessage(
        Level = LogLevel.Information,
        Message = "Order {Id} shipped")]
    public static partial void Shipped(
        this ILogger log, int id);
}

// Call it like this
Log.Shipped(logger, 12345);

The generator produces code equivalent to:

csharp
public static void Shipped(this ILogger log, int id)
{
    if (log.IsEnabled(LogLevel.Information))
    {
        log.Log(
            LogLevel.Information,
            new EventId(0, "Shipped"),
            new LogValues { Id = id },
            null,
            LogValues.Format);
    }
}

The structured logging format avoids string interpolation, reduces allocations, and makes structured data queryable in your logging backend. For a system logging millions of events daily, this difference is material.

You can even stack parameters:

csharp
public static partial class Log
{
    [LoggerMessage(
        Level = LogLevel.Warning,
        Message = "Payment retry attempt {Attempt} for Order {OrderId} after {DelayMs}ms")]
    public static partial void PaymentRetrying(
        this ILogger log, int attempt, int orderId, int delayMs);
}

Writing your own source generators

Building a custom generator is tempting, especially when you see boilerplate repeating across your codebase. But restraint is important. Generators add build-time complexity, make debugging harder, and create invisible dependencies on your build pipeline.

That said, there are legitimate cases. Consider a mapping generator for your domain models:

csharp
[AutoMap(typeof(CreateOrderRequest), typeof(Order))]
public partial class OrderMapper
{
    public partial Order Map(CreateOrderRequest request);
}

// Usage
var order = mapper.Map(requestDto);

A generator could inspect both types, match properties by name, and emit a Map method. This beats hand-writing dozens of mapper methods or runtime reflection.

But before you build one, ask: Is this a performance bottleneck? Can I use an existing tool like Mapster or AutoMapper? Is the complexity worth avoiding the library dependency?

If you do build one, start with IIncrementalGenerator, not the older ISourceGenerator. Incremental generators are more efficient because they cache results and only regenerate when inputs change, making rebuilds much faster.

csharp
public class MappingGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        var classDeclarations = context.SyntaxProvider
            .CreateSyntaxProvider(
                predicate: (s, _) => s is ClassDeclarationSyntax,
                transform: (ctx, _) => (ClassDeclarationSyntax)ctx.Node)
            .Where(c => c is not null);

        context.RegisterSourceOutput(classDeclarations, (spc, classDecl) =>
        {
            var className = classDecl.Identifier.Text;
            var source = $@"
namespace Generated
{{
    public partial class {className}Mapper
    {{
        public void GeneratedMethod() {{ }}
    }}
}}";
            spc.AddSource($"{className}_Mapper.g.cs", source);
        });
    }
}

This is the skeleton. You'd inspect the syntax tree, extract properties, match types, and generate specific map methods. The key is that all this work happens at build time, not runtime.

Common mistakes and pitfalls

  • Assuming generated code is fast by default: A generator can emit inefficient code just like you can write it by hand. Profile and validate the output.
  • Overcomplicating the generator: Keep generator logic simple. If you're doing complex type resolution or multi-file coordination, you're probably overbuilding. Use existing solutions.
  • Not versioning generator output: If your generator changes, regenerated code may break existing compiled assemblies. Document when you regenerate and test carefully.
  • Ignoring the generated code: Always inspect what's emitted. Check the obj/Debug/net8.0/generated folder, read the code, make sure it's what you intended.
  • Building generators for one-off scenarios: If you're generating code for a single class or endpoint, you don't need a generator. Use explicit partial methods or refactor to reduce boilerplate.
  • Poor error messages in generators: When a generator fails, the error message is all the developer has. Invest time in clear, actionable diagnostics.

Key takeaways

  • Source generators shift code generation from runtime to compile time, eliminating reflection overhead and producing debuggable, type-safe code.
  • You already use them: System.Text.Json, regex, minimal APIs, and LoggerMessage all rely on generators for performance.
  • LoggerMessage is the easiest win for most teams — it replaces reflection-based logging with allocation-free structured methods.
  • Build your own only when existing tools don't fit, the performance payoff is clear, and you're willing to maintain build-time complexity.
  • Always inspect generated output, keep generator logic simple, and use IIncrementalGenerator for fast incremental builds.
  • Generators are a tool for performance and correctness, not a replacement for good architecture or solid libraries.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles