Nobody Writes an Up() Method
At startup VersionKit reflects over your model assembly and compiles property attributes into an executable pipeline - one migration step per version pair, five operation types, all of them deliberately idempotent. Part 3 of the Entity Versioning with VersionKit series.
Every migration framework I have used asks the same tax: for each schema change, somebody writes a method. EF Core wants an Up(). Event-sourcing libraries want an upcaster class per event version. The method is nearly always mechanical — rename this key, default that field — and mechanical code written by hand is where bugs breed.
VersionKit's core bet is that the mechanical 90% can be compiled from the attributes you saw in Part 2. This part walks through that compilation: what the scanner does at startup, what it produces, and why the operations it emits are all deliberately idempotent.
One call at startup
The entry point is DI registration:
builder.Services.AddVersionKit(v =>
{
v.ScanAssembly(typeof(Order).Assembly);
v.AddMigrator<OrderV1ToV2Migrator>(); // hand-written override, Part 5
v.EnableBackfillService = true; // eager rewrites, Part 6
});
ScanAssembly just records the assembly; the actual scan runs inside AddVersionKit, at registration time, not lazily on first use. That is a deliberate choice: if an attribute references a provider type that cannot be constructed, or a model assembly fails to load, I want the application to die on the launch pad, not on the first unlucky read in production.
The scanner itself is compact enough to read whole:
public static void Scan(Assembly assembly)
{
var types = assembly.GetTypes()
.Where(t => t is { IsClass: true, IsAbstract: false })
.Where(t => t.GetCustomAttribute<SchemaVersionAttribute>() is not null);
foreach (var type in types)
{
var schemaAttr = type.GetCustomAttribute<SchemaVersionAttribute>()!;
var steps = BuildSteps(type, schemaAttr.Current);
var migrator = new VersionedMigrator(type, schemaAttr.Current, steps);
MigrationRegistry.Register(type, migrator);
}
}
Concrete classes with [SchemaVersion] get a VersionedMigrator; everything else is invisible to VersionKit. There is no configuration file, no fluent mapping, no convention magic beyond “the attribute is the registration”.
Bucketing changes by the version they fire in
BuildSteps is where declarations become executable structure. For each readable public property it merges the attribute stack (the first-wins rules from Part 2), then emits operations into version buckets:
// Rename fires in the version the rename happened
if (merged.RenamedFrom is not null && merged.RenamedInVersion > 0)
{
stepOperations[merged.RenamedInVersion]
.Add(new RenameOperation(merged.RenamedFrom, propertyName));
}
// Add-with-default fires in the version the property was added
if (merged.AddedIn > 1)
{
if (merged.DefaultProvider is not null)
stepOperations[merged.AddedIn]
.Add(new AddComputedDefaultOperation(propertyName, merged.DefaultProvider));
else if (merged.DefaultValue is not null)
stepOperations[merged.AddedIn]
.Add(new AddDefaultOperation(propertyName, merged.DefaultValue));
}
The key insight is that every declared change carries the version it fires in — RenamedInVersion, AddedIn, RemovedInVersion, TypeChangedInVersion — and that version is the bucket key. A rename that happened in v2 belongs to the step that lifts a payload from v1 to v2. Nothing else needs to be said.
Then the buckets are laid out as a chain of consecutive steps:
var steps = new List<MigrationStep>();
for (int from = 1; from < currentVersion; from++)
{
int to = from + 1;
var ops = stepOperations.TryGetValue(to, out var list)
? list
: new List<IMigrationOperation>();
steps.Add(new MigrationStep(from, to, ops));
}
Two things to notice. First, the chain is always complete: for a v5 entity you get exactly four steps, 1→2 through 4→5, even if some are empty. An empty step still stamps the watermark forward, which keeps the walk logic (Part 4) free of special cases. Second, this is the N−1 economics from Part 1 made concrete — the scanner never builds point-to-point shortcuts, so there are never N×(N−1)/2 paths to test.
Ordering within a step is worth being honest about: operations land in property declaration order, and per property in a fixed sequence (rename, move, add-default, remove, type change). For independent properties that is irrelevant. But a computed default provider that reads a sibling field which was renamed in the same version will see the old or new key depending on declaration order. The contract (Part 5) says providers should expect FROM-version keys; if you need guarantees beyond that, the step has outgrown attributes.
The five operations
Each emitted operation is a tiny class implementing one interface:
public interface IMigrationOperation
{
void Apply(Dictionary<string, object?> payload);
}
Payloads at this level are dictionaries, not typed entities — deliberately. A v1 payload cannot be deserialised into v3 code; that is the entire problem. The dictionary is the only honest representation of data that predates the current type. Typed deserialisation happens exactly once, after the walk finishes.
The operations themselves are almost boringly simple, and their shared property is the important part — every one of them is conditional:
public void Apply(Dictionary<string, object?> payload) // RenameOperation
{
if (payload.TryGetValue(OldKey, out var value))
{
payload[NewKey] = value;
payload.Remove(OldKey);
}
}
public void Apply(Dictionary<string, object?> payload) // AddDefaultOperation
{
if (!payload.ContainsKey(Key))
payload[Key] = DefaultValue;
}
Rename and move only fire if the old key exists. Defaults only fire if the key is absent. Remove is naturally idempotent. Type change only fires if the key exists, and its adapter refuses values that are already the target type. The result: applying a step to a payload that has already been through it is a no-op.
That idempotency is not politeness — it is a load-bearing safety net. Payloads without a parseable watermark are treated as v1 and walked from the bottom (Part 4), and the EF Core integration re-runs low steps against rows whose actual version is fuzzy at the edges (Part 9). Because operations are conditional, “when in doubt, migrate from v1” is safe instead of catastrophic. If I had made AddDefaultOperation overwrite unconditionally, a re-run would clobber real data; as built, the worst case is wasted work.
The computed-default operation carries the one performance wart in the set:
public void Apply(Dictionary<string, object?> payload) // AddComputedDefaultOperation
{
if (!payload.ContainsKey(Key))
{
var provider = (IDefaultValueProvider)Activator.CreateInstance(ProviderType)!;
payload[Key] = provider.GetDefault(payload);
}
}
Activator.CreateInstance per payload, no caching, no DI. The same pattern appears in TypeChangeOperation. It keeps providers dependency-free and trivially testable, but it means they must have public parameterless constructors and cannot inject services — no database lookups, no IOptions. For read-path defaults that is usually the right constraint anyway (you do not want a hidden query per deserialisation); when it is not, the hand-written migrator escape hatch takes over the whole step and can be resolved from DI with whatever dependencies it likes.
The registry: one static map
Compiled migrators land in a process-global registry:
public static class MigrationRegistry
{
private static readonly ConcurrentDictionary<Type, IVersionedMigrator> _migrators = new();
public static void Register(Type entityType, IVersionedMigrator migrator) =>
_migrators[entityType] = migrator;
public static bool IsVersioned(Type entityType) => _migrators.ContainsKey(entityType);
public static IVersionedMigrator Get(Type entityType) => _migrators[entityType];
}
Static state is a trade-off I made with open eyes. The alternative — flowing a registry instance through every reader, writer, and background service — adds a constructor parameter to half the library for a map that is written once at startup and read forever after. The static registry makes the EF Core integration in particular much less ceremonious: any code, anywhere, can ask MigrationRegistry.IsVersioned(typeof(T)) without plumbing.
The cost shows up in tests: parallel test fixtures that scan different assemblies share the dictionary, and re-registering a type replaces its migrator globally. Registration is last-write-wins by design (_migrators[entityType] = migrator), which makes repeated scans harmless but means isolation is on you. It is the classic static-registry bargain — the same one JsonSerializerOptions.Default and EF's convention sets make — and for a library whose entire model is “declared once, true everywhere”, I still think it is the right side of the trade.
What you get for free
Step back and look at what the sample Order from Part 2 produces without anyone writing migration code: a step 1→2 containing a rename (ClientId→CustomerId), a move (ShippingCity→City), and a literal default (Status = "Pending"); a step 2→3 containing a remove (LegacyNotes), a type change (IsExpedited via StringToBoolMigrator), and a computed default (IsHighValue). Declaration order, version arithmetic, conditionality — all handled by thirty lines of scanner.
The pipeline now exists. The next question is when it runs — and the answer, “at the moment of read, one version at a time, guided by a watermark”, has its own set of sharp edges courtesy of System.Text.Json.
Next: The Watermark and the Walk — lazy migration on read, and the JsonElement gotchas that come with it.