Skip to content
Kumar Chandrachooda
.NET

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.

By Kumar Chandrachooda 17 Jul 2025 3 min read
v1 data climbing, one step at a time, to the shape the code expects

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

  1. The problem and the thesis — this post.
  2. Five ways a property can change — the change kinds and the attribute vocabulary for declaring them.
  3. Nobody writes an Up() method — how the scanner compiles attributes into operation pipelines at startup.
  4. The watermark and the walk — the __version watermark, step-by-step migration on read, historical reads, and the JsonElement gotchas System.Text.Json throws at dictionary-shaped code.
  5. When attributes aren't enough — type migrators and the hand-written escape hatch.
  6. Paying down the migration debt — the backfill background service, batching, and running it serverless.
  7. Give old rows their own table — the ambitious half begins: archiving Orders to Orders_v2 on a schema bump, and the registry that knows where every row lives.
  8. 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.
  9. Draining the archive without losing a row — optimistic-concurrency draining.
  10. 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.