System.Text.Json: Reflection vs Source Generation — A Decision Guide for .NET Services
Reflection or source-generated System.Text.Json? A decision guide covering trimming, Native AOT, cold starts, hybrid resolver chains, and how to validate the choice on your own DTOs.
08 Oct 2025, 14:58 UTC

If your ASP.NET Core service serializes a stable set of DTOs, you have two supported paths in System.Text.Json: the default reflection-based serializer, or compile-time source generation via JsonSerializerContext. The short version: keep reflection unless you need trimming, Native AOT, or faster cold starts — those three constraints are where source generation stops being optional and becomes the only viable choice.
This guide assumes .NET 8 or later. The resolver-chain APIs and analyzer behavior described here vary across .NET 6–9, so confirm specifics against the version you target.
The decision and its constraints
Reflection-based serialization discovers type metadata at runtime. It works with zero setup and handles types you don't know about at compile time — plugin assemblies, dynamic payloads, open generic shapes. The cost is runtime metadata construction on first use, and a hard incompatibility with full trimming and Native AOT publishing, because the trimmer cannot see which members reflection will touch.
Source generation moves that discovery to compile time. You declare every serializable type with attributes; a source generator emits the metadata code into your assembly. You get faster startup, lower memory for metadata, and trim/AOT compatibility — at the cost of enumerating your types up front.
Options compared
| Factor | Reflection (default) | Source-generated context |
|---|---|---|
| Setup effort | None | Partial context class + [JsonSerializable] per type |
| Trimming / Native AOT | Not supported (warnings or failures) | Supported |
| Cold-start latency | Higher (runtime metadata build) | Lower (metadata pre-generated) |
| Unknown or plugin types | Works | Must be declared; otherwise falls back or fails |
| Problem detection | Runtime exceptions | Compile-time warnings (SYSLIB analyzers) |
| Steady-state throughput | Baseline | Often similar; don't assume a win |
That last row matters. The clearest, reliable gains from source generation are startup time, memory footprint, and AOT compatibility — not per-request serialization speed in every workload. If your service is a long-running process with warm caches and no trimming requirement, reflection is a legitimate final answer, not a stopgap.
Implementing a source-generated context
The pattern is a partial class deriving from JsonSerializerContext, annotated with the types you serialize:
using System.Text.Json.Serialization;
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(CustomerDto))]
public partial class AppJsonContext : JsonSerializerContext
{
}Two details trip up first-time adopters: the class must be partial (the generator emits the other half), and it must be accessible from where you use it. Forgetting partial produces compiler errors that don't obviously point at the cause.
Wire it into ASP.NET Core in Program.cs (runs at startup; needs no special permissions):
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});TypeInfoResolverChain is the hybrid escape hatch: resolvers are tried in order, so your generated context serves the hot DTOs while the built-in reflection resolver (which remains in the chain by default) still handles anything you didn't declare. Note that combining reflection into the chain reintroduces trim warnings — for a fully AOT-safe app, the chain must contain only generated resolvers.
For polymorphic hierarchies, source generation needs explicit declarations, or derived members silently drop out of the JSON:
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(EmailNotification), "email")]
[JsonDerivedType(typeof(SmsNotification), "sms")]
public abstract class Notification { }Validating the choice
Don't migrate on faith. Three concrete checks, all runnable locally with the .NET SDK:
- Output parity. Serialize a representative DTO with both modes and diff the JSON. Naming policies and converter settings must match between
JsonSerializerOptionsand[JsonSourceGenerationOptions], or you'll get subtle casing differences. - Trim/AOT build. Run
dotnet publish -p:PublishAot=true(or enable the trim analyzer) from the project directory. Expect the pure reflection path to produce warnings; the generated path should build clean. Watch for SYSLIB-prefixed analyzer warnings — they flag exactly the APIs in your code that are trim-incompatible. - Cold-start measurement. Time first serialization of your largest DTO in both modes (a simple stopwatch loop or BenchmarkDotNet with separate process invocations). This tells you whether the startup gain is material for your payload sizes.
Limitations to keep in mind
Every serialized type must be known at compile time; runtime-discovered types need the hybrid chain or a different serializer strategy. Generated contexts add compile time and generated code volume in large models. And the exact API surface — TypeInfoResolverChain ordering, analyzer coverage, AOT feature support — shifts across .NET versions, so re-verify when you upgrade the target framework.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.