Source Generators and AOT-Friendly Code
Objective
Reflection and runtime code generation are how .NET libraries used to discover
your types, and they are exactly what trimming and Native AOT cannot see
through. A source generator moves that work to compile time: it reads your code
and adds new C# files to the compilation, so the result is ordinary, visible,
analyzable code. This concept shows the generators the platform ships
([GeneratedRegex], JsonSerializerContext, [LoggerMessage]), how to read
what they emit, what the AOT analyzers say about the reflection-based
alternative, and how small a custom incremental generator can be.
Use Cases
- Making a console app or minimal API safe to trim or publish with Native AOT.
- Replacing a hot-path
new Regex(...)with code the compiler can optimize. - Removing boilerplate such as
ToString, mappers orINotifyPropertyChangedwith your own generator. - Debugging "what did the framework generate for my type?"
- Getting faster startup by skipping reflection-based discovery.
Deep Dive
The three platform generators in one project
All three come with the SDK or a Microsoft package; none needs a reference to
Roslyn. This net10.0 console project (SDK 10.0.100) uses them together, with
IsAotCompatible turned on so the trim and AOT analyzers run during a normal
build:
csharpvar m = Rx.Order().Match("order-42");
Console.WriteLine($"regex: {m.Success} {m.Groups[1].Value}");
var json = JsonSerializer.Serialize(new Item("pen", 3), AppJson.Default.Item);
Console.WriteLine($"json: {json}");
using var lf = LoggerFactory.Create(b => b.AddSimpleConsole());
Log.Created(lf.CreateLogger("demo"), 42);
record Item(string Name, int Qty);
[JsonSerializable(typeof(Item))]
partial class AppJson : JsonSerializerContext;
static partial class Rx
{
[GeneratedRegex(@"^order-(\d+)$")]
public static partial Regex Order();
}
static partial class Log
{
[LoggerMessage(Level = LogLevel.Information, Message = "Created order {Id}")]
public static partial void Created(ILogger logger, int id);
}textregex: True 42 json: {"Name":"pen","Qty":3} info: demo[165548891] Created order 42
The strange number is the event id: I omitted EventId, so the generator
produced one. Each generator is triggered by an attribute on a partial type or
method and supplies the missing half. The docs say so for the regex generator
and for [LoggerMessage], whose method must be partial and return void; a
static one must receive the ILogger as a parameter. The logging page also
notes that the generator needs a C# 9 or later compiler.
Seeing what was generated
Add two properties and the files land on disk:
plaintext<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <CompilerGeneratedFilesOutputPath>$(MSBuildProjectDirectory)/generated</CompilerGeneratedFilesOutputPath>
My run produced one folder per generator: RegexGenerator.g.cs, six
AppJson*.g.cs files and LoggerMessage.g.cs. The regex file begins with an
explanation of the pattern as XML comments ("Match the string "order-"", "1st
capture group"). Reading these files is the fastest way to learn what a
generator really does, and you can set breakpoints in them.
One trap, hit for real: when the output folder lives inside the project, the
next build sees those .cs files as normal sources and also receives the
generator output, so every member is defined twice. The build failed with errors
like CS0102: The type 'AppJson' already contains a definition for 'String'. Fix
it by excluding the folder:
plaintext<ItemGroup><Compile Remove="generated/**" /></ItemGroup>
What the analyzers say about the reflection path
With IsAotCompatible on, I added the classic reflection-based call next to the
generated one:
csharpJsonSerializer.Serialize(new Item("pen", 3));textwarning IL2026: Using member 'JsonSerializer.Serialize<TValue>(TValue, JsonSerializerOptions)' which has 'RequiresUnreferencedCodeAttribute' can break functionality when trimming... Use the overload that takes a JsonTypeInfo or JsonSerializerContext... warning IL3050: ... which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling... Use System.Text.Json source generation for native AOT applications.
The overload that takes AppJson.Default.Item produced no warning. That is the
whole idea: the context lists the types at compile time, so the trimmer and the
AOT compiler see every constructor and property. A plain new Regex(pattern)
gave no warning in my build; the regex page recommends the generator for
startup, throughput and trimmability, and says the analyzer SYSLIB1045
suggests the conversion for patterns known at compile time. Regexes the
generator cannot implement (for example RegexOptions.NonBacktracking, per the
docs) fall back to a cached Regex instance.
Writing a small incremental generator
A custom generator is a class implementing IIncrementalGenerator in a
separate project. Mine targeted netstandard2.0, referenced
Microsoft.CodeAnalysis.CSharp 4.14.0, and loaded fine in the SDK 10 compiler.
It emits an [AutoToString] attribute and, for each annotated class, a
ToString:
csharp[Generator]
public sealed class ToStringGenerator : IIncrementalGenerator
{
public void Initialize(IncrementalGeneratorInitializationContext context)
{
context.RegisterPostInitializationOutput(c =>
c.AddSource("AutoToStringAttribute.g.cs", AttributeSource));
var models = context.SyntaxProvider.ForAttributeWithMetadataName(
"Gen.AutoToStringAttribute",
predicate: static (node, _) => node is ClassDeclarationSyntax,
transform: static (ctx, _) => /* read symbol, return a small model */);
context.RegisterSourceOutput(models, static (spc, m) =>
spc.AddSource($"{m.Name}.g.cs", /* build the partial class text */));
}
}The app references it as an analyzer, not as a library:
plaintext<ProjectReference Include="../Gen/Gen.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
With [AutoToString] public partial class Person the output was
Person { Name=Ada, Age=36 }, and the emitted Person.g.cs was the three-line
partial class one would write by hand. Two design rules matter. The pipeline
should pass between stages only small, value-equatable models (strings,
arrays of strings), never ISymbol or syntax nodes, so the compiler can see
that nothing changed and skip regenerating; the Roslyn cookbook linked below
covers this, and I did not benchmark it here. And the cheap predicate filters
syntax first, so the costly transform runs only for candidates.
Trade-offs
- Generated code is AOT-safe and fast, but it is code in your assembly: many regexes or JSON types grow the binary, and a rarely used regex may not deserve the generator.
- Source-generated JSON needs every serialized type registered in a
JsonSerializerContext; a type you forget is simply not covered by the context, so check the generated members. - Partial members are the contract: the attribute only works on
partialtypes and methods, so you edit declarations you used to leave alone. - Custom generators remove boilerplate, but they run inside the compiler and IDE, are hard to debug, need an extra project, and can slow the editor if the pipeline is not incremental.
EmitCompilerGeneratedFileshelps reviews and debugging, but the output folder must be excluded from compilation and from source control noise.