Egil.SystemTextJson.Migration
1.7.4
dotnet add package Egil.SystemTextJson.Migration --version 1.7.4
NuGet\Install-Package Egil.SystemTextJson.Migration -Version 1.7.4
<PackageReference Include="Egil.SystemTextJson.Migration" Version="1.7.4" />
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="1.7.4" />
<PackageReference Include="Egil.SystemTextJson.Migration" />
paket add Egil.SystemTextJson.Migration --version 1.7.4
#r "nuget: Egil.SystemTextJson.Migration, 1.7.4"
#:package Egil.SystemTextJson.Migration@1.7.4
#addin nuget:?package=Egil.SystemTextJson.Migration&version=1.7.4
#tool nuget:?package=Egil.SystemTextJson.Migration&version=1.7.4
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.
- AOT-friendly — works with source-generated
JsonSerializerContext. - 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.
📖 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
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);
});
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. 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 | 304.5 ns | 1.00 | 0.06 | 160 B | 1.00 |
| JsonMigratable | Small | 337.1 ns | 1.11 | 0.05 | 160 B | 1.00 | |
| Plain STJ | Medium | 1,465.9 ns | 1.00 | 0.01 | 1656 B | 1.00 | |
| JsonMigratable | Medium | 1,572.8 ns | 1.07 | 0.01 | 1656 B | 1.00 | |
| Plain STJ | Large | 15,625.2 ns | 1.00 | 0.01 | 24624 B | 1.00 | |
| JsonMigratable | Large | 16,915.7 ns | 1.08 | 0.10 | 24624 B | 1.00 | |
| Static migration | Manual STJ migration | Small | 342.7 ns | 1.00 | 0.08 | 312 B | 1.00 |
| JsonMigratable | Small | 632.9 ns | 1.85 | 0.19 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 2,013.6 ns | 1.00 | 0.06 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,104.1 ns | 1.05 | 0.05 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 17,840.2 ns | 1.00 | 0.10 | 24776 B | 1.00 | |
| JsonMigratable | Large | 16,988.8 ns | 0.96 | 0.09 | 24776 B | 1.00 | |
| External migration | Manual STJ migration | Small | 334.1 ns | 1.00 | 0.06 | 312 B | 1.00 |
| JsonMigratable | Small | 566.9 ns | 1.70 | 0.08 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 1,861.7 ns | 1.00 | 0.06 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,405.9 ns | 1.29 | 0.08 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 17,206.9 ns | 1.01 | 0.13 | 24776 B | 1.00 | |
| JsonMigratable | Large | 18,846.3 ns | 1.10 | 0.17 | 24776 B | 1.00 | |
| Undiscriminated source migration | Manual STJ migration | Small | 379.1 ns | 1.00 | 0.09 | 312 B | 1.00 |
| JsonMigratable | Small | 491.2 ns | 1.30 | 0.11 | 312 B | 1.00 | |
| Manual STJ migration | Medium | 2,026.8 ns | 1.00 | 0.08 | 1808 B | 1.00 | |
| JsonMigratable | Medium | 2,106.4 ns | 1.04 | 0.10 | 1808 B | 1.00 | |
| Manual STJ migration | Large | 16,685.8 ns | 1.03 | 0.25 | 24776 B | 1.00 | |
| JsonMigratable | Large | 15,659.2 ns | 0.97 | 0.18 | 24776 B | 1.00 | |
| Legacy payload | Plain STJ + tracking | Small | 373.4 ns | 1.00 | 0.04 | 192 B | 1.00 |
| JsonMigratable | Small | 486.0 ns | 1.30 | 0.08 | 192 B | 1.00 | |
| Plain STJ + tracking | Medium | 1,933.9 ns | 1.01 | 0.12 | 1688 B | 1.00 | |
| JsonMigratable | Medium | 1,991.9 ns | 1.04 | 0.12 | 1688 B | 1.00 | |
| Plain STJ + tracking | Large | 16,924.1 ns | 1.00 | 0.02 | 24656 B | 1.00 | |
| JsonMigratable | Large | 15,528.3 ns | 0.92 | 0.05 | 24656 B | 1.00 | |
| Serialization | Plain STJ | Small | 107.8 ns | 1.00 | 0.03 | 56 B | 1.00 |
| JsonMigratable | Small | 169.8 ns | 1.58 | 0.03 | 136 B | 2.43 | |
| Plain STJ | Medium | 436.8 ns | 1.00 | 0.00 | 416 B | 1.00 | |
| JsonMigratable | Medium | 735.2 ns | 1.68 | 0.01 | 800 B | 1.92 | |
| Plain STJ | Large | 3,689.9 ns | 1.00 | 0.01 | 10384 B | 1.00 | |
| JsonMigratable | Large | 4,982.6 ns | 1.35 | 0.01 | 10776 B | 1.04 |
Full benchmark reports: source-gen · reflection
Run benchmarks locally with
dotnet run --project perf/Egil.SystemTextJson.Migration.PerfTests -c Release. 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. The library matches migrators by comparing the JSON token type against the source type's
JsonTypeInfoKind(StartArray→Enumerable, primitives →None). Dictionary source types (Dictionary<string, T>) are also supported — when no discriminator match is found on a JSON object, the library checks forJsonTypeInfoKind.Dictionarymigrators 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 | 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. |
-
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.
Performance:
- replace tag-count benchmarks with payload profiles
Performance benchmarks now use small, medium, and large payload profiles instead of a tag-count dimension, with representative object graphs and generated JSON examples.
Public performance docs are regenerated from BenchmarkDotNet output, omit internal polymorphic STJ guardrail rows, and keep README ratio data in sync with the raw source-generated benchmark report.