Skip to content
Kumar Chandrachooda
.NET

The Bill for Versioning Everything

Static registries, Activator-created migrators, a Postgres accent, a flag that does nothing, and no test project - the closing part of the VersionKit series itemises every trade-off in the library, says when you should not use it, and sketches what comes next. Part 10 of the Entity Versioning with VersionKit series.

By Kumar Chandrachooda 22 Mar 2026 7 min read
Every design decision, itemised and totalled

Nine parts of this series argued for things. This one collects the invoice. Every library is a stack of trade-offs wearing an API, and the ones in VersionKit were made by me, on purpose, mostly — which means I can tell you exactly what each costs, which ones I would defend in front of a review board, and which ones are simply debts with my name on them. If you are evaluating VersionKit — or building your own versioning layer, which is an entirely reasonable response to this series — this is the part to read twice.

Costs I would pay again

The static MigrationRegistry. Process-global mutable state, the cardinal sin. I covered the reasoning in Part 3: the registry is written once at startup and read forever, and making it static means the EF integration, the backfill workers, and any future consumer can ask IsVersioned(typeof(T)) without threading a service through every constructor in three packages. The real cost lands in test suites — parallel fixtures share the map, and isolation is your problem. Registration being last-write-wins makes repeated scans harmless, which softens but does not remove it. Verdict: correct for what the library is; I would revisit it only if per-tenant schemas ever became a requirement, because “global” stops being true the moment two tenants disagree about an entity's current version.

The dictionary round-trip. Migrated payloads reach their typed form by serialising the dictionary back to JSON and deserialising into T (Part 4). One redundant serialisation per migrated read, in exchange for never re-implementing System.Text.Json's object construction, converters, and case handling. Every profiling pass I have done says the same thing: the database read dwarfs it. And the backfill exists specifically to make migrated reads rare. Cheap at twice the price.

Idempotent-by-construction operations. Every operation checks before it acts, so re-running low steps is safe (Part 3). The cost is subtle: conditionality can mask mistakes, because a mis-declared rename fires zero times rather than failing loudly. I accept that because the alternative — operations that trust the watermark absolutely — turns every fuzzy-watermark edge case (Part 4's int-vs-JsonElement parse, EF rows stamped in code) into corruption. Silence on the safe side beats noise on the destructive side.

Insert-before-delete draining. The drain choreography (Part 9) chooses transient duplication over any possibility of a lost row, then spends three guards (ON CONFLICT DO NOTHING, the timestamp predicate, the conditioned registry repoint) shrinking the duplication window. Any design that can lose a row during background migration is not a design; it is a countdown.

Costs I acknowledge and watch

Activator.CreateInstance everywhere. Type migrators and default providers are instantiated per payload, per operation, with no caching and no DI (Parts 3 and 5). The instantiation cost is real but small; the constraint — parameterless constructors, no injected services — is the actual design force, and I mostly like what it forces (read-path defaults that cannot do I/O). The uncached allocation, though, is pure waste; a ConcurrentDictionary<Type, instance> cache is the obvious fix and will happen. If your provider is stateless — they all should be — nothing observable will change.

default(TTo) on unconvertible values. Part 5 called this the sharpest edge in the library and I stand by that: a type migrator fed a value it cannot coerce returns the target type's default, silently. The mitigations are operational (backfill surfaces conversion surprises early, in logs) rather than structural. The structural fix is a strict mode — an option that throws with payload id and property name instead of defaulting — and it is the single highest-priority item on the list below.

First-wins attribute merging. A property carries at most one rename, one move, one type change across its whole declared history (Part 2). Two renames of the same property cannot be expressed; the second declaration silently loses. The workaround (a hand-written migrator for one of the steps) is fine; the silence is not. At minimum the scanner should log — or throw — when a merged stack discards a declaration.

The reflection tax at startup. Scanning assemblies, reading attribute stacks, building steps — all at AddVersionKit time. For hundreds of entities this is milliseconds and I have never seen it matter. It would matter for AOT-compiled or startup-critical scenarios; source generation is the known escape and nothing about the attribute model prevents it.

Debts, plainly labelled

Things in the codebase today that are not what the docs or symmetry would lead you to expect:

  • WriteThrough is decorative. VersionKitEntityFrameworkOptions.WriteThrough exists, the sample sets it — and no code reads it. Write-through updates are always on (Part 8). Either the flag grows its opt-out or it should be deleted; shipping a switch wired to nothing is worse than shipping no switch.
  • MoveOperation does not move. It renames (Part 2). The nested-path semantics its documentation gestures at (Address.City) are unimplemented; today it is RenameOperation with a different name and better intentions. The distinct type keeps declarations semantically honest and reserves the seam, but callers expecting restructuring will not get it.
  • PropertyVersionConvention rides the bench. A proper EF IModelFinalizingConvention exists — with richer annotations for future tooling — and is not wired in; ApplyVersionKitConventions duplicates its logic inline instead (Part 7).
  • The pluraliser is Name + "s". OrderOrders works; CategoryCategorys does not. GetTableName<T> prefers the EF model's real table name when it can, but the schema builders and the drain worker's name-matching lean on the naive convention. Custom table names need care today.
  • No test project. The repository ships a sample API and a validation script, and no unit tests. For a library whose whole pitch is trustworthy mechanical transformation of your data, that is the least defensible line on this invoice. The design is testable — pure operations over dictionaries, a walk you can drive with fixtures, MigrateTo built for step-isolation assertions (Part 4) — which makes the absence a scheduling failure, not an architectural one. It is being fixed before anything else on this page.

The accent, and the boundaries

VersionKit.EntityFramework speaks PostgreSQL. NOW(), timestamptz, ON CONFLICT, NULL::type casts, $1 parameters, CREATE OR REPLACE VIEW (Parts 7–9). The core JSON pipeline is dialect-free; the EF package targets the database I run. The SQL surface is a handful of builders, so a SQL Server port is a contribution-sized job, not a rewrite — but today, non-Postgres users get the core package only.

Guid Id, by decree. The EF writer reflects on a public Guid Id property and throws otherwise; the registry, archive deletes, and conflict clauses all assume it (Part 8). Composite and non-Guid keys are outside the current design.

Up only. There is no downgrade path anywhere in the library — payloads above the target return unchanged, never demoted (Part 4). Historical reads exist (MigrateTo), historical writes do not. I consider this a feature: down-migrations are where migration frameworks go to breed data loss.

When you should not use this

Fair is fair. Skip VersionKit entirely if: your stored payloads are short-lived (caches with TTLs shorter than your release cadence have no versioning problem); you can afford stop-the-world rewrites (small tables, generous maintenance windows — a one-off script is simpler than a framework); your schema changes are purely additive (nullable columns and tolerant readers need no machinery); or your history is so tangled that most steps would be hand-written migrators anyway — at which point the attributes are decoration and you want an explicit upcaster chain, which plenty of event-sourcing libraries do superbly. VersionKit's sweet spot is long-lived serialized entities with mostly mechanical histories and no tolerance for deploy-night data rewrites. That is a real and common spot; it is not every spot.

Where it goes next

The roadmap, in the order the previous sections imply: a strict mode for type conversion failures; loud rejection of discarded attribute declarations; cached provider and migrator instances; wiring or deleting WriteThrough; nested-path MoveOperation; generating VersionKitSql bulk-copy mappings from VersionTableSchema (the knowledge is already there — Part 7); a test suite that earns the library its own pitch; and, further out, source-generated migrators and a second SQL dialect.

Ten parts ago this series started with a sentence I still believe: your database remembers what your code forgot. The whole library is an attempt to make the code remember too — to turn schema history from an accident scattered across old commits into a declaration sitting on the property it happened to. The bill above is what that costs. Knowing exactly what you are paying is the only honest way to decide it is worth it — and for the systems I run, it is.

Thanks for reading the series. The code is the documentation's collateral: if a part contradicts the repository, trust the repository, then tell me.