Egil.SystemTextJson.Migration
2.0.47
See the version list below for details.
dotnet add package Egil.SystemTextJson.Migration --version 2.0.47
NuGet\Install-Package Egil.SystemTextJson.Migration -Version 2.0.47
<PackageReference Include="Egil.SystemTextJson.Migration" Version="2.0.47" />
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="2.0.47" />
<PackageReference Include="Egil.SystemTextJson.Migration" />
paket add Egil.SystemTextJson.Migration --version 2.0.47
#r "nuget: Egil.SystemTextJson.Migration, 2.0.47"
#:package Egil.SystemTextJson.Migration@2.0.47
#addin nuget:?package=Egil.SystemTextJson.Migration&version=2.0.47
#tool nuget:?package=Egil.SystemTextJson.Migration&version=2.0.47
Egil.SystemTextJson.Migration
Version-tolerant JSON migration for System.Text.Json.
When data models evolve, old JSON payloads still exist — in databases, caches, queues, and on disk. This library migrates those payloads to the current type automatically during deserialization, so application code never deals with obsolete shapes.
Key characteristics:
- Little to no overhead for normal-sized payloads — the medium source-generated happy-path profile benchmarks close to plain
System.Text.Jsonthroughput with zero extra library allocations. - O(1) discriminator check — only the first JSON property is inspected to determine the payload version.
- Source-generation friendly — works with source-generated
JsonSerializerContextand registers through the resolver chain, so types outside migration keep STJ's generated fast-path serializers. Trimmed/NativeAOT publishing is not supported yet; see the AOT recipe. - C# unions (.NET 11) — a
unionof[JsonMigratable]cases is classified by migration discriminator, so old and current payloads route to the case that migrates them. See the polymorphism & unions recipe. - Two migration styles — static (target-owned) via
IMigrateFrom<TSource, TTarget>, or external (separate class) viaIMigrate<TSource, TTarget>with optional dependency injection. - Nested migration — migratable child types inside migratable parents are migrated recursively.
- Migration tracking — types can implement
IJsonMigrationTrackedto know whether they were migrated. - Configurable failure handling — choose between throwing, falling back to the target type, or returning null when a migrator cannot convert a payload.
Upgrading from 1.x? No API changed; the behavior changes and the checks to run are in Upgrading to 2.0.
📖 Looking for more? See the Recipes for 39 scenario-driven guides covering nested objects, collections, DI, source generation, failure handling, ASP.NET Core, Orleans, telemetry, and more.
Examples
The examples below use these shared types as a running scenario — a User type whose schema has changed between versions:
// The old shape. Marked [JsonMigratable] so the library writes
// a type discriminator during serialization and recognizes it
// during deserialization.
[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV1(string Name, int Age);
// The current shape.
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age);
Choosing a migration contract
Use the contract that matches where the migration logic lives:
| Scenario | Interface | Registration |
|---|---|---|
The current [JsonMigratable] target type owns the migration logic |
IMigrateFrom<TSource, TTarget> |
No RegisterMigrator* call. The target type's contracts are discovered automatically when its converter is created. |
| A separate external class owns the migration logic | IMigrate<TSource, TTarget> |
Register the migrator with RegisterMigrator* or RegisterMigratorsFrom*. |
Do not implement IMigrate<TSource, TTarget> directly on a [JsonMigratable] type. That interface is reserved for separate external migrator classes.
Static migration
When the migration logic naturally belongs on the target type, implement IMigrateFrom directly. These target-owned migrations are discovered automatically; no RegisterMigrator* or RegisterMigratorsFrom* call is needed:
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
: IMigrateFrom<UserV1, UserV2>
{
public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
{
var names = source.Name.Split(' ');
result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
return true;
}
}
Enable migration support on the serializer options and deserialize as usual:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
// A UserV1 payload is automatically migrated to UserV2:
var json = """{"$type":"user-v1","name":"Jane Doe","age":30}""";
UserV2 user = JsonSerializer.Deserialize<UserV2>(json, options)!;
// user is UserV2 { FirstName = "Jane", LastName = "Doe", Age = 30 }
Brownfield adoption
Existing projects can adopt migration support one type at a time:
- No shape change needed: add
[JsonMigratable]to the current type and enableAddJsonMigrationSupport(). Existing discriminator-less object payloads that already match the current type continue to deserialize as that type; new writes include$type, so future reads use the normal happy path. - Existing payloads need migration: if stored JSON was written before
[JsonMigratable]existed and represents an older object shape, setUndiscriminatedSourceTypeon the current type and provide a static or external migrator from that source type. The library then treats discriminator-less object payloads as that source shape, while discriminator-bearing payloads still use normal version matching.
When stored JSON represents an older source shape, configure the target with UndiscriminatedSourceType:
[JsonMigratable(
TypeDiscriminator = "customer-name-v1",
UndiscriminatedSourceType = typeof(CustomerNameV0))]
public record class CustomerNameV1(string Name)
: IMigrateFrom<CustomerNameV0, CustomerNameV1>
{
public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result)
{
result = new CustomerNameV1($"{source.FirstName} {source.LastName}");
return true;
}
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
// Existing stored JSON was written before migration support existed,
// so it has no $type discriminator. CustomerNameV1 opts in to treating
// discriminator-less objects as CustomerNameV0 and runs its migrator.
var json = """{"firstName":"Jane","lastName":"Doe"}""";
CustomerNameV1 customer = JsonSerializer.Deserialize<CustomerNameV1>(json, options)!;
// customer is CustomerNameV1 { Name = "Jane Doe" }
UndiscriminatedSourceType is intentionally one source type per target. If multiple historical object shapes exist without discriminators, choose the one that represents the stored brownfield payloads you need to migrate.
External migration
When migration logic should live in its own separate class — for separation of concerns, testability, dependency injection, or because you don't control the target type — implement IMigrate and register it:
public class UserMigrator : IMigrate<UserV1, UserV2>
{
public bool TryMigrateFrom(UserV1 source, out UserV2 result)
{
var names = source.Name.Split(' ');
result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
return true;
}
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
builder.RegisterMigrator<UserMigrator>();
});
Multi-step migration chains
A target type can accept payloads from multiple older versions. Each source version has its own migration path:
[JsonMigratable(TypeDiscriminator = "user-v0")]
public record UserV0(string FullName);
[JsonMigratable(TypeDiscriminator = "user-v1")]
public record UserV1(string Name, int Age);
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
: IMigrateFrom<UserV0, UserV2>,
IMigrateFrom<UserV1, UserV2>
{
public static bool TryMigrateFrom(UserV0 source, out UserV2 result)
{
var names = source.FullName.Split(' ');
result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", 0);
return true;
}
public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
{
var names = source.Name.Split(' ');
result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
return true;
}
}
Migrating from non-object JSON payloads
When the stored JSON is not an object — for example, a plain array or a primitive — the library can migrate it to a structured target type. The source type does not need [JsonMigratable]:
// The target type accepts a List<string> as its source.
// The source type (List<string>) is NOT marked with [JsonMigratable]
// — it's a plain .NET collection whose JSON representation is an array.
[JsonMigratable(TypeDiscriminator = "settings-v2")]
public record SettingsV2(List<string> Tags, string Label)
: IMigrateFrom<List<string>, SettingsV2>
{
public static bool TryMigrateFrom(List<string> source, out SettingsV2 result)
{
result = new SettingsV2(source, "migrated");
return true;
}
}
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
// Stored JSON is a plain array — no $type, no object wrapper.
var json = """["csharp","dotnet","azure"]""";
SettingsV2 settings = JsonSerializer.Deserialize<SettingsV2>(json, options)!;
// settings.Tags = ["csharp", "dotnet", "azure"], settings.Label = "migrated"
After migration, the target type serializes as an object with $type, so future reads take the zero-allocation happy path. See the recipe for more examples including primitives and mixed migrators.
Dependency injection for migrators
Pass an IServiceProvider so external migrators can receive constructor-injected dependencies. The migrator is resolved from the service provider on each call, supporting scoped lifetimes:
var services = new ServiceCollection();
services.AddScoped<UserMigrator>();
using var serviceProvider = services.BuildServiceProvider();
// When building serializer options, pass the service provider:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(serviceProvider, builder =>
{
builder.RegisterMigrator<UserMigrator>();
});
If no service provider is configured, the library falls back to creating the migrator via its parameterless constructor.
Assembly scanning
Register external IMigrate<,> migrator classes in one or more assemblies instead of listing each one. Assembly scanning is not required for target-owned IMigrateFrom<,> migrations:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
builder.RegisterMigratorsFromAssemblies(typeof(UserMigrator).Assembly);
});
Source-generated JsonSerializerContext
To avoid System.Text.Json's reflection-based metadata, register both old and current types in a source-generated context (this does not make trimmed or NativeAOT publishing supported; see the AOT recipe):
[JsonSerializable(typeof(UserV1))]
[JsonSerializable(typeof(UserV2))]
public partial class AppJsonContext : JsonSerializerContext;
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
options.TypeInfoResolverChain.Add(AppJsonContext.Default);
Migration tracking
Implement IJsonMigrationTracked on your type to detect at runtime whether a particular instance was migrated during deserialization. This is useful for deciding whether to write the value back in its updated form:
[JsonMigratable(TypeDiscriminator = "user-v2")]
public record UserV2(string FirstName, string LastName, int Age)
: IJsonMigrationTracked, IMigrateFrom<UserV1, UserV2>
{
[JsonIgnore]
public bool MigratedDuringDeserialization { get; set; }
public static bool TryMigrateFrom(UserV1 source, out UserV2 result)
{
var names = source.Name.Split(' ');
result = new UserV2(names[0], names.ElementAtOrDefault(1) ?? "", source.Age);
return true;
}
}
// After deserialization:
var json = """{"$type":"user-v1","name":"Jane Doe","age":30}""";
UserV2 user = JsonSerializer.Deserialize<UserV2>(json, options)!;
if (user.MigratedDuringDeserialization)
{
// Persist the updated representation so future reads
// hit the happy path.
// await SaveAsync(user);
}
Custom type discriminator
By default the library uses "$type" as the discriminator property name and the type's full name as its value. Both can be customized:
// Per-type via the attribute:
[JsonMigratable(
TypeDiscriminator = "user-v2",
TypeDiscriminatorPropertyName = "version")]
public record UserV2(string FirstName, string LastName, int Age);
// Or set a global default property name via the builder:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
builder.SetTypeDiscriminatorPropertyName("_schema");
});
You can also derive the discriminator value from an existing attribute on your types, keeping the library out of your domain model:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
builder.GetTypeDiscriminatorFrom<SchemaVersionAttribute>(
attr => attr.Version);
});
Failure handling
Control what happens when a migrator's TryMigrateFrom returns false:
| Policy | Behavior |
|---|---|
ThrowJsonException |
Throw a JsonException (default). |
FallBackToTargetType |
Deserialize the payload directly as the target type. |
ReturnNull |
Return null (only valid for nullable target types). |
Set a global policy on the builder, or override per-type on the attribute:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
builder.SetMigrationFailureHandling(
JsonMigrationFailureHandling.FallBackToTargetType);
});
// Per-type override:
[JsonMigratable(MigrationFailureHandling = JsonMigrationFailureHandling.ReturnNull)]
public record OptionalData(string Value);
Observability
The library emits an OpenTelemetry-compatible counter (stjm.migrations) via System.Diagnostics.Metrics. Each migration attempt records the source type, target type, and status (success / failure).
Subscribe to the meter in a console or test app:
// Subscribe to the migration meter using MeterListener:
using var meterListener = new MeterListener();
var migrationCount = 0L;
meterListener.InstrumentPublished = (instrument, listener) =>
{
if (instrument.Meter.Name == JsonMigrationTelemetry.MeterName)
{
listener.EnableMeasurementEvents(instrument);
}
};
meterListener.SetMeasurementEventCallback<long>(
(instrument, measurement, tags, state) =>
{
if (instrument.Name == JsonMigrationTelemetry.MigrationCounterName)
{
Interlocked.Add(ref migrationCount, measurement);
}
});
meterListener.Start();
In ASP.NET Core, register the meter with OpenTelemetry:
builder.Services.AddOpenTelemetry()
.WithMetrics(metrics =>
{
metrics.AddMeter(JsonMigrationTelemetry.MeterName);
});
Performance
Every benchmark compares the library against hand-written migration code on top of plain System.Text.Json. The small profile is a minimal { "name": "...", "age": ... } object that highlights worst-case fixed overhead; the medium profile is a best-guess average object with about 12 object members; and the large profile has about 96 object members spread across nested objects, arrays, and dictionary entries. See benchmark payload examples for representative JSON from each profile.
The generated table below is refreshed by ./scripts/update-perf-docs.ps1 from the latest source-generated BenchmarkDotNet report, produced with the net11.0 build of the benchmarks (the union dispatch scenario only exists there; every other scenario also runs on net10.0). It keeps BenchmarkDotNet's Ratio, RatioSD, and Alloc Ratio columns so README numbers stay tied to the raw benchmark output.
| Scenario | Method | Payload size | Mean | Ratio | RatioSD | Allocated | Alloc Ratio |
|---|---|---|---|---|---|---|---|
| No migration (happy path) | Plain STJ | Small | 251.02 ns | 1.00 | 0.01 | 160 B | 1.00 |
| JsonMigratable | Small | 383.56 ns | 1.53 | 0.01 | 160 B | 1.00 | |
| Plain STJ | Medium | 1,865.72 ns | 1.00 | 0.00 | 1656 B | 1.00 | |
| JsonMigratable | Medium | 2,025.74 ns | 1.09 | 0.01 | 1656 B | 1.00 | |
| Plain STJ | Large | 14,357.30 ns | 1.00 | 0.01 | 24624 B | 1.00 | |
| JsonMigratable | Large | 14,519.17 ns | 1.01 | 0.01 | 24624 B | 1.00 | |
| Static migration | Manual STJ migration | Small | 315.56 ns | 1.00 | 0.00 | 312 B | 1.00 |
| JsonMigratable | Small | 554.32 ns | 1.76 | 0.00 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 2,003.50 ns | 1.00 | 0.05 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,228.92 ns | 1.11 | 0.04 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 14,584.96 ns | 1.00 | 0.01 | 24776 B | 1.00 | |
| JsonMigratable | Large | 14,826.24 ns | 1.02 | 0.01 | 24776 B | 1.00 | |
| External migration | Manual STJ migration | Small | 319.04 ns | 1.00 | 0.00 | 312 B | 1.00 |
| JsonMigratable | Small | 562.98 ns | 1.76 | 0.05 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 1,947.25 ns | 1.00 | 0.01 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,181.95 ns | 1.12 | 0.02 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 14,055.56 ns | 1.00 | 0.01 | 24776 B | 1.00 | |
| JsonMigratable | Large | 14,761.47 ns | 1.05 | 0.01 | 24776 B | 1.00 | |
| Undiscriminated source migration | Manual STJ migration | Small | 318.95 ns | 1.00 | 0.00 | 312 B | 1.00 |
| JsonMigratable | Small | 452.95 ns | 1.42 | 0.00 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 1,960.61 ns | 1.00 | 0.01 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,281.10 ns | 1.16 | 0.01 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 14,450.53 ns | 1.00 | 0.00 | 24776 B | 1.00 | |
| JsonMigratable | Large | 14,664.90 ns | 1.01 | 0.01 | 24776 B | 1.00 | |
| Legacy payload | Plain STJ + tracking | Small | 324.82 ns | 1.00 | 0.00 | 192 B | 1.00 |
| JsonMigratable | Small | 431.97 ns | 1.33 | 0.00 | 192 B | 1.00 | |
| Plain STJ + tracking | Medium | 1,922.71 ns | 1.00 | 0.01 | 1688 B | 1.00 | |
| JsonMigratable | Medium | 2,091.69 ns | 1.09 | 0.01 | 1688 B | 1.00 | |
| Plain STJ + tracking | Large | 14,393.11 ns | 1.00 | 0.01 | 24656 B | 1.00 | |
| JsonMigratable | Large | 14,673.94 ns | 1.02 | 0.01 | 24656 B | 1.00 | |
| Union dispatch (.NET 11) | Plain STJ structural classifier | Small | 621.03 ns | 1.00 | 0.00 | 632 B | 1.00 |
| JsonMigratable classifier | Small | 572.33 ns | 0.92 | 0.00 | 160 B | 0.25 | |
| JsonMigratable classifier + migration | Small | 763.34 ns | 1.23 | 0.01 | 312 B | 0.49 | |
| Plain STJ structural classifier | Medium | 3,178.05 ns | 1.00 | 0.00 | 1656 B | 1.00 | |
| JsonMigratable classifier | Medium | 2,681.66 ns | 0.84 | 0.00 | 1656 B | 1.00 | |
| JsonMigratable classifier + migration | Medium | 2,967.83 ns | 0.93 | 0.01 | 1808 B | 1.09 | |
| Plain STJ structural classifier | Large | 22,504.99 ns | 1.00 | 0.01 | 25544 B | 1.00 | |
| JsonMigratable classifier | Large | 18,793.45 ns | 0.84 | 0.01 | 24624 B | 0.96 | |
| JsonMigratable classifier + migration | Large | 19,124.73 ns | 0.85 | 0.01 | 24776 B | 0.97 | |
| Serialization | Plain STJ | Small | 84.97 ns | 1.00 | 0.01 | 56 B | 1.00 |
| JsonMigratable | Small | 168.78 ns | 1.99 | 0.01 | 88 B | 1.57 | |
| Plain STJ | Medium | 504.53 ns | 1.00 | 0.00 | 416 B | 1.00 | |
| JsonMigratable | Medium | 692.47 ns | 1.37 | 0.00 | 752 B | 1.81 | |
| Plain STJ | Large | 4,800.09 ns | 1.00 | 0.00 | 10384 B | 1.00 | |
| JsonMigratable | Large | 5,110.45 ns | 1.06 | 0.00 | 10728 B | 1.03 |
Full benchmark reports: source-gen · reflection
Run benchmarks locally with
dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release --framework net11.0. Refresh these docs from the latest BenchmarkDotNet output with./scripts/update-perf-docs.ps1.
Design notes
First-property discriminator check. The converter inspects only the first JSON property for the type discriminator, keeping detection O(1) and allocation-free. The library serializes
$typewithOrder = int.MinValueso round-tripped payloads always have it first. If external JSON has the discriminator in a non-first position, the payload is treated as a legacy payload.Static migrators take precedence. When both a static
IMigrateFromand an externalIMigrateexist for the same source type, the static contract wins.Short discriminators recommended. Values like
"user-v2"are smaller and faster to compare than the default full type name.Non-object payload migration. When the JSON payload is not an object (e.g., an array or primitive), discriminator-based matching is not possible. Collections are matched by contract kind (
StartArray→JsonTypeInfoKind.Enumerable); scalars are matched by the token family their built-in converter reads: a JSON number matches theTypeCodenumerics (and enums),Half,Int128,UInt128, and on .NET 11BFloat16/Decimal32/Decimal64/Decimal128; a JSON string matchesstring,char,DateTime,DateTimeOffset,DateOnly,TimeOnly,TimeSpan,Guid,Uri,Version,byte[],Memory<byte>andReadOnlyMemory<byte>;true/falsematchbool.Nullable<T>sources are matched by their underlying type; scalar types that need a custom converter are not matched. Dictionary source types (Dictionary<string, T>) are also supported — when no discriminator match is found on a non-empty JSON object, the library checks forJsonTypeInfoKind.Dictionarymigrators before falling back to legacy handling; an empty object{}takes the legacy path. A configuredUndiscriminatedSourceTypetakes precedence over both and receives every object without a recognized discriminator. This adds zero overhead to the existing object-based happy path.
Mutation testing
dotnet tool restore
dotnet stryker --config-file stryker-config.json -t mtp
Reports are written under StrykerOutput/.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. net11.0 is compatible. |
-
net10.0
- No dependencies.
-
net11.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
New Features:
- support .NET 11 with union classification and full numeric source matching
Multi-targets net10.0 and net11.0 (the .NET 11 asset is built with the RC1 SDK
under a go-live licence).
On .NET 11, AddJsonMigrationSupport() registers a JsonTypeClassifierFactory so
a C# union whose cases are [JsonMigratable] types is classified by migration
discriminator: current and old payloads route to the case that migrates them,
nested unions forward their discriminators, and any situation that cannot be
resolved safely throws a JsonException listing the known discriminators.
Source-generated contexts opt in with
[JsonUnion(TypeClassifier = typeof(JsonMigratableUnionTypeClassifier))].
Non-object payload matching now recognises every numeric type System.Text.Json
can read (Half, Int128, UInt128 and, on .NET 11, BFloat16 and Decimal32/64/128),
honours JsonNumberHandling for quoted numbers and named floating-point literals,
matches byte[] and other string-shaped sources, disambiguates collection sources
by element discriminator and contract kind, and never shape-matches a source
read through a converter override.
[JsonMigratable] on a union, collection or dictionary now fails with
JsonMigratableTargetKindNotSupportedException pointing at the recipe instead of
an opaque STJ error.
Documentation covers .NET 11 unions, the unchanged [JsonPolymorphic] limitation,
an NDJSON read-migrate-write-back recipe, and states that trimmed/NativeAOT
publishing is not supported yet.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Bug Fixes:
- match a quoted number to 1.x's numeric sources before 2.0's
The quoted-number tier considered every numeric source at once, so a
target migrating from int and Half threw on "42" as ambiguous although
a plain 42 goes to int. The tier is now split, legacy numeric types
first and then the ones 2.0 added, for top-level payloads and for the
first element of a collection alike.
- name every source of a union case's converter override
Picking the converter branch whenever options.Converters held a
matching converter misattributed a resolver-supplied override when a
converter had been added after registration too. Which source served
the contract is not recorded on it, so the diagnostic now names all
three with the fix for each.
- name the actual source of a union case's converter override
A union case served by a converter other than migration's was always
told to register its converters after AddJsonMigrationSupport(), also
when the override came from a resolver ahead of the migration entry or
from an attribute, where no converter registration is involved. The
diagnostic now distinguishes a converter in options.Converters from a
resolver or attribute override and gives the fix for each.
- keep the building registry for a union configured inside a converter build
The union classifier re-rooted the options' scope to the resolver that
served the union's first case. Inside a converter build that scope is
the building registry, and the case being built has no migration
converter to ask, so it fell back to whichever registry served the
first case; a nested case then got that registry's discriminator and
migrators. Since each migratable case now reads its registry from its
own converter, the scope is kept as is, and the first case's resolver
stands in only when the options carry no registration at all.
- stop inferring a shared registration from the shape of a registered chain
A resolver at the front of a registered chain that no longer shows its
migration resolver looks the same whether it wraps that resolver or was
left behind when the resolver was removed, so independently created
options reusing it inherited a registration they never had and their
own registration call ran no configuration.
Registration carries over to a copy only by identity, through the chain
object STJ's copy constructor shares with it. A copy that changed its
own chain behind a wrapper registers like any other options, with the
configuration it passes.
- never call a user's resolver during registration
Discovery probed application-defined resolvers with a marker type, so
the first registration on options whose resolver wraps a
DefaultJsonTypeInfoResolver resolved through it and froze its
Modifiers, the side effect registration promises not to have.
The probe has no case left to serve: a copy finds its registration
through the chain object it shares with the original or the wrapper at
the front of that chain, a cached registration is kept while an
application-defined resolver is in the chain, and union routing reads
each case's registry from the converter the case resolves to. It is
removed; discovery walks STJ's chains and decorators and calls nothing.
- infer a shared registration only from the entry hiding its resolver
Matching any application-defined resolver shared with a registered
chain made independently created options that reuse a downstream
resolver inherit that chain's registration: their own registration
call returned without inserting a resolver or running its
configuration.
Registration puts the migration resolver at the front of the chain, so
the inference now requires the registered chain to no longer show its
resolver and the shared instance to be the application-defined entry
at its front, the one that took the resolver's place. A downstream
resolver shared with a registered chain says nothing.
- forward nested union routes from the registry serving the nested case
The outer union forwarded a nested union's case discriminators and
sources from its own registry, while a type-selective resolver can
serve the nested case through another migration resolver whose
registry holds the external migrators for it. Old nested payloads then
went unclassified although the inner union could migrate them.
A nested migratable case now forwards its routes from the registry that
built its converter, as a direct case does.
- find a copy's registration through shared chain entries and route union cases per registry
A copy that extended its own chain gets a chain object of its own, so
the chain the original registered on no longer identified it; behind a
type-selective wrapper neither the walk nor the probe could recover the
registration and a fresh registry went in front. The copy's chain is
built from the entries of the chain it was copied from, so an
application-defined resolver it shares with a registered chain now
identifies that registration.
Union routing used one registry for every case, the first served case's,
while a type-selective wrapper can send cases to different migration
resolvers. Each case now routes with the registry that built its
converter; a case inside its own migration build keeps the scope's.
- route unions with the registry that serves their cases
The union classifier routed with whatever registration was cached for
the options. A registration the options no longer use can stay cached
behind an application-defined resolver that answers no probe, and a
wrapper can send the cases to a resolver registered on other options;
in both cases the routes came from the wrong registry.
The classifier now reads the resolver from the migration converter a
case resolves to and routes with its registry, using the cached lookup
only for the exclusions of the options being resolved. A case that
resolves to its plain contract without a converter override is
reported as missing migration support rather than as a converter
ordering problem.
- treat a silent resolver probe as unknown, not as removal
A resolver that forwards only its own application's types never sees
the probe's marker, so its silence said nothing, yet the guard read it
as the registration having been removed and put a fresh registry in
front of it, losing the explicitly registered migrators.
A positive probe remains proof. A silent application-defined resolver
now keeps the cached registration, as the walk cannot tell it from a
wrapper. Copies of registered options, which carry no cached
registration, find the original's through the chain object STJ's copy
constructor shares with them until they change their own chain.
- probe application-defined resolvers for the migration entry they wrap
A copy of options whose migration entry sits behind an application-
defined wrapper carries no cached registration, and the chain walk
cannot see through the wrapper, so registering again on the copy put a
fresh registry in front of it and the union classifier reported missing
migration support.
Registration discovery now asks each application-defined resolver in
the chain for the contract of a marker type that only the migration
resolver answers, and takes the resolver stamped on the answer as the
one behind it. That replaces the assumption that any such resolver was
wrapping the entry: replacing the chain with one's own resolver and
registering again now registers again, as with STJ's resolvers.
- leave a wrapped migration entry in place and route unions through it
Restoring a hidden registration by inserting its resolver at the front
of the chain took the migratable types away from the application-defined
wrapper that had been consulted first; the wrapper's own contracts for
those types no longer applied. The guard now leaves the chain as the
user shaped it: an application-defined resolver in the chain is assumed
to be wrapping the registered entry, and a chain made of STJ's own
resolvers is the only shape that counts as having removed it.
The union classifier used the strict structural check and reported
missing migration support behind such a wrapper; it now applies the same
rule and routes with the registration cached for the options.
- keep the first registration when a wrapper hides its resolver
The registration guard cannot see through an application-defined
wrapper, so calling AddJsonMigrationSupport() again after wrapping the
chain entry inserted a fresh resolver with an empty registry, and the
migrators the first call registered were no longer consulted.
The cached registration is now kept when the chain holds a resolver the
structural walk cannot see through, and its resolver is put back in
front where a later call finds it. Replacing the chain with STJ's own
resolvers still counts as removal and registers again.
- honour exclusion scopes from any active migration resolver
Registering migration again after an application-defined wrapper hid
the first resolver adds a second one, and both stay active. Each
resolver then rejected the exclusion scope the other had registered for
a clone, so neither stepped aside for the type being built and the
converter build recursed until the stack overflowed.
A scope's exclusions and root options describe the options instance,
not the resolver that registered it, so a resolver now honours whatever
scope it finds; when it builds a converter under a scope another
resolver registered it re-roots the clone's scope to itself, so the
discriminator metadata comes from the registry that builds the
converter. The registration guard keeps its structural check for
replaced or cleared resolvers.
- apply entry decorators to migratable plain contracts and refuse stale union routing
When the migration entry stands in for the default resolver and is
decorated with a modifier, the plain contract of a migratable type was
produced by the exclusion clone's wrapper, outside the decorator, so the
modifier applied to every type except the migratable ones. The migration
resolver now returns the reflection contract itself for the type being
built, so it travels back through the decorator like any other contract.
The .NET 11 union classifier read the cached registration without
checking that its resolver was still reachable, so after the resolver
chain was cleared or replaced it built routing against a registry the
options no longer used. It now uses a reachability-checked lookup that
never removes entries and reports missing migration support instead.
- keep the exclusion scope while resolving through an opaque resolver wrapper
Structural validation of a cached scope cannot see the migration
resolver behind an application-defined wrapper, so while a migratable
type's converter was being built through such a wrapper the resolver
dropped the exclusion clone's own scope, no longer stepped aside for the
type, and started building the same converter again without end.
The executing resolver now authenticates a scope by identity: a scope
whose resolver is the one being called is valid however the chain is
wrapped. Structural validation, with removal of a stale entry, is done
only by the registration guard in AddJsonMigrationSupport(), the one
place that must detect a resolver that was replaced or cleared, and it
never runs during a resolution. With an opaque wrapper and no downstream
resolver the failure is STJ's own missing-metadata error rather than a
stack overflow.
- match the scalar sources 1.x knew before the ones 2.0 added
A target with both a string and a Guid source received a JSON string
from the string source in 1.x, because Guid was not a scalar source
then. 2.0 classified both as string-shaped and reported the payload as
ambiguous, so a stored payload stopped migrating. Scalar matching now
runs in tiers: the types 1.x matched by TypeCode first (plain sources
ahead of ones behind a converter override), then the types 2.0 added,
then quoted numbers, then overridden sources of the newer types. The
same order applies to the first element when collection sources
compete. A source 1.x selected is therefore selected again, and a 2.0
source only where 1.x had no match; two sources in one tier are still
ambiguous.
- discover migration support through the chain, not by resolver identity
The idempotency guard in AddJsonMigrationSupport() and the union
classifier's registry lookup both scanned TypeInfoResolverChain for the
migration resolver by type. A decorated entry (WithAddedModifier or an
application wrapper) hid it, so a second AddJsonMigrationSupport() call
inserted a fresh registry ahead of the decorated one and dropped the
configured external migrators, and a copy of such options (which carries
no registered scope) made the .NET 11 classifier report that migration
support was missing.
Discovery now also asks the chain for a private marker type that only
the migration resolver answers; decorators forward the request, and the
answer carries the resolver. Calling the resolver directly rather than
options.GetTypeInfo leaves mutable options mutable. The Stryker mutate
list names the renamed resolver files.
- match overridden sources by their CLR shape as a last resort, as 1.x did
The enum-only fallback added earlier left other sources with a converter
override behind: an int read through a custom JsonConverter<int> was
matched by TypeCode in 1.x, and 2.0 rejected its stored numeric payloads.
Every source with a converter override now keeps the token family of its
CLR type as a last-resort match, considered only after all other sources,
so 1.x-readable payloads stay readable while a plain source of the same
shape still wins.
- decide migration precedence from the options in use, not chain identity
The migration resolver located itself in TypeInfoResolverChain by
reference when building a type's converter, so any decorator around
that entry (WithAddedModifier, or an application wrapper) made the
lookup fail with ArgumentOutOfRangeException on the first migratable
type. The exclusion clone now wraps whatever resolver it inherited and a
per-options scope tells the migration resolver when to step aside for
the type being built; the union classifier finds the registry through
the same scope. Decorated entries work, and a decorator's modifiers
keep applying to everything the chain returns.
A converter registered before AddJsonMigrationSupport() also kept its
precedence in copies of the options that no longer contained it,
leaving the type with a plain contract and no $type. Precedence is now
checked against the converters of the options being resolved.
- keep numeric payloads reaching an enum source that has a string converter
1.x routed a JSON number to an enum source by TypeCode even when
JsonStringEnumConverter was registered, and that converter reads integers
by default. The 2.0 rule that excludes sources with a converter override
from shape matching therefore rejected stored numeric payloads whose only
source is such an enum. The number is now offered to an overridden enum
source when no other source matched, so those payloads migrate as they
did in 1.x while a plain numeric source still wins over the enum.
- make AddJsonMigrationSupport() idempotent
Calling AddJsonMigrationSupport() twice on the same options put two
migration resolvers in the resolver chain. The reflection fallback only
stands in while the migration resolver is the sole chain entry, so a
second call made options without any other resolver fail to serialize
plain types. A second call now keeps the first registration, which is
what the previous converter-factory registration did in effect because
System.Text.Json only consulted the first matching converter.
Performance:
- keep STJ fast-path serialization by registering through the resolver chain
AddJsonMigrationSupport() no longer adds a converter factory to
options.Converters. System.Text.Json only uses a JsonSerializerContext's
generated fast-path serializers when that list is empty, so one entry
switched every type in the options to the metadata path, including
types that never take part in migration. Migration is now a resolver
inserted at the front of TypeInfoResolverChain. Types outside migration
keep their generated serializer; a [JsonMigratable] type itself, and
any type whose properties reach one, still serializes through the
metadata path because its contract carries the injected discriminator.
Precedence is unchanged for converters: one registered in
options.Converters before AddJsonMigrationSupport() still wins for a
[JsonMigratable] type, one registered after still does not. Options
without any resolver keep reflection-based serialization.
Two setups behave differently. A custom IJsonTypeInfoResolver that
serves a [JsonMigratable] type with its own converter used to win
silently because it never consulted options.Converters; it now has to
be inserted ahead of migration with TypeInfoResolverChain.Insert(0, ...)
after AddJsonMigrationSupport(), otherwise migration owns the type and
rejects the non-object contract. Clearing TypeInfoResolverChain or
assigning TypeInfoResolver after AddJsonMigrationSupport() now removes
migration support, since that is where it lives.