Egil.SystemTextJson.Migration 1.2.6

dotnet add package Egil.SystemTextJson.Migration --version 1.2.6
                    
NuGet\Install-Package Egil.SystemTextJson.Migration -Version 1.2.6
                    
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="1.2.6" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="1.2.6" />
                    
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 1.2.6
                    
#r "nuget: Egil.SystemTextJson.Migration, 1.2.6"
                    
#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@1.2.6
                    
#: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=1.2.6
                    
Install as a Cake Addin
#tool nuget:?package=Egil.SystemTextJson.Migration&version=1.2.6
                    
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:

  • Zero allocation on the happy path — current-version payloads deserialize with no extra overhead.
  • O(1) discriminator check — only the first JSON property is inspected to determine the payload version.
  • AOT-friendly — works with source-generated JsonSerializerContext.
  • 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.

📖 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);

Static migration

When the migration logic naturally belongs on the target type, implement IMigrateFrom directly:

[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 }

External migration

When migration logic should live in its own class — for separation of concerns, testability, 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 all IMigrate<,> implementations in one or more assemblies instead of listing each one:

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.RegisterMigratorsFromAssemblies(typeof(UserMigrator).Assembly);
});

Source-generated JsonSerializerContext

For AOT scenarios, register both old and current types in a source-generated context:

[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);
    });

Legacy payloads without a discriminator

Payloads that were serialized before migration support was added will have no $type property. The library treats these as legacy payloads and attempts migration using the registered source types. This means you can adopt the library incrementally — existing stored JSON keeps working.

Performance

Every benchmark compares the library against hand-written migration code on top of plain System.Text.Json. Each scenario is tested at three payload sizes (2, 32, and 256 array items) to show how overhead scales.

Key takeaways:

  • Happy path (no migration needed): deserialization is ~1.0–1.3× plain STJ with zero extra allocations. The overhead comes from the O(1) first-property discriminator check and is constant regardless of payload size.
  • Migration path: 1.4–1.5× plain STJ for small payloads, converging toward ~1.0× as payload size grows — the fixed migration overhead is amortized over more data.
  • Legacy payloads (no discriminator): 1.0–1.2× plain STJ with zero extra allocations — the same as current-version payloads.
  • Serialization: near 1:1 at larger payloads (ratio ≈ 1.0). Small payloads show ~2× due to the fixed cost of writing the discriminator property.

Detailed results with source-generated JsonSerializerContext:

Scenario TagCount Ratio vs plain STJ Alloc Ratio
No migration (happy path) 2 1.25× 1.00
32 0.76× 1.00
256 1.02× 1.00
Static migration 2 1.43× 1.13
32 1.22× 1.04
256 1.06× 1.01
External migration 2 1.50× 1.13
32 1.18× 1.05
256 1.04× 1.01
Legacy payload 2 1.16× 1.00
32 1.05× 1.00
256 0.82× 1.00
Serialization 2 2.10× 5.45
32 1.17× 2.02
256 0.93× 1.15

Full benchmark reports: source-gen · reflection

Run benchmarks locally with dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release.

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. The library matches migrators by comparing the JSON token type against the source type's JsonTypeInfoKind (StartArrayEnumerable, primitives → None). Dictionary source types (Dictionary<string, T>) are also supported — when no discriminator match is found on a JSON object, the library checks for JsonTypeInfoKind.Dictionary migrators before falling back to legacy handling. 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • net10.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
1.2.6 111 4/14/2026
1.1.3 93 4/13/2026
1.0.2 90 4/11/2026
0.4.2 83 4/11/2026
0.3.4 80 4/11/2026
0.2.3 84 4/11/2026

New Features:
- support migration from non-object JSON payloads
 Enable migrating from non-object JSON payloads (arrays, primitives,
 dictionaries) to [JsonMigratable] target types via IMigrateFrom and
 IMigrate contracts.
 Previously, the converter threw when the first JSON token was not
 StartObject. Now the Inspect method matches migrators by
 JsonTypeInfoKind for non-object tokens (StartArray -> Enumerable,
 primitives -> None) and checks for Dictionary-kind migrators when
 discriminator matching fails on object payloads.
 Disambiguation when multiple migrators share the same JSON shape:
 - Primitives: matched by JSON token type (String, Number, True/False)
   to CLR type (string, numeric, bool).
 - Enumerables/dictionaries: peek at the first element/value token to
   narrow candidates. For migratable object elements, read the type
   discriminator from the first element to select the correct source.
 - Empty collections and truly ambiguous cases throw a clear
   JsonException.
 The non-object matching logic is extracted into a partial class file
 (JsonMigratableConverter.NonObjectMatching.cs) to keep the core
 converter focused on the discriminator-based object pipeline.
 Zero overhead on the existing object-based happy path -- the new
 branches are only taken for non-object tokens or unrecognized object
 payloads. Benchmarks confirm no regression.
 Includes documentation updates (README, recipes, samples) and 30+
 new tests covering all source types, disambiguation, tracking,
 round-trip, and error cases.