Egil.SystemTextJson.Migration 0.4.2

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

Egil.SystemTextJson.Migration

NuGet

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:

<a id='snippet-shared_types'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/SharedTypes.cs#L3-L13' title='Snippet source file'>snippet source</a> | <a href='#snippet-shared_types' title='Start of snippet'>anchor</a></sup>

Static migration

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

<a id='snippet-static_migration_type'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/StaticMigrationSample.cs#L6-L18' title='Snippet source file'>snippet source</a> | <a href='#snippet-static_migration_type' title='Start of snippet'>anchor</a></sup>

Enable migration support on the serializer options and deserialize as usual:

<a id='snippet-static_migration_usage'></a>

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 }

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/StaticMigrationSample.cs#L25-L33' title='Snippet source file'>snippet source</a> | <a href='#snippet-static_migration_usage' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-external_migrator'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/ExternalMigrationSample.cs#L9-L19' title='Snippet source file'>snippet source</a> | <a href='#snippet-external_migrator' title='Start of snippet'>anchor</a></sup>

<a id='snippet-external_migration_setup'></a>

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.RegisterMigrator<UserMigrator>();
});

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/ExternalMigrationSample.cs#L26-L32' title='Snippet source file'>snippet source</a> | <a href='#snippet-external_migration_setup' title='Start of snippet'>anchor</a></sup>

Multi-step migration chains

A target type can accept payloads from multiple older versions. Each source version has its own migration path:

<a id='snippet-multi_step_chain'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/MultiStepChainSample.cs#L3-L29' title='Snippet source file'>snippet source</a> | <a href='#snippet-multi_step_chain' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-di_migrators'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/DiMigratorsSample.cs#L26-L38' title='Snippet source file'>snippet source</a> | <a href='#snippet-di_migrators' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-assembly_scanning'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/AssemblyScanningSample.cs#L24-L30' title='Snippet source file'>snippet source</a> | <a href='#snippet-assembly_scanning' title='Start of snippet'>anchor</a></sup>

Source-generated JsonSerializerContext

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

<a id='snippet-source_gen_context'></a>

[JsonSerializable(typeof(UserV1))]
[JsonSerializable(typeof(UserV2))]
public partial class AppJsonContext : JsonSerializerContext;

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/SourceGenSample.cs#L18-L22' title='Snippet source file'>snippet source</a> | <a href='#snippet-source_gen_context' title='Start of snippet'>anchor</a></sup>

<a id='snippet-source_gen_usage'></a>

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport();
options.TypeInfoResolverChain.Add(AppJsonContext.Default);

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/SourceGenSample.cs#L29-L33' title='Snippet source file'>snippet source</a> | <a href='#snippet-source_gen_usage' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-migration_tracking_type'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/MigrationTrackingSample.cs#L6-L21' title='Snippet source file'>snippet source</a> | <a href='#snippet-migration_tracking_type' title='Start of snippet'>anchor</a></sup>

<a id='snippet-migration_tracking_usage'></a>

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

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/MigrationTrackingSample.cs#L31-L41' title='Snippet source file'>snippet source</a> | <a href='#snippet-migration_tracking_usage' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-custom_discriminator_attribute'></a>

// Per-type via the attribute:
[JsonMigratable(
    TypeDiscriminator = "user-v2",
    TypeDiscriminatorPropertyName = "version")]
public record UserV2(string FirstName, string LastName, int Age);

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/CustomDiscriminatorSample.cs#L3-L9' title='Snippet source file'>snippet source</a> | <a href='#snippet-custom_discriminator_attribute' title='Start of snippet'>anchor</a></sup>

<a id='snippet-custom_discriminator_builder'></a>

// Or set a global default property name via the builder:
var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.SetTypeDiscriminatorPropertyName("_schema");
});

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/CustomDiscriminatorSample.cs#L28-L35' title='Snippet source file'>snippet source</a> | <a href='#snippet-custom_discriminator_builder' title='Start of snippet'>anchor</a></sup>

You can also derive the discriminator value from an existing attribute on your types, keeping the library out of your domain model:

<a id='snippet-derive_discriminator'></a>

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.GetTypeDiscriminatorFrom<SchemaVersionAttribute>(
        attr => attr.Version);
});

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/CustomDiscriminatorSample.cs#L59-L66' title='Snippet source file'>snippet source</a> | <a href='#snippet-derive_discriminator' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-failure_handling_builder'></a>

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
options.AddJsonMigrationSupport(builder =>
{
    builder.SetMigrationFailureHandling(
        JsonMigrationFailureHandling.FallBackToTargetType);
});

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/FailureHandlingSample.cs#L41-L48' title='Snippet source file'>snippet source</a> | <a href='#snippet-failure_handling_builder' title='Start of snippet'>anchor</a></sup>

<a id='snippet-failure_handling_return_null'></a>

// Per-type override:
[JsonMigratable(MigrationFailureHandling = JsonMigrationFailureHandling.ReturnNull)]
public record OptionalData(string Value);

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/FailureHandlingSample.cs#L17-L21' title='Snippet source file'>snippet source</a> | <a href='#snippet-failure_handling_return_null' title='Start of snippet'>anchor</a></sup>

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:

<a id='snippet-otel_meter_listener'></a>

// 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();

<sup><a href='/samples/Egil.SystemTextJson.Migration.Samples/TelemetrySample.cs#L24-L44' title='Snippet source file'>snippet source</a> | <a href='#snippet-otel_meter_listener' title='Start of snippet'>anchor</a></sup>

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.

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.5.3 68 5/6/2026
1.4.3 58 5/5/2026
1.3.7 74 5/5/2026
1.2.6 160 4/14/2026
1.1.3 96 4/13/2026
1.0.2 93 4/11/2026
0.4.2 87 4/11/2026
0.3.4 83 4/11/2026
0.2.3 87 4/11/2026