JSON Source Generation for AOT-Ready .NET Applications

System.Text.Json relies on runtime reflection by default — it inspects your types, discovers properties, and builds serialization metadata on the fly. This works brilliantly in a standard .NET application but falls apart with Native AOT, where the trimmer strips away unreferenced code and the runtime reflection infrastructure is heavily reduced.

JSON source generation solves this by shifting metadata collection to compile time. A Roslyn source generator produces the serialization code ahead of time, removing the need for reflection entirely.

The basics

Create a partial class that inherits from JsonSerializerContext and decorate it with [JsonSerializable] attributes for each type you need to serialize:

Example.cs
[JsonSerializable(typeof(WeatherForecast))]
[JsonSerializable(typeof(List<WeatherForecast>))]
public partial class AppJsonContext : JsonSerializerContext
{
}

The source generator produces the implementation at compile time. You can then use it explicitly:

Example.cs
var json = JsonSerializer.Serialize(forecast, AppJsonContext.Default.WeatherForecast);
var forecast = JsonSerializer.Deserialize(json, AppJsonContext.Default.WeatherForecast);

Or configure it as the default for ASP.NET Core:

Program.cs
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});

Two generation modes

The source generator supports two modes, controlled by the JsonSourceGenerationOptions attribute:

Metadata-based (default)

Generates type metadata but still uses the framework's serialization logic at runtime. This is compatible with most JsonSerializerOptions settings and custom converters.

Example.cs
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Metadata)]
[JsonSerializable(typeof(WeatherForecast))]
public partial class AppJsonContext : JsonSerializerContext
{
}

Serialization-optimised

Generates the actual Read and Write methods, bypassing the framework's serialization pipeline entirely. This yields the best performance but doesn't support all options at runtime.

Example.cs
[JsonSourceGenerationOptions(GenerationMode = JsonSourceGenerationMode.Serialization)]
[JsonSerializable(typeof(WeatherForecast))]
public partial class AppJsonContext : JsonSerializerContext
{
}

You can also use JsonSourceGenerationMode.Default, which generates both.

Configuring options at compile time

Rather than passing JsonSerializerOptions at runtime, bake them into the context:

Example.cs
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = true)]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(CustomerDto))]
public partial class AppJsonContext : JsonSerializerContext
{
}

These options are embedded in the generated code and incur no runtime cost.

Handling collections and nested types

You must register every type that appears in your serialization graph. If OrderDto contains a List<LineItem>, you need:

Example.cs
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<LineItem>))]
public partial class AppJsonContext : JsonSerializerContext
{
}

The generator will warn you about missing types in most cases, but generic collections sometimes slip through. A good practice is to add an integration test that serializes and deserialises your top-level DTOs — if a type is missing from the context, it will throw at runtime.

Combining multiple contexts

In larger applications, you might split contexts by domain area. From .NET 8, you can chain them using TypeInfoResolverChain:

Example.cs
var options = new JsonSerializerOptions
{
    TypeInfoResolverChain =
    {
        OrdersJsonContext.Default,
        CustomersJsonContext.Default,
        SharedJsonContext.Default
    }
};

This keeps each module's serialization concerns self-contained whilst allowing a single options instance at the application level.

Common pitfalls

Forgetting to mark the class as partial. The source generator needs to emit code into the same class. Without partial, compilation fails with cryptic errors.

Using unsupported features in serialization mode. Custom converters, JsonDocument, and JsonNode are not supported in the Serialization generation mode. Stick with Metadata mode if you need those.

Referencing types from other assemblies. The source generator can only see types visible to the compilation unit. If your DTOs live in a separate project, either define a context in that project or reference the types explicitly.

Not registering nullable types. If a property is string?, the generator handles it. But Nullable<int> in generic contexts may need explicit registration.

Is it worth it?

For AOT and trimmed applications, source generation is not optional — it's required. For standard applications, it still offers measurable performance improvements: reduced startup time (no reflection on first serialization call) and lower memory allocation. In benchmarks, source-generated serialization is typically 20-40% faster for small to medium payloads.

If you're already on .NET 8 or later and your DTOs are reasonably straightforward, the setup cost is minimal and the benefits are real.