.NET Source Generators: Compile-Time Code Generation

Source generators run during compilation and produce C source code that gets compiled into your assembly. They replace runtime reflection with compile-time code generation, improving startup performan

Introduction#

Source generators run during compilation and produce C# source code that gets compiled into your assembly. They replace runtime reflection with compile-time code generation, improving startup performance and enabling AOT (Ahead-of-Time) compilation. System.Text.Json’s serializer context, LoggerMessage, and RegexGenerator are all built on source generators.

Why Source Generators#

1
2
3
4
5
6
7
8
9
10
11
// Problem: runtime reflection is slow and blocks AOT
var json = JsonSerializer.Serialize(myObject);
// Internally uses reflection to discover properties at runtime
// Cannot be AOT-compiled (reflection requires runtime type info)

// Solution: source generator produces serialization code at compile time
[JsonSerializable(typeof(MyObject))]
public partial class AppJsonContext : JsonSerializerContext { }

var json = JsonSerializer.Serialize(myObject, AppJsonContext.Default.MyObject);
// Uses generated code — no reflection, AOT-compatible, 2-5x faster

Built-In Generator: System.Text.Json#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
using System.Text.Json;
using System.Text.Json.Serialization;

// Define your types
public record OrderDto(
    Guid Id,
    string CustomerName,
    decimal Total,
    List<OrderLineDto> Lines
);

public record OrderLineDto(Guid ProductId, int Quantity, decimal UnitPrice);

// Declare the serializer context — generator creates the rest
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
[JsonSerializable(typeof(OrderLineDto))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    WriteIndented = false,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
)]
public partial class AppJsonContext : JsonSerializerContext { }

// Usage — fast, no reflection
public static class Json
{
    public static string Serialize<T>(T value, JsonTypeInfo<T> typeInfo)
        => JsonSerializer.Serialize(value, typeInfo);

    public static T? Deserialize<T>(string json, JsonTypeInfo<T> typeInfo)
        => JsonSerializer.Deserialize(json, typeInfo);
}

// In a controller or service:
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);
var order = JsonSerializer.Deserialize(json, AppJsonContext.Default.OrderDto);

Built-In Generator: LoggerMessage#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
using Microsoft.Extensions.Logging;

// Without generator: boxing + allocation per call
public class OrderService
{
    private readonly ILogger<OrderService> _logger;

    public void ProcessOrder(Guid orderId)
    {
        _logger.LogInformation("Processing order {OrderId}", orderId);  // allocates
    }
}

// With LoggerMessage generator: zero allocation, compile-time validation
public partial class OrderService
{
    private readonly ILogger<OrderService> _logger;

    [LoggerMessage(Level = LogLevel.Information, Message = "Processing order {OrderId}")]
    private partial void LogProcessingOrder(Guid orderId);

    [LoggerMessage(Level = LogLevel.Error, Message = "Order {OrderId} failed: {Error}")]
    private partial void LogOrderFailed(Guid orderId, string error);

    [LoggerMessage(Level = LogLevel.Warning, Message = "Order {OrderId} retrying (attempt {Attempt})")]
    private partial void LogOrderRetry(Guid orderId, int attempt);

    public void ProcessOrder(Guid orderId)
    {
        LogProcessingOrder(orderId);  // no allocation
    }
}

Built-In Generator: Regex#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
using System.Text.RegularExpressions;

public partial class Validator
{
    // Without generator: regex compiled at runtime
    private static readonly Regex EmailRegex = new(
        @"^[^@\s]+@[^@\s]+\.[^@\s]+$",
        RegexOptions.Compiled
    );

    // With generator: regex compiled at compile time
    [GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$")]
    private static partial Regex EmailPattern();

    [GeneratedRegex(@"^\+?[\d\s\-().]{7,15}$")]
    private static partial Regex PhonePattern();

    [GeneratedRegex(@"^[a-zA-Z0-9\-_]{3,50}$")]
    private static partial Regex UsernamePattern();

    public bool IsValidEmail(string email) => EmailPattern().IsMatch(email);
    public bool IsValidPhone(string phone) => PhonePattern().IsMatch(phone);
    public bool IsValidUsername(string username) => UsernamePattern().IsMatch(username);
}

Writing a Custom Source Generator#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
// A generator that creates ToString() for classes marked with [AutoToString]

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using System.Text;

[Generator]
public class AutoToStringGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Find all classes with [AutoToString] attribute
        var classes = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "AutoToStringAttribute",
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: (ctx, _) => (INamedTypeSymbol)ctx.TargetSymbol
            );

        context.RegisterSourceOutput(classes, GenerateToString);
    }

    private static void GenerateToString(
        SourceProductionContext context,
        INamedTypeSymbol classSymbol
    )
    {
        var ns = classSymbol.ContainingNamespace.ToDisplayString();
        var className = classSymbol.Name;

        var properties = classSymbol.GetMembers()
            .OfType<IPropertySymbol>()
            .Where(p => p.DeclaredAccessibility == Accessibility.Public);

        var sb = new StringBuilder();
        sb.AppendLine($"namespace {ns};");
        sb.AppendLine();
        sb.AppendLine($"public partial class {className}");
        sb.AppendLine("{");
        sb.AppendLine("    public override string ToString()");
        sb.AppendLine("    {");
        sb.Append($"        return $\"{className} {{ ");

        var parts = properties.Select(p => $"{p.Name} = {{{p.Name}}}");
        sb.Append(string.Join(", ", parts));
        sb.AppendLine(" }\";");
        sb.AppendLine("    }");
        sb.AppendLine("}");

        context.AddSource($"{className}.g.cs", sb.ToString());
    }
}

// The attribute definition (placed in a separate project or inline)
[AttributeUsage(AttributeTargets.Class)]
public sealed class AutoToStringAttribute : Attribute { }

// Usage in your application project:
[AutoToString]
public partial class Order
{
    public Guid Id { get; init; }
    public string CustomerName { get; init; } = "";
    public decimal Total { get; init; }
}

// Generated code (Order.g.cs):
// public override string ToString()
//     => $"Order { Id = {Id}, CustomerName = {CustomerName}, Total = {Total} }";

Incremental Generator for Performance#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// IIncrementalGenerator vs ISourceGenerator:
// IIncrementalGenerator (preferred): fine-grained caching, only regenerates
// when relevant syntax nodes change
// ISourceGenerator (legacy): regenerates everything on each compilation

[Generator]
public class MapperGenerator : IIncrementalGenerator
{
    public void Initialize(IncrementalGeneratorInitializationContext context)
    {
        // Step 1: Collect all [GenerateMapper] attributes
        var mapperClasses = context.SyntaxProvider
            .ForAttributeWithMetadataName(
                "GenerateMapperAttribute",
                predicate: (node, _) => node is ClassDeclarationSyntax,
                transform: (ctx, ct) => GetMapperInfo(ctx, ct)
            )
            .Where(info => info is not null);

        // Step 2: Generate code for each mapper
        context.RegisterSourceOutput(mapperClasses, (ctx, info) =>
        {
            var source = GenerateMapper(info!);
            ctx.AddSource($"{info!.ClassName}Mapper.g.cs", source);
        });
    }

    private static MapperInfo? GetMapperInfo(
        GeneratorAttributeSyntaxContext ctx,
        CancellationToken ct)
    {
        if (ctx.TargetSymbol is not INamedTypeSymbol symbol)
            return null;

        // Extract source and target types from attribute
        var attr = ctx.Attributes.First();
        var sourceType = attr.ConstructorArguments[0].Value as ITypeSymbol;
        var targetType = attr.ConstructorArguments[1].Value as ITypeSymbol;

        return new MapperInfo(symbol.Name, sourceType, targetType);
    }
}

ASP.NET Core + Source Generation for Performance#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Minimal API with source-generated serialization
using System.Text.Json;

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

var app = builder.Build();

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

app.Run();

Conclusion#

Source generators move work from runtime to compile time, improving startup performance and enabling Native AOT compilation. Start with the built-in generators: [JsonSerializable] for serialization, [LoggerMessage] for structured logging, and [GeneratedRegex] for regex. Custom source generators are appropriate when you find yourself writing boilerplate that depends only on type structure — mappers, validators, ToString implementations, and similar patterns. Use IIncrementalGenerator for all new generators to benefit from fine-grained caching and faster rebuild times.

Contents