Skip to content
Kumar Chandrachooda
.NET

When Attributes Aren't Enough

Splitting a name field, converting a string to a bool, deriving a flag from a total - some migrations are code, not metadata. This part covers VersionKit's three escape hatches and the sharp edges each one carries. Part 5 of the Entity Versioning with VersionKit series.

By Kumar Chandrachooda 29 Sep 2025 6 min read
The declared pipeline, with a trapdoor for the step that needs one

A declarative system earns trust by being predictable, and it stays predictable by refusing to express things. Part 2 listed what [PropertyVersion] will not say: splits, merges, conditionals, lookups. This part is about the three places VersionKit hands you back the keyboard — the hand-written step migrator, the type migrator, and the computed default provider — and the different contract each one signs.

Escape hatch one: take over the whole step

IHandWrittenMigrator is the nuclear option: for one (entity, from, to) transition, your code runs instead of the attribute-derived operations. Not in addition to — instead of. The walk checks for it first:

if (_handWritten.TryGetValue((from, to), out var hw))
{
    current = hw.Migrate(current);
    continue;   // the attribute-derived step for this pair never runs
}

The contract is three properties and one method:

public sealed class OrderV1ToV2Migrator : IHandWrittenMigrator
{
    public Type EntityType  => typeof(Order);
    public int  FromVersion => 1;
    public int  ToVersion   => 2;

    public Dictionary<string, object?> Migrate(Dictionary<string, object?> payload)
    {
        var next = new Dictionary<string, object?>(payload);

        // everything the attributes would have done, you now own:
        if (next.TryGetValue("ClientId", out var clientId))
        {
            next["CustomerId"] = clientId;
            next.Remove("ClientId");
        }
        if (next.TryGetValue("ShippingCity", out var city))
        {
            next["City"] = city;
            next.Remove("ShippingCity");
        }
        if (!next.ContainsKey("Status"))
            next["Status"] = "Pending";

        // and the thing attributes can't do — split a field:
        if (next.TryGetValue("FullName", out var full) && full is string fullStr)
        {
            var parts = fullStr.Split(' ', 2, StringSplitOptions.TrimEntries);
            next["FirstName"] = parts[0];
            next["LastName"]  = parts.Length > 1 ? parts[1] : string.Empty;
            next.Remove("FullName");
        }

        next["__version"] = 2;
        return next;
    }
}

That sample (from the repo, trimmed) makes the cost of the takeover visible: the rename, the move, and the default still have to happen, and now they happen in your code, because the attribute step you replaced will not run. When I designed this I considered a “runs after the attribute step” mode, and rejected it — half-declared, half-coded steps are exactly the kind of split-brain that makes migrations unauditable. One step, one owner. If you take a step over, the attributes on the entity become documentation for that transition and your migrator becomes the truth.

Follow the sample's conventions even when the compiler does not force you to: copy the input dictionary (the walk relies on step isolation), keep every mutation conditional (idempotency is the safety net for fuzzy watermarks — Part 4), and stamp __version yourself, because the walk's automatic stamping belongs to the declarative branch you just bypassed.

Registration is one line, and wiring is worth understanding:

builder.Services.AddVersionKit(v => v.AddMigrator<OrderV1ToV2Migrator>());

Migrators register as IHandWrittenMigrator singletons in the container; MigrationRunner's constructor receives them all and pushes them into every registered VersionedMigrator, which files them by (FromVersion, ToVersion) after filtering to its own entity type. Two consequences fall out. First, hand-written migrators are DI singletons, so — unlike providers and type migrators — they can take constructor dependencies. Treat that power with suspicion: a migration step that calls a service makes every read that triggers it slower and less deterministic. Second, the injection happens when MigrationRunner is first constructed, so a runner resolved before your migrator registration would miss it — another reason all registration goes through the single AddVersionKit call.

Escape hatch two: convert a value in place

Type changes stay declared on the property — TypeChangedInVersion = 3, TypeMigrator = typeof(StringToBoolMigrator) — but the conversion itself is code. You write the typed version and inherit the adapter:

public sealed class StringToBoolMigrator : TypeMigratorAdapter<string, bool>
{
    public override bool Migrate(string oldValue) =>
        oldValue?.Trim().ToLowerInvariant() is "true" or "1" or "yes";
}

The base class exists because the operation pipeline works in object? dictionaries and cannot know TFrom/TTo at compile time. Its untyped shim is where the interesting decisions live:

object? ITypeMigratorAdapter.Migrate(object? oldValue)
{
    if (oldValue is TFrom typed) return Migrate(typed);

    // System.Text.Json deserialises numbers as JsonElement in object? dicts
    if (oldValue is JsonElement je)
    {
        var coerced = CoerceJsonElement<TFrom>(je);
        if (coerced is not null) return Migrate(coerced);
    }

    return default(TTo);
}

Line by line: a value that is already TFrom converts directly (database-sourced payloads — Part 8 — deliver real CLR types). A JsonElement is coerced by deserialising its raw text into TFrom — this is the Part 4 JsonElement tax, paid once in the base class so your override receives a clean string and never sees a JsonElement. That coercion is the whole reason the adapter exists; without it every type migrator in every consuming project would reimplement the same defensive switch.

And then the last line, which I will not defend, only explain: a value that is neither TFrom nor coercible becomes default(TTo). For StringToBoolMigrator fed something unexpected, that is false — silently. The safe-looking alternatives all have worse failure modes in this position: throwing turns one malformed historic payload into a read outage for that entity, and returning the original value hands the final deserialisation a type it will choke on anyway, just with a more confusing stack trace. But make no mistake, this is fallback as data: a corrupt value quietly becomes a default. It is the sharpest edge in the library. My mitigation is operational — the backfill service (Part 6) forces every historic payload through migration on a schedule, so conversion surprises surface in backfill logs during business hours rather than in a customer read path at 3 a.m. If a future version grows a strict mode that throws here, it will be because this paragraph kept nagging me.

Note also what the adapter does when the value is already converted: a v3 true (boolean) hitting TypeMigratorAdapter<string, bool> fails the is TFrom check, fails string coercion — and returns default(TTo). This is why type-change operations, like every operation, only run inside their own step, and why the walk never re-enters a step for a correctly-watermarked payload. The idempotency story for type changes is positional (the step will not re-run), not value-level like renames and defaults. It is the one operation where the conditional-op safety net has a hole, and it is the strongest argument for keeping watermarks intact in storage.

Escape hatch three: compute a default

The third hatch, IDefaultValueProvider, fires when an added property is missing from an old payload and a literal will not do:

public sealed class IsHighValueDefaultProvider : IDefaultValueProvider
{
    private const decimal HighValueThreshold = 1000m;

    public object? GetDefault(IReadOnlyDictionary<string, object?> payload)
    {
        if (!payload.TryGetValue("Total", out var raw)) return false;
        var total = raw switch
        {
            decimal d => d,
            double  d => (decimal)d,
            JsonElement je when je.TryGetDecimal(out var d) => d,
            _ => 0m
        };
        return total >= HighValueThreshold;
    }
}

The signature is the contract: you get the whole payload, read-only, and return one value. The documented subtlety is which payload you get — keys as they stood in the version being migrated FROM. Renames and moves belonging to the same step may not have been applied yet when your provider runs (operation order follows property declaration order — Part 3). So a provider that reads sibling fields should reach for the old names, or defensively try both. If a default depends on the post-rename shape of the same step, that step has outgrown the declarative model; take it over with a hand-written migrator and control the order explicitly.

Like type migrators, providers are created via Activator.CreateInstance per invocation: public parameterless constructor, no DI, no caching. The constraint is a feature disguised as a limitation — a default provider that cannot call services is a default provider that cannot make a read path block on a network hop.

Choosing a hatch

After a couple of years of using these in anger, my decision table is short. Value needs converting? Type migrator — it is scoped, declared on the property, and survives audits. Missing value derivable from the payload? Default provider. Anything structural, conditional, or multi-field? Hand-written migrator for that step, and accept that you now own everything the step does. And if you find yourself writing hand-written migrators for most steps of an entity, VersionKit is telling you something: your entity's history is not mechanical, and no declarative system should pretend it is.

All of this — declared steps and escape hatches alike — still runs at read time, which means old payloads pay a migration tax on every read until something rewrites them. That something is next.

Next: Paying Down the Migration Debt — the backfill service that migrates stale payloads eagerly, in batches, forever.