Source Generators in .NET: Replacing Reflection in Serialization Without Losing Startup Speed
When System.Text.Json needs to serialize an object, by default it works out at runtime which properties a type has, what they're called, what attributes they carry, and how to reach their getters and setters. That resolution happens through reflection: the runtime inspects the assembly's metadata, builds an internal representation of the type -a JsonTypeInfo- and caches it so the work isn't repeated on every subsequent call. That first resolution isn't free, and in scenarios where startup time matters -a serverless function, a container that scales to zero and needs to come up in milliseconds, a binary compiled with Native AOT- that initial reflection work shows.
The problem isn't only speed. Native AOT compiles the application to native machine code with no .NET runtime underneath, and to keep the binary small it applies trimming: it strips out of the final assembly any code the static analyzer can't prove is used. Reflection depends on metadata that the trimmer can't always track -calling a getter by property name at runtime leaves no visible trace in the static dependency graph- so it's common for a type to serialize fine in Debug mode against the full runtime and then fail in production under Native AOT, because the trimmer removed the reflection metadata that was needed at that point.
What the compiler generates instead of resolving at runtime
C# source generators, available since .NET 6 for the Roslyn compiler, run code during compilation that analyzes the project's syntax tree and emits additional C# code before the build finishes. For JSON serialization, that means that instead of the runtime discovering a type's properties via reflection the first time it serializes it, the compiler has already generated, at build time, the concrete code that reads each property and writes each field of the JSON output.
You turn it on by declaring a partial class that inherits from JsonSerializerContext and marking it with one attribute per type to be serialized:
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(OrderLine))]
[JsonSerializable(typeof(List<Order>))]
public partial class AppJsonContext : JsonSerializerContext
{
}
The compiler sees that declaration and generates, in a separate file that nobody writes by hand or checks into version control, the actual serialization code for Order, OrderLine, and List<Order>: properties read directly, with no PropertyInfo.GetValue call, no resolving the type by name on every call. To use it, you just point the serializer options at the generated context:
var options = new JsonSerializerOptions
{
TypeInfoResolver = AppJsonContext.Default
};
var json = JsonSerializer.Serialize(order, options);
With TypeInfoResolver pointing at the generated context, JsonSerializer never takes the reflection path for those types: it uses the JsonTypeInfo the compiler already built directly. The trimmer, in turn, can analyze that generated code like any other ordinary method in the project, so there's no longer a blind spot where it can't tell whether something is used.
The effect on startup
Reflection-based resolution of a type doesn't happen once in some abstract sense: it happens the first time that specific type goes through the serializer within the process. In a long-running application that cost is paid once and amortized over thousands of subsequent calls, so it's barely noticeable. In a scenario where the process starts up, handles a handful of requests, and may terminate -an Azure Function on a consumption plan, a Cloud Run container scaling to zero between traffic bursts- that first-resolution cost is paid on every cold start, because there's no long-running process to amortize it over. With source generation, that resolution no longer exists as a runtime step at all: the code that, under the reflection model, would run the first time a type is serialized is already compiled as part of the assembly, so starting up doesn't involve discovering anything about the types that will be serialized.
What didn't work: mixing the generated context with a reflection fallback
The first version of our migration didn't fully replace the default resolver; instead, it combined it with the generated context through a chained resolver:
var options = new JsonSerializerOptions
{
TypeInfoResolver = JsonTypeInfoResolver.Combine(
AppJsonContext.Default,
new DefaultJsonTypeInfoResolver())
};
The reasoning seemed sound: types registered in AppJsonContext use the generated code, and any type someone forgot to register automatically falls back to the reflection-based resolver instead of failing outright. In the development environment, with the full .NET runtime and no trimming, that's exactly what happened, and nobody noticed anything odd. The problem surfaced when we published with Native AOT: a new DTO a developer added to an endpoint without updating the context's [JsonSerializable] attribute list compiled without errors, passed the tests -which ran against the normal runtime, not the AOT binary- and then failed in production with a NotSupportedException on the very first real request, because the trimmer had stripped the reflection metadata the default resolver needed for that specific type, and the fallback that had worked silently in development no longer had anything to fall back to.
We fixed it by dropping the fallback entirely and leaving the generated context as the sole resolver:
var options = new JsonSerializerOptions
{
TypeInfoResolver = AppJsonContext.Default
};
Without a fallback, an unregistered type doesn't fail silently in production: it fails in every environment, including development, with the same error, as soon as anyone tries to serialize it. That moves the problem from a NotSupportedException on a real production request to a reproducible failure in the first manual or automated test that exercises that type, which is exactly where we want it to show up. The cost is that every new type being serialized needs to be added to the context explicitly, a step that used to be invisible and is now a line of code someone has to remember to write - but we'd rather have that explicit reminder than a fallback that behaves differently depending on the publish mode.
When the change is worth making
Not every application needs this. A long-running service, with no cold-start constraints and no Native AOT on the horizon, pays the reflection cost once per type and amortizes it without anyone noticing. It matters in any scenario where the process starts up frequently -serverless functions, containers that scale to zero, short-lived CLIs- or where Native AOT is a requirement, because there reflection isn't just a performance question but a compatibility one: without source generation, any type not covered by the default resolver in trimmed mode can fail at runtime in a way no test run against the full runtime will ever catch. The rule we apply now is simple: if the project already targets Native AOT, the generated context isn't an optional optimization - it's the only supported way to serialize reliably.