Using built-in .NET source generators for performance, trimming and Native AOT, and inspecting generated code.
A source generator is a compiler plug-in that reads your code (usually a marker attribute on a partial declaration) and adds new C# files to the compilation; it can never change or delete your code. .NET ships several: System.Text.Json ([JsonSerializable] on a JsonSerializerContext) for reflection-free serialisation, [GeneratedRegex] for regex compiled to C# at build time, [LoggerMessage] for allocation-free structured logging, [LibraryImport] for P/Invoke marshalling, and in ASP.NET Core the configuration binding and Request Delegate generators that replace reflection in Bind/Get<T> and minimal APIs. They improve startup, cut allocations and make code trim and Native AOT safe. Inspect output under Dependencies, Analyzers in the IDE, or set EmitCompilerGeneratedFiles to write the files to obj.
A source generator is a compiler plug-in that runs during compilation, inspects your code (syntax and semantic model) and adds new C# source files to the compilation. As a consumer, you usually trigger one with an attribute or a partial declaration, and get fast, reflection-free, AOT-friendly code. I'd explain what generators can and can't do, then walk through the ones that ship with .NET 10 and how to inspect and debug their output.
Can:
AdditionalFiles (e.g. JSON schemas), MSBuild properties.*.g.cs) — typically implementing partial members you declared.IIncrementalGenerator.Cannot:
partial types/methods/properties.System.Text.Json source generation
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<OrderDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }
var json = JsonSerializer.Serialize(order, AppJsonContext.Default.OrderDto);
builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
Generates type metadata (and optionally fast-path serialisation code) at compile time: no runtime reflection, faster start-up, required for NativeAOT/trimming.
[GeneratedRegex]
public static partial class Patterns
{
[GeneratedRegex(@"^\d{4}$", RegexOptions.CultureInvariant)]
public static partial Regex AustralianPostcode();
}
Generates the regex matching code as C# — no runtime regex parsing/compilation, readable source, AOT-friendly, often faster than RegexOptions.Compiled. Analyser SYSLIB1045 suggests converting.
[LoggerMessage]
public static partial class Log
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Order {OrderId} shipped to {State}")]
public static partial void OrderShipped(ILogger logger, Guid orderId, string state);
}
Generates high-performance logging: checks IsEnabled first, no params object[], no boxing, cached template parsing.
[LibraryImport] (replaces [DllImport] for P/Invoke)
[LibraryImport("libc", StringMarshalling = StringMarshalling.Utf8)]
internal static partial int strlen(string s);
Generates marshalling code at compile time instead of runtime IL stubs — AOT-compatible and debuggable.
Configuration binding generator: enable with <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator> (on by default for AOT/trimmed web apps). It uses interceptors to replace calls like config.GetSection("Smtp").Get<SmtpOptions>() and services.Configure<T>(section) with generated, reflection-free binding code.
Request Delegate Generator (RDG) for minimal APIs: <EnableRequestDelegateGenerator>true</EnableRequestDelegateGenerator> (default with PublishAot). It intercepts app.MapGet(...) calls and generates the parameter binding and response-writing code that would otherwise be built at run time with reflection and expression trees.
Others: options validation generator ([OptionsValidator]), [ComInterfaceGenerator] (COM interop), metrics generators ([Counter], [Histogram] in Microsoft.Extensions.Telemetry.Abstractions), and the Blazor/Razor compilers.
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>Generated</CompilerGeneratedFilesOutputPath>
dotnet build -bl and the MSBuild Structured Log Viewer show generator timings.Moving an API to NativeAOT for faster cold start on Azure Container Apps produced dozens of trimming warnings. We added a JsonSerializerContext for all API DTOs, enabled RDG and the configuration binding generator, converted [DllImport] to [LibraryImport] in a small interop layer, and replaced hot-path Regex instances with [GeneratedRegex]. Warnings went to zero, cold start dropped from ~1.4 s to ~120 ms, and memory usage fell — and the JSON and logging changes made the JIT build faster too.
The interviewer wants to see that you use modern .NET's build-time features, not just reflection-era APIs.
Green flags
Red flags
JsonSerializerContext or [LoggerMessage].The compiler loads analyser/generator assemblies (from NuGet packages or the SDK's built-in ones), creates the initial compilation, runs generators (incremental pipelines), adds their outputs, then compiles everything together. Because outputs feed into the same compilation, your code can call generated methods.
Incremental generators define a pipeline (context.SyntaxProvider.ForAttributeWithMetadataName(...), .Select(...), .Combine(...)) whose steps are cached by value equality, so typing in the IDE only re-runs affected parts — crucial for IDE performance.
You write the declaration:
[LoggerMessage(...)] public static partial void OrderShipped(ILogger logger, Guid id, string state);
The generator writes the implementation in another part:
public static partial void OrderShipped(ILogger logger, Guid id, string state)
{
if (logger.IsEnabled(LogLevel.Information))
__OrderShippedCallback(logger, id, state, null);
}
C# 13 partial properties and C# 14 partial constructors/events extend what generators can implement.
Interceptors let a generator say "replace the call at file X, line Y, column Z with my method" via [InterceptsLocation] (now using an opaque, versioned location encoding). RDG and the configuration binder use them to swap reflection-based APIs for generated code without you changing call sites. Projects opt in via InterceptorsNamespaces (formerly InterceptorsPreviewNamespaces).
| Benefit | Cost |
|---|---|
| No runtime reflection; AOT/trim friendly | Build-time cost (usually small with incremental generators) |
| Faster start-up and execution | Generated code adds to assembly size |
| Errors at compile time | Some features limited vs reflection (e.g. polymorphic JSON needs explicit config) |
| Debuggable generated source | Requires partial declarations and attributes |
JsonTypeInfo metadata; serialisation still uses the general serializer engine.Utf8JsonWriter code for serialisation (not deserialisation), fastest for writes.[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata | Serialization)].[GeneratedRegex] and [LoggerMessage] to a project and inspect the generated files.EmitCompilerGeneratedFiles and look at the output folder.PublishAot and compare warnings with and without a JsonSerializerContext.Regex with RegexOptions.Compiled vs [GeneratedRegex] for start-up and throughput.<!-- Generators.Demo.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks> <!-- required by LibraryImport -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> <!-- write generated files to obj -->
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.0" />
</ItemGroup>
</Project>
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
public sealed record Order(int Id, string Customer, decimal Total);
// System.Text.Json: serialisation metadata generated at build time
[JsonSerializable(typeof(Order))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class AppJsonContext : JsonSerializerContext
{
}
public static partial class Patterns
{
// Australian postcode; partial property form needs .NET 9 / C# 13
[GeneratedRegex(@"^\d{4}$")]
public static partial Regex Postcode { get; }
}
public static partial class Log
{
// Template placeholders are checked against parameters at build time
[LoggerMessage(EventId = 1001, Level = LogLevel.Information, Message = "Order {OrderId} placed for {Total}")]
public static partial void OrderPlaced(ILogger logger, int orderId, decimal total);
}
public static partial class Native
{
// Marshalling generated at build time (Linux/macOS libc)
[LibraryImport("libc", EntryPoint = "getpid")]
public static partial int GetPid();
}
public static class Program
{
public static void Main()
{
var order = new Order(42, "Mia", 129.95m);
string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Order? back = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);
Console.WriteLine($"{json} -> {back}");
Console.WriteLine(Patterns.Postcode.IsMatch("2000")); // True
Console.WriteLine(Patterns.Postcode.IsMatch("20000")); // False
using ILoggerFactory factory = LoggerFactory.Create(b => b.AddConsole());
ILogger logger = factory.CreateLogger("Orders");
Log.OrderPlaced(logger, order.Id, order.Total);
if (!OperatingSystem.IsWindows())
Console.WriteLine($"PID {Native.GetPid()}");
}
}
partialWhat happens: attributes added to non-partial methods/classes.
Why it is wrong: generators can't add implementations; compile errors or no generation.
Do instead: declare classes and members partial as required.
What happens: a JsonSerializerContext exists but ASP.NET Core still uses reflection.
Why it is wrong: no AOT benefit; trim warnings remain.
Do instead: add the context to TypeInfoResolverChain or pass JsonTypeInfo explicitly.
What happens: some DTOs or collection types aren't listed.
Why it is wrong: runtime NotSupportedException in AOT apps.
Do instead: register every root type (including List<T> and polymorphic types).
What happens: modifying .g.cs outputs.
Why it is wrong: overwritten on the next build.
Do instead: change your declarations or generator options.
What happens: SYSLIB warnings suppressed. Why it is wrong: hides misconfiguration (invalid templates, unsupported types). Do instead: fix them; treat them as errors in CI.
What happens: trying to inject behaviour into existing methods. Why it is wrong: generators only add code. Do instead: use partial members, interceptors (via official generators), or decorators.
RegexOptions.Compiled for static patternsWhat happens: compiled regexes at start-up.
Why it is wrong: slower start-up and not AOT friendly.
Do instead: [GeneratedRegex].
[LoggerMessage]What happens: LogInformation($"...") in hot paths.
Why it is wrong: allocations and lost structure.
Do instead: source-generated logging methods.
Built-in generators are the practical face of a big .NET shift: move work from run time to build time. A senior engineer uses them deliberately.
| Goal | Generator lever |
|---|---|
| Faster cold start in containers or serverless | JSON context, GeneratedRegex, RDG, config binder |
| Lower allocation in hot logging paths | LoggerMessage |
| Native AOT or trimmed publish | All of the above; they remove reflection warnings |
| Earlier failure | Build-time diagnostics for templates, patterns and marshalling |
The costs are real but small: slightly longer builds, more files to review, and features that the reflection path supports but the fast path does not (some custom converters, dynamic types). Adopt them where start-up, throughput or AOT matter, and turn on the SYSLIB analyser suggestions so the team migrates gradually rather than in one risky change.
Reflection reads the metadata the compiler wrote into an assembly at run time: typeof(T) or obj.GetType() gives a Type, and from it you get PropertyInfo, MethodInfo, FieldInfo and so on, filtered by BindingFlags. It is flexible but slow on hot paths: lookups search metadata, Invoke takes an object[], boxes value types, checks arguments and wraps exceptions in TargetInvocationException. Make it fast by looking up once and caching, turning a MethodInfo into a typed delegate, using MethodInvoker (.NET 8) when you only have a MethodInfo, or [UnsafeAccessor] (.NET 8) to reach a private member with no reflection at all. Reflection also hides dependencies from the trimmer, so in trimmed or Native AOT apps you must annotate with [DynamicallyAccessedMembers] or move the work to a source generator.
An attribute is declarative metadata: the compiler serialises the constructor arguments and named properties into the assembly's metadata, and nothing runs until someone reads it. An instance is created only when code calls GetCustomAttribute(s), and a new instance is created on every call. [AttributeUsage] controls valid targets, AllowMultiple and Inherited. Arguments must be compile-time constants, typeof, enums or one-dimensional arrays of those. C# 11 added generic attributes ([Validator<OrderValidator>]). Some attributes are read by the compiler itself ([Obsolete], [Conditional], nullable attributes such as [NotNullWhen]), and caller info attributes ([CallerMemberName], [CallerFilePath], [CallerLineNumber], C# 10's [CallerArgumentExpression]) make the compiler fill optional parameters at the call site.
Roslyn is the C# compiler exposed as an API: code becomes syntax trees, a semantic model that knows what every symbol means, and language-neutral operations. A Roslyn analyser is a plug-in that subscribes to those and reports diagnostics in the IDE and in every build, so team rules become compiler warnings or errors instead of review comments. Start with what ships with the SDK: the .NET analysers (CAxxxx) and code style rules (IDExxxx), tuned with AnalysisLevel/AnalysisMode, .editorconfig severities and EnforceCodeStyleInBuild. BannedApiAnalyzers bans specific APIs from a BannedSymbols.txt file with no custom code. When you need a domain rule, write a DiagnosticAnalyzer targeting netstandard2.0, register operation or symbol actions, enable concurrent execution, test it, and ship it as a NuGet package or project reference.
How to tell it (STAR)
**Situation:** during [sales events] our order API scaled out, but each new replica needed [2 seconds] to start, so users saw errors. **Task:** I was asked to cut cold start without a rewrite. **Action:** I profiled startup, found reflection-heavy JSON, regex and binding work, and moved each to the built-in source generators, then published with Native AOT and fixed the remaining trimming warnings. **Result:** cold start fell to about [300 ms], memory per replica dropped by [40%], and scale-out errors disappeared.
Last updated 30 Oct 2026.