Your Database Remembers What Your Code Forgot
Rename a property, change a type, ship the release - and every payload you stored last year stops deserialising. Introducing VersionKit, a .NET library that declares schema history as attributes and migrates old data on read.
EF Core migrations are excellent at one job: changing tables. They are no help at all with the other place your domain models live — the serialized payloads you wrote to a document column, an event store, a cache, or a snapshot table, months ago, with last year's property names.
Rename CustomerName to BuyerName and every historic payload still says CustomerName. Change Total from decimal to a Money object and old JSON simply refuses to become the new type. The code moved on; the data remembers.
VersionKit is a library I built to make that evolution a declared, first-class part of the model instead of a scattering of defensive JsonPropertyName hacks and one-off backfill scripts. This post introduces the idea; the series then works down through the machinery.
Declare history where it happened
A versioned entity carries its schema number, and each evolved property describes what happened to it and when:
[SchemaVersion(3)]
public class Order
{
public Guid Id { get; set; }
// v2: renamed from CustomerName
[PropertyVersion(RenamedFrom = "CustomerName", RenamedInVersion = 2)]
public string BuyerName { get; set; } = string.Empty;
// v3: added, with a computed default for older rows
[PropertyVersion(AddedIn = 3, DefaultProvider = typeof(IsHighValueDefaultProvider))]
public bool IsHighValue { get; set; }
}
Five kinds of change are expressible: rename, move/restructure, add with a default (constant or computed), remove, and type change (with a small ITypeMigrator<TFrom,TTo> you supply). The attributes allow multiples, so a property that changed twice stacks its history.
Compiled at startup, applied on read
At startup a scanner reflects over the model assembly and compiles those attributes into concrete migration operations, bucketed by the version each change fires in — one MigrationStep per consecutive version pair. Nobody writes an Up() method.
Stored payloads carry a __version watermark. When a v1 payload is read into v3 code, the migrator walks it up one version at a time:
for (int from = version; from < targetVersion; from++)
{
int to = from + 1;
// a hand-written migrator can take over any single step
if (handWritten.TryGetValue((from, to), out var hw))
{
current = hw.Migrate(current);
continue;
}
foreach (var op in StepFor(from, to).Operations)
{
op.Apply(current); // rename / move / default / remove / convert
}
current["__version"] = to;
}
Step-by-step beats point-to-point for a simple reason: with N versions you maintain N-1 transitions, not N*(N-1)/2 of them — and v1 data automatically benefits from every rule you ever declared along the way. When a transition is genuinely too gnarly for attributes, IHandWrittenMigrator swaps in for exactly that step and nothing else.
Lazy first, eager eventually
Migrating on read means deploys are instant — no big-bang data rewrite. But you do not want to pay the migration tax on the same hot rows forever, so a background backfill service pulls stale payloads in batches, migrates them to current, and writes them back. Reads get cheaper over time; the store converges on the current version. The service is storage-agnostic (you supply an IBackfillStore) and can equally run as a scheduled serverless function.
And because the migrator can also stop early — MigrateTo<Order>(json, targetVersion: 2) — you can deliberately read data as it looked at an older version, which turns out to be a pleasant way to serve audit questions.
Where the series goes
- The problem and the thesis — this post.
- Five ways a property can change — the change kinds and the attribute vocabulary for declaring them.
- Nobody writes an Up() method — how the scanner compiles attributes into operation pipelines at startup.
- The watermark and the walk — the
__versionwatermark, step-by-step migration on read, historical reads, and theJsonElementgotchasSystem.Text.Jsonthrows at dictionary-shaped code. - When attributes aren't enough — type migrators and the hand-written escape hatch.
- Paying down the migration debt — the backfill background service, batching, and running it serverless.
- Give old rows their own table — the ambitious half begins: archiving
OrderstoOrders_v2on a schema bump, and the registry that knows where every row lives. - One query across every version — write-through reads that migrate on the fly, and a generated UNION view that queries every version as if it were current.
- Draining the archive without losing a row — optimistic-concurrency draining.
- The bill for versioning everything — the honest trade-offs.
Parts 7–10 are where the JSON story and the relational story meet — and where the trade-offs get genuinely interesting.