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} }";
|
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);
}
}
|
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.