[STJ Source Gen] Allow stacked JsonSerializerContexts
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
> [!NOTE]
> This proposal was drafted with the help of an AI agent. Please review for accuracy and remove this notice once you're satisfied with the content.
## Background and motivation
The System.Text.Json source generator performs a full transitive traversal of the type graph for every `[JsonSerializable]` type. When a type graph includes types from external assemblies that already have their own source-generated `JsonSerializerContext`, the generator still re-generates metadata for those external types. This causes:
- **Duplicated codegen**: The same type metadata is generated in every consuming assembly.
- **Binary size bloat**: Real-world applications report 17MB+ / 29% package size increases when 260 models expand to 742 generated types (see #77897).
- **Slower compilation**: The generator traverses and emits code for the entire transitive closure.
Today, users can manually chain contexts using `TypeInfoResolverChain` or `JsonTypeInfoResolver.Combine`, but these are **runtime-only** mechanisms — the source generator still generates all the code at compile time. There is no mechanism to tell the source generator "this assembly already has a canonical context — delegate to it instead of re-generating."
## API Proposal
```csharp
namespace System.Text.Json.Serialization;
[AttributeUsage(AttributeTargets.Assembly, AllowMultiple = false)]
public sealed class DefaultJsonSerializerContextAttribute : JsonAttribute
{
public DefaultJsonSerializerContextAttribute(Type contextType);
}
public partial class JsonSourceGenerationOptionsAttribute
{
///
/// When set to true, instructs the source generator to ignore any
/// DefaultJsonSerializerContextAttribute annotations on referenced assemblies
/// and traverse the full type graph as it would normally.
///
public bool ForceFullTypeTraversal { get; set; }
}
```
## API Usage
### Library author: designate a canonical context
```csharp
// In MyLibrary.csproj
using System.Text.Json.Serialization;
[assembly: DefaultJsonSerializerContext(typeof(MyLibrary.LibContext))]
namespace MyLibrary;
public class LibModel
{
public int Id { get; set; }
public string Name { get; set; }
public LibChild Child { get; set; }
}
public class LibChild
{
public string Value { get; set; }
}
[JsonSerializable(typeof(LibModel))]
public partial class LibContext : JsonSerializerContext { }
```
### App author: automatic delegation
```csharp
// In MyApp — references MyLibrary
using System.Text.Json.Serialization;
using MyLibrary;
namespace MyApp;
public class AppModel
{
public string Title { get; set; }
public LibModel Lib { get; set; } // From MyLibrary
}
[JsonSerializable(typeof(AppModel))]
internal partial class AppContext : JsonSerializerContext { }
```
The source generator for `AppContext` detects `[assembly: DefaultJsonSerializerContext]` on `MyLibrary` and:
1. **Skips BFS traversal** for `LibModel` and its transitive dependencies (`LibChild`, etc.) — these types are not parsed and their subtrees are not explored.
2. **Auto-chains `LibContext`** as a catch-all resolver at the end of the generated `IJsonTypeInfoResolver.GetTypeInfo`:
```csharp
// Generated code (simplified)
JsonTypeInfo? IJsonTypeInfoResolver.GetTypeInfo(Type type, JsonSerializerOptions options)
{
// Local types generated as usual
if (type == typeof(AppModel))
return Create_AppModel(options);
// Catch-all: chain to canonical contexts for types not generated locally.
// Handles LibModel, LibChild, and any other types the canonical context knows about.
{
JsonTypeInfo? typeInfo = ((IJsonTypeInfoResolver)LibContext.Default).GetTypeInfo(type, options);
if (typeInfo is not null)
{
return typeInfo;
}
}
return null;
}
```
No manual `TypeInfoResolverChain` configuration is needed — the delegation is automatic.
### Opt-out: ForceFullTypeTraversal
```csharp
// Force full traversal, ignoring any canonical context attributes
[JsonSourceGenerationOptions(ForceFullTypeTraversal = true)]
[JsonSerializable(typeof(AppModel))]
internal partial class AppContext : JsonSerializerContext { }
```
## Design Decisions
- **Assembly-level, `AllowMultiple = false`**: One canonical context per assembly. This is the simplest model and avoids ambiguity about which context "owns" which types. Libraries that need multiple contexts can still define them, but only one is the canonical delegate target.
- **Inherits `JsonAttribute`**: Follows the existing STJ attribute hierarchy pattern. The attribute lives in `Common/` so it's shared between the source generator and the runtime library.
- **Property-based coverage check**: The source generator determines which types are "covered" by a canonical context by scanning its `JsonTypeInfo` properties. A type is delegated only if the canonical context has a property returning `JsonTypeInfo` for that type. This is more precise than checking by assembly alone — the canonical context generates properties for its entire transitive closure, so all types it knows about are covered.
- **BFS traversal skip**: When a type is covered by a canonical context, the generator creates a minimal stub instead of parsing properties/constructors. This cuts off the BFS traversal for that type's subtree entirely, avoiding generation of metadata for transitive dependencies.
- **Catch-all resolver chain**: After all local `typeof` checks, the generated resolver chains to each canonical context as a catch-all via `((IJsonTypeInfoResolver)Context.Default).GetTypeInfo(type, options)`. This correctly forwards the consumer's `JsonSerializerOptions`, satisfying the `IJsonTypeInfoResolver` contract. The catch-all pattern handles both directly delegated types and their transitive dependencies (which were never discovered by BFS).
- **Fast-path disabled for types with delegated properties**: When an object type has properties whose types are delegated to an external context, the fast-path serializer is not generated for that object. This avoids referencing handlers that don't exist locally. The metadata-based path handles serialization correctly by resolving through the chained context. This is a conservative choice that can be refined in future work.
- **Default context must be public**: Because the generated code in the consuming assembly needs to reference `LibContext.Default` directly, the canonical context type must be `public`. If an `internal` context is specified, the source generator emits **SYSLIB1226** ("Default JSON serializer context is not accessible") and falls back to generating metadata locally as if the attribute weren't present. This is a deliberate constraint — a library that wants to enable cross-assembly delegation must expose its canonical context publicly.
- **Ambiguity for transitive types**: When multiple referenced assemblies define canonical contexts, a type defined in Assembly A is always delegated to Assembly A's canonical context (unambiguous). Ambiguity can only arise for transitive types that multiple canonical contexts happen to generate metadata for. In this case, the first canonical context that returns a non-null `JsonTypeInfo` wins. This is acceptable undefined behavior.
- **`ForceFullTypeTraversal` opt-out**: When set to `true`, the generator ignores all `[assembly: DefaultJsonSerializerContext]` attributes and traverses the full type graph as it does today. This provides a safety valve for consumers who encounter unexpected behavior or need full control.
## Alternative Designs
- **Per-type opt-in attribute**: An attribute like `[JsonDelegateToContext(typeof(LibContext))]` on each type would give finer control but adds boilerplate and doesn't solve the core problem — library authors want to say "this assembly has a canonical context" once, not annotate every type.
- **Convention-based discovery**: The generator could automatically discover contexts in referenced assemblies without an attribute. This was rejected because it creates ambiguity when an assembly has multiple contexts and provides no explicit opt-in signal.
- **`AllowMultiple = true`**: Supporting multiple canonical contexts per assembly was considered but rejected for simplicity. If a library has multiple contexts, the library author should consolidate into one canonical context or use resolver chains manually.
- **Per-type `typeof` delegation**: An earlier design generated individual `typeof` checks for each delegated type. This was replaced with catch-all chaining because: (1) it's simpler, (2) it naturally handles transitive dependencies not discovered by BFS, and (3) it avoids generating potentially large blocks of `typeof` checks for many delegated types.
- **Direct property access**: An earlier design delegated by calling `LibContext.Default.LibModel` directly. This violated the `IJsonTypeInfoResolver` contract because it returns a `JsonTypeInfo` bound to the canonical context's `JsonSerializerOptions`, not the caller's options. The `((IJsonTypeInfoResolver)Context.Default).GetTypeInfo(type, options)` pattern correctly forwards options.
## Risks
- **Options compatibility**: The generated delegation passes the consumer's `JsonSerializerOptions` to the canonical context's resolver. If the canonical context was designed for different options (e.g., different naming policy), the behavior may differ from what the library author expected. This is consistent with how manual `TypeInfoResolverChain` works today.
- **No source breaking changes**: The attribute is purely additive. Existing code that doesn't use the attribute is unaffected. The source generator's behavior only changes when it encounters the attribute on a referenced assembly.
- **Binary compat**: The attribute is a new public type. `ForceFullTypeTraversal` is a new property on an existing attribute. No existing types or members are modified.
## Open Questions
- Should the source generator emit a diagnostic warning when it encounters `[assembly: DefaultJsonSerializerContext]` with a `contextType` that doesn't derive from `JsonSerializerContext`?
- Should there be an opt-out mechanism on the consumer side for specific types? E.g., `[JsonSerializable(typeof(LibModel), ForceGenerate = true)]` to override delegation for a specific type.
- Should the attribute support `AllowMultiple = true` for assemblies with multiple domain-specific contexts?
## Related Issues
- #77897 — Package size explosion from source-generated JSON serialization (17MB/29% for 260 types)
- #108317 — External generated context class
- #63747 — Source generator improvements tracking
## Prototype
https://github.com/eiriktsarpalis/runtime/commit/42d52708f50
Contributor guide
Assessment
This issue has not been assessed yet.