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
<PackageReference Include="Egil.SystemTextJson.Migration" Version="1.2.6" />
<PackageVersion Include="Egil.SystemTextJson.Migration" Version="1.2.6" />
<PackageReference Include="Egil.SystemTextJson.Migration" />
paket add Egil.SystemTextJson.Migration --version 1.2.6
#r "nuget: Egil.SystemTextJson.Migration, 1.2.6"
#:package Egil.SystemTextJson.Migration@1.2.6
#addin nuget:?package=Egil.SystemTextJson.Migration&version=1.2.6
#tool nuget:?package=Egil.SystemTextJson.Migration&version=1.2.6
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) 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);
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
$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.
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.