Egil.SystemTextJson.Migration 2.0.76

There is a newer version of this package available.
See the version list below for details.
dotnet add package Egil.SystemTextJson.Migration --version 2.0.76
                    
NuGet\Install-Package Egil.SystemTextJson.Migration -Version 2.0.76
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Egil.SystemTextJson.Migration" Version="2.0.76" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="2.0.76" />
                    
Directory.Packages.props
<PackageReference Include="Egil.SystemTextJson.Migration" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Egil.SystemTextJson.Migration --version 2.0.76
                    
#r "nuget: Egil.SystemTextJson.Migration, 2.0.76"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Egil.SystemTextJson.Migration@2.0.76
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Egil.SystemTextJson.Migration&version=2.0.76
                    
Install as a Cake Addin
#tool nuget:?package=Egil.SystemTextJson.Migration&version=2.0.76
                    
Install as a Cake Tool

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.Json throughput 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 JsonSerializerContext and 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 union of [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) via IMigrate<TSource, TTarget> with optional dependency injection.
  • Nested migration — migratable child types inside migratable parents are migrated recursively.
  • Migration tracking — types can implement IJsonMigrationTracked to 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 enable AddJsonMigrationSupport(). 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, set UndiscriminatedSourceType on 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 $type with Order = int.MinValue so 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 IMigrateFrom and an external IMigrate exist 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 the TypeCode numerics (and enums), Half, Int128, UInt128, and on .NET 11 BFloat16/Decimal32/Decimal64/Decimal128; a JSON string matches string, char, DateTime, DateTimeOffset, DateOnly, TimeOnly, TimeSpan, Guid, Uri, Version, byte[], Memory<byte> and ReadOnlyMemory<byte>; true/false match bool. 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 for JsonTypeInfoKind.Dictionary migrators before falling back to legacy handling; an empty object {} takes the legacy path. A configured UndiscriminatedSourceType takes 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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.

Version Downloads Last Updated
2.2.2 0 9/24/2026
2.0.76 0 9/24/2026
2.0.47 33 9/23/2026
1.7.4 1,522 6/11/2026
1.6.4 687 5/12/2026
1.5.3 318 5/6/2026
1.4.3 112 5/5/2026
1.3.7 120 5/5/2026
1.2.6 193 4/14/2026
1.1.3 126 4/13/2026
1.0.2 126 4/11/2026
0.4.2 115 4/11/2026
0.3.4 110 4/11/2026
0.2.3 114 4/11/2026

New Features:
- detect ambiguous non-object migration sources (#254)
 The analyzer now warns when static migration sources for one target share a primitive or collection JSON shape that cannot carry a discriminator. This exposes ambiguous historical payloads before deserialization depends on registration order.
- detect missing source metadata in JSON contexts (#253)
 Source-generated contexts now warn when a reachable migratable target lacks metadata for an IMigrateFrom source type it needs. The analyzer follows inherited and included members and collection elements, respects ignored members, and reports each missing target/source pair once per context. This identifies incomplete metadata before historical payloads fail to deserialize without requiring redundant explicit registrations.
 Converter-controlled and unresolved generic contracts are treated conservatively because their metadata requirements cannot be inferred from CLR members. Ordinary targets in the same context remain checked.
- name missing migration union classifiers (#252)
 Source-generated contexts now warn when a union with a migratable case lacks JsonMigratableUnionTypeClassifier. The warning names the required JsonUnion configuration so it complements SYSLIB1227.
- detect duplicate migration source discriminators (#250)
 The analyzer now warns when two source types for one target resolve to the same discriminator key. It distinguishes independent targets and skips callback-based discriminator configuration whose values are only known at runtime.
- detect missing string metadata in JSON contexts (#251)
 STJM0007 warns when a source-generated JSON context contains migratable payload types but lacks metadata for their injected string discriminator. Registering string explicitly or making it reachable through registered metadata resolves the warning. The analysis follows inherited and nested members and collection contracts, respects ignored members, and keeps each context independent.
 Closes #235
- warn on STJ polymorphism conflicts (#243)
 * feat(stjm): detect polymorphism migration conflicts
 The analyzer now warns when JsonMigratable is combined with System.Text.Json polymorphism in one hierarchy. The diagnostic links directly to migration guidance so consumers can choose a supported design before serialization fails at runtime.
 * fix(stjm): keep migration diagnostics active without STJ
 Migration diagnostics now remain available when a consumer compilation omits System.Text.Json. STJM0005 alone disables itself until its polymorphism metadata is present, so consumers still receive applicable migration warnings.
- validate undiscriminated source contracts (#247)
 Undiscriminated source configuration now reports STJM0003 when no target-owned static migration or visible external migration contract can supply the selected source. This identifies configuration that would otherwise fail only when an undiscriminated payload is deserialized.
 During review, inherited configurations showed that a derived target could be warned at a valid base attribute. Report that case on the derived type and cover it with a two-file regression.
- warn on unsupported migratable target kinds (#246)
 STJM0004 now warns when a migratable target is a collection, dictionary, asynchronous enumerable, or .NET 11 union. Use ordinary object targets or mark union case types so the JSON contract can carry the migration discriminator.
 The analyzer recognizes inherited migration markers and distinguishes collection interfaces from ordinary GetEnumerator methods. Union detection remains compatible with older analyzer hosts while checking actual .NET 11 union declarations.
- detect orphaned legacy payload types (#245)
 Marked historical payload types now warn when no migration contract in the compilation uses them as a source. Set MigratedExternally when the contract lives in another assembly, or remove the marker when the historical type is no longer migrated.
- warn on legacy payload usage outside migration (#241)
 Mark historical payload classes, structs, and enums with JsonMigrationLegacyType to receive STJM0010 warnings when application code names or infers them outside their direct migration boundary. Required JSON metadata and migration registration remain supported, while ordinary replacement APIs and tests follow the same boundary.
 The marker has no runtime effect. MigratedExternally is reserved for future orphan-source analysis and does not suppress legacy-use warnings.
- warn on target-owned external migrators (#242)
 Migratable targets now receive STJM0002 when they directly implement the external IMigrate<TSource, TTarget> contract. The diagnostic points to the offending contract and guides consumers to use IMigrateFrom or an external migrator, helping catch configurations that cannot use the target-owned migration path.
Bug Fixes:
- account for uncertain collection discriminators
 Warn when non-object migration sources may collide because a builder selector or default discriminator property changes the element key, or because an element converter owns its JSON representation. Preserve precise checks when the discriminator remains statically known. Add focused cases for these configurations.
- follow generated JSON metadata paths accurately
 Include polymorphic derived types and selected constructor parameters when checking migration source metadata. Treat member converter paths as unable to require migration while preserving their emitted type metadata, and stop unresolved recursive generic graphs without a speculative warning. SDK-backed tests exercise the generated contexts.
- address legacy and metadata diagnostic edge cases
 Allow legacy sources inside direct migration contracts and registration types, report duplicate discriminators from referenced assemblies at a useful source location, and avoid speculative string metadata warnings for open generic graphs. Index visible legacy source contracts once so orphan checks remain responsive in larger projects. Clarify the diagnostics documentation for inherited contracts and conditional ignore behavior.
- key source discriminators by property and value (#248)
 Migration sources can now share a discriminator value when their effective discriminator property names differ. Deserialization matches the complete pair, honors case-sensitive property names and builder defaults, and rejects ambiguous shared-value payloads. Duplicate pairs still raise the dedicated exception, and static migration retains precedence over external migration for the same source and target.
 Closes #244
- track migrations on struct targets
 A struct target that implements IJsonMigrationTracked now reports MigratedDuringDeserialization after a migration or a legacy read. The flag was previously set on a boxed copy, so the returned value always reported false.
Performance:
- use typed migration dispatch
 Migrations now read the source and call the migrator through typed code instead of object-typed invokers. A migration whose source or target is a value type no longer boxes either value, saving two allocations per migration; in pinned .NET 10 and .NET 11 benchmarks such migrations ran 4-9% faster. Reference-type migrations are unchanged.
 Existing behavior is preserved, including quoted numeric sources, source converters declared for a base type, inherited static contracts with public or non-public derived-target overloads, per-call service provider resolution, and cached fallback construction failures.