Source-Generated JSON
Generate JSON contracts at compile time with JsonSerializerContext, wire them into ASP.NET Core, and avoid the missing-type failure under Native AOT.
[JsonSerializable(typeof(List<OrderSummary>))]
internal partial class ShopJsonContext : JsonSerializerContext;
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, ShopJsonContext.Default));By default System.Text.Json discovers a type's contract at run time: the first time a type is used with an options instance, it reflects over the properties and constructors, builds a JsonTypeInfo, and where dynamic code is allowed emits IL for fast accessors. That costs time on the first request for each type, memory for the metadata, and it is not compatible with trimming or Native AOT, where reflection over unknown types is not available.
How the generator works
You declare a partial class deriving from JsonSerializerContext and list root types with [JsonSerializable(typeof(T))]. At compile time the generator writes the metadata for those types and everything reachable from them, exposed as properties such as ShopJsonContext.Default.OrderSummary (a JsonTypeInfo<OrderSummary>). GenerationMode chooses metadata (supports reading and writing), serialization-only fast-path code that writes straight to a Utf8JsonWriter, or both (the default). The fast path is only used when the runtime options are compatible with the context's [JsonSourceGenerationOptions].
Wiring it into ASP.NET Core
Insert the context at the front of the resolver chain: ConfigureHttpJsonOptions(o => o.SerializerOptions.TypeInfoResolverChain.Insert(0, ShopJsonContext.Default)). The chain is asked in order and the first resolver that knows the type wins. The ASP.NET Core options still apply: naming policy, converters and ignore conditions come from the options object, so a context without JsonSerializerDefaults.Web still produces camelCase in responses.
The production failure
When reflection is enabled, ASP.NET Core keeps the reflection-based DefaultJsonTypeInfoResolver at the end of the chain. A type you forgot to register is served silently by reflection, every test passes, and nobody notices. A PublishAot build sets JsonSerializerIsReflectionEnabledByDefault to false, the fallback disappears, and the first request returning that type fails with NotSupportedException: JsonTypeInfo metadata for type ... was not provided, a 500. Common misses:
- Collections returned at the top level:
List<OrderSummary>andOrderSummary[]are separate roots. - Derived types used polymorphically when the base has no
[JsonDerivedType]. - Anonymous types, as in
TypedResults.Ok(new { id, total }), which cannot be listed in[JsonSerializable], and values typed asobject. - Enums with the non-generic
JsonStringEnumConverter; useJsonStringEnumConverter<TEnum>.
Set JsonSerializerIsReflectionEnabledByDefault to false in every build, not only the AOT publish, so tests hit the same failure, and test that every response type resolves.
When to use it
It is required for Native AOT and for trimmed apps, and worth it for services where cold start matters (scale-to-zero, many short-lived pods). In a long-running JIT service the gain is mostly start-up time and first-request latency; steady-state throughput is similar, so measure before converting a large codebase. Minimal APIs in an AOT app also need the Request Delegate Generator, which PublishAot turns on.
Example
When to use it
- Publish an orders API with PublishAot for a scale-to-zero platform where cold start decides the first customer's latency.
- Catch a forgotten List<OrderSummary> registration in CI rather than as a 500 after the AOT image is deployed.
- Serialize outbox messages with ShopJsonContext.Default.OrderPlaced in a hot loop without an options lookup per call.
More examples
Making every build behave like the AOT build
The switch is written into each project's <code>runtimeconfig.json</code>, and a <code>WebApplicationFactory</code> test runs the API inside the test project's process, so setting it only in the API's .csproj would leave tests on reflection. Putting it in <code>Directory.Build.props</code> covers both. <code>TreatWarningsAsErrors</code> keeps AOT and trimming warnings such as IL2026 from shipping.
<!-- Directory.Build.props at the repository root, so the API and its test projects all get it.
The API's own .csproj sets <PublishAot>true</PublishAot>. -->
<Project>
<PropertyGroup>
<!-- A PublishAot build already defaults to false; this makes dotnet run and
dotnet test fail the same way on a type missing from a JsonSerializerContext. -->
<JsonSerializerIsReflectionEnabledByDefault>false</JsonSerializerIsReflectionEnabledByDefault>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
</Project>
One context per feature, combined at start-up
Each feature folder owns its context, so adding an endpoint and its <code>[JsonSerializable]</code> line happen in the same change. <code>OrderLineDto</code> and <code>List<OrderLineDto></code> are reachable from <code>OrderDto</code>, so they are generated without being listed; only types passed to the serializer as roots need an attribute.
using System.Text.Json.Serialization;
var builder = WebApplication.CreateSlimBuilder(args);
builder.Services.ConfigureHttpJsonOptions(options =>
{
var chain = options.SerializerOptions.TypeInfoResolverChain;
chain.Insert(0, OrdersJsonContext.Default);
chain.Insert(1, CatalogJsonContext.Default);
});
var app = builder.Build();
app.MapGet("/orders/{id:int}", (int id) =>
TypedResults.Ok(new OrderDto(id, OrderStatus.Paid, [new OrderLineDto("KB-104", 2, 59.90m)])));
app.MapGet("/products", () =>
TypedResults.Ok(new List<ProductDto> { new("KB-104", "Mechanical keyboard", 59.90m) }));
app.Run();
// The generic converter is AOT-safe; the non-generic one needs reflection.
[JsonConverter(typeof(JsonStringEnumConverter<OrderStatus>))]
public enum OrderStatus { Pending, Paid, Shipped }
public record OrderLineDto(string Sku, int Quantity, decimal UnitPrice);
public record OrderDto(int Id, OrderStatus Status, List<OrderLineDto> Lines);
public record ProductDto(string Sku, string Name, decimal Price);
[JsonSerializable(typeof(OrderDto))]
internal partial class OrdersJsonContext : JsonSerializerContext;
[JsonSerializable(typeof(List<ProductDto>))]
internal partial class CatalogJsonContext : JsonSerializerContext;
A test that every response type is generated
<code>GetTypeInfo</code> throws <code>NotSupportedException</code> when the only resolver is the context and the type is missing, so this test fails in a normal JIT test run even though reflection is available. Keep the list next to the endpoints, or build it from the endpoints' <code>IProducesResponseTypeMetadata</code> in an integration test.
using System.Text.Json;
using System.Text.Json.Serialization;
using Xunit;
public class JsonContractTests
{
public static TheoryData<Type> ResponseTypes =>
[
typeof(OrderSummary),
typeof(List<OrderSummary>),
typeof(CustomerSummary),
];
[Theory]
[MemberData(nameof(ResponseTypes))]
public void Response_type_has_generated_metadata(Type type)
{
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { TypeInfoResolver = ShopJsonContext.Default };
var info = options.GetTypeInfo(type);
Assert.Equal(type, info.Type);
}
}
public record OrderSummary(int Id, string Number, decimal Total, DateTimeOffset PlacedAt);
public record CustomerSummary(int Id, string Name);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(OrderSummary))]
[JsonSerializable(typeof(List<OrderSummary>))]
[JsonSerializable(typeof(CustomerSummary))]
public partial class ShopJsonContext : JsonSerializerContext;
Discussion