migrations

The incremental migration playbook: derisk, enable, finish

A big-bang cutover needs every team ready on the same day; an incremental migration needs one team ready at a time. The patterns, the tooling, the long tail, and the honest ledger for a rewrite.

By Andrei Gaspar · Editor, Tech Debt Weekly

Dark folds of cloth lit along their edges by a soft ember glow

Will Larson's claim, in Migrations: the sole scalable fix to tech debt, is that once an organization is past a certain size, migrations are the only mechanism that pays debt down at all. Everything else is local: a team refactors what it owns, and the interest on everything shared keeps compounding. A migration is the change that has to happen in a hundred places owned by forty teams, none of whom asked for it.

The big-bang cutover fails at that scale not because it is technically wrong but because it requires every one of those places to be ready on the same day. The incremental alternative is a sequence of small, reversible states in which the old and the new coexist. The whole craft is in making that coexistence cheap, short, and above all finished, because until the old thing is deleted you are paying interest on two systems instead of one.

This is the playbook: the three patterns that let old and new run side by side, Larson's derisk, enable, finish arc, the tooling that makes the last mile affordable, and an honest account of when a rewrite is the right call.

Why the cutover keeps losing

A cutover has one date, and on that date everything either works or it does not. The unit of rollback is the whole system. The test window before the date has to be long enough to exercise every caller. During that window the old system keeps changing, so the new system is chasing a target that moves every day. Every team that has not migrated is a blocker for the date, so the date slips to the slowest team, and the slowest team is by definition the one with the least reason to care.

The typical result is a cutover that never quite happens: the new system reaches eighty percent, the date slips twice, the sponsor moves on, and the organization is left with two systems, both taking traffic, both needing patches. That state is worse than never having started. Before the migration you paid interest on one system. Now you pay it on two, plus the coordination cost of keeping them in step.

The incremental approach trades one big irreversible step for many small reversible ones. Each step is deployable on its own, each can be undone on its own, and no step requires more than one team to be ready at once. The price is that you have to design the intermediate states deliberately, which is what the three patterns are for.

Three patterns for running old and new side by side

Martin Fowler's Strangler Fig is the pattern for a boundary you can route at. Put a facade in front of the old system, move one capability at a time behind it to the new implementation, and let the old system shrink until what is left can be switched off. Every capability moved is a step; every step can be routed back. Where it bends: the facade has a habit of becoming permanent architecture, and if the old and new systems share a database, the fig cannot strangle the part that couples them. The routing layer moves the calls; the schema is where the real migration lives.

Parallel Change, also known as expand and contract, is the pattern for an interface or a schema. Expand: add the new interface, column or field alongside the old one, so both exist. Migrate: move callers over one at a time, dual-writing where reads and writes have to stay consistent. Contract: once nothing reads the old shape, remove it. For a column rename that is six deployable steps: add the column, write to both, backfill, switch reads, stop writing the old column, drop it. Each step is safe to ship on a Tuesday afternoon and safe to revert on Wednesday morning. Where it bends: the contract step is the one that gets skipped, because it delivers nothing visible, and a schema full of half-contracted columns is a migration that quietly became permanent.

Branch by Abstraction is the pattern for a dependency you cannot route at the edge: an in-process library, a client, a subsystem everything calls directly. Introduce an abstraction over the old implementation, move callers onto the abstraction, build the new implementation behind it, flip, then delete the old implementation and, if it no longer earns its place, the abstraction. It is the trunk-based alternative to a long-lived migration branch, and its main virtue is that the codebase is shippable at every commit. Where it bends: the abstraction gets shaped like the old implementation, because that is what it was extracted from, and you end up migrating callers to an interface only the old system can satisfy well. Derisk the new implementation against the abstraction before you move the callers, not after.

PatternWorks atUnit of rollbackWhere it bends
Strangler figA network or routing boundaryOne routed capabilityShared database underneath; permanent facade
Parallel changeAn interface or schemaOne caller, one columnThe contract step never happens
Branch by abstractionAn in-process dependencyOne caller behind the seamAbstraction shaped like the old thing

Most real migrations use two of the three. A service extraction is a strangler fig at the edge and a parallel change in the schema. A framework upgrade is a branch by abstraction around the framework's entry points and a parallel change in every configuration file.

Derisk, enable, finish

Larson's arc is three phases with different shapes of work, and the failure mode of most migrations is spending the effort in the wrong phase.

Derisk is proving the design on the hardest cases first. Write the design, then migrate two or three representative units by hand, including the ugliest one in the fleet. If the approach does not survive the ugliest case, you want to know before anyone else has been asked to do anything. The output of derisk is not a migrated service; it is a design that has been shown to work on the cases most likely to break it, and a realistic cost per unit.

Enable is making the migration self-service. This is where most of the effort belongs and where most teams underinvest, because it feels like building tools instead of migrating things. The output is a codemod or a script that does most of the mechanical work, a guide that a team can follow in an afternoon without talking to you, a dashboard that shows what is left and who owns it, and a lint rule that stops new code from using the old path. When enable is done well, the cost per unit drops from a week of the owning team's time to an hour. That is the difference between a migration that finishes and one that does not.

Finish is the phase nobody assigns. The last ten percent of units take as long as the first ninety, because they are the ones the owning teams had good reasons to leave alone: the oldest code, the most fragile, the ones whose team has been reorganized twice. Finishing is also the only phase that pays anything back, because the interest on two systems only stops when the old one is deleted. A migration that reaches ninety percent and stops has cost the organization more than the debt it set out to fix.

Sponsored: Gitdailies — Install today. Move faster daily.

Tooling is what makes finishing affordable

The measure that decides whether a migration finishes is the cost per migrated unit, multiplied by the number of units, compared against the time anyone has budgeted. If migrating one service takes a week of its owning team and there are sixty services, that is sixty team-weeks nobody planned for, and the migration will stall at whichever team says no first. The tooling's job is to drive the cost per unit down until the objection disappears.

Codemods do the mechanical part. jscodeshift for JavaScript, LibCST for Python, OpenRewrite for the JVM, go fix and cargo fix in their ecosystems: these rewrite source against a syntax tree rather than a regex, which is what makes them safe to run over a thousand files. The transform is rarely the hard part. The rollout is. A codemod that produces one four-hundred-file pull request has produced something nobody will review; the same codemod run per owning team, with that team's tests, produces forty reviewable changes that each land in a day. Better still, run it in CI on every branch so that the old pattern is rewritten before it merges, and the migration proceeds without anyone opening a ticket.

Lint migrations are the ratchet. A rule that forbids the old pattern in changed code only, and a count of remaining occurrences that is allowed to go down and never up, does two things at once. It stops the migration from losing ground while it runs, and it turns every ordinary change into a small step forward. Start it as a warning, promote it to an error once the count is low enough, and delete it when the count hits zero. The deletion of the rule is part of the definition of done.

Deprecation instrumentation answers the question every migration eventually has to ask: who is still using this? A runtime warning that logs the call site, aggregated into a count by team, is worth more than any spreadsheet, because it is true. Some organizations go one step further and schedule short, announced brownouts of the deprecated path, so that the callers who did not read the announcement find out on a Tuesday afternoon rather than on the shutdown date.

Dependency automation is the tooling that prevents the migration from being needed in the first place. Small, automated upgrades every week mean the version gap never grows to the size where crossing it is a project.

The rule of thumb: if you find yourself writing a runbook for a migration, ask whether the runbook could be a script, and if it could be a script, ask whether it could run in CI.

Deprecating an internal API across dozens of teams

The internal API case is the one Larson's framing was built for, and it has a sequence.

Announce the deprecation with a date and a reason. The reason matters: teams migrate faster when they know what the old thing is costing, and slower when it reads as a platform team's preference.

Ship the replacement first, and prove it on the derisk cases. Nobody migrates to something that does not exist yet.

Measure the callers. Not the callers you think exist; the ones the instrumentation reports. The list is always longer than the announcement went to.

Migrate the top of the list yourself. The highest-traffic callers and the most reluctant owners are the ones whose migration teaches you the most about the tooling, and doing them yourself removes the two most common reasons for the date to slip.

Tool the rest, and ratchet. This is the enable phase applied to one API.

Then own the long tail. This is the point that decides whether the migration finishes, and it is a policy question rather than a technical one. The team that wants the old API gone has the incentive to finish; the forty owning teams do not. So the migrating team owns the long tail: after the announced date, it may open the migration pull request against any remaining caller, and the owning team's obligation is to review it, not to write it. Codify it, and codify what finished means: the old code is deleted, the instrumentation reports zero, the lint rule is gone. New code existing is not finished.

The failure modes all have the same shape, which is that finishing did not happen. Two systems forever: the migration hit the long tail, the sponsor moved on, and the organization now pays interest on both. The volunteer migration: the owner was someone who cared rather than someone who was assigned, and they left or got busy. The migration that grew: every "while we are in there" added scope, and every addition pushed the deletion date out, until the deletion stopped being a date. The shadow migration: the new system shipped before it was derisked, and it now has its own debt and its own migration ahead of it.

When a rewrite is right, and the ledger when it was not

Joel Spolsky's Things You Should Never Do, Part I is the piece every rewrite proposal has to answer, and its argument deserves to be taken seriously rather than quoted. The old code is ugly because it has been fixed. Every strange conditional is a bug someone found in production. A rewrite discards those fixes, and while it is being built the organization ships no new features and the old system keeps taking bug fixes the new one has to catch up with. The essay was written about a browser rewrite in 2000, but the mechanism is general: a rewrite is a migration with a single, enormous finish step and no interest relief until it lands.

That is what makes the ledger for a rewrite unfavorable. Principal is the entire cost of reaching parity, paid up front. Interest on the old system continues at the full rate throughout. Parity is a moving target, because the old system keeps changing. And the finish step is a cutover, with everything a cutover implies.

Spolsky's argument is not scripture, though, and its weakest assumption is that the old system's knowledge is recoverable only from its code. When the behavior is captured elsewhere, in a thorough test suite, in recorded production traffic you can replay against both systems, in a specification someone maintains, the cost of rediscovery drops, and with it the case against starting over. Three conditions make a rewrite defensible. The unit is small enough that one team can rewrite it in one quarter and strangler-wrap it, which means it is an incremental migration of a single node. The platform underneath is going away on a date, and the incremental path costs more than the rewrite; rare, but real. Or the domain has changed enough that the old behaviors are liabilities rather than fixes, and you can enumerate which ones.

The honest ledger for a rewrite that was not defensible looks, as an illustration, like this: two years of two systems, the old one feature-frozen and still taking traffic; a regression list rediscovering, one incident at a time, the conditionals the old code had for a reason; the features a competitor shipped in the meantime, listed by name; and, in the worst case, a cutover that never came, leaving both systems in production and a third proposal to unify them. None of those costs appear in the rewrite proposal, and all of them appear in the ledger afterward.

If you cannot name the date the old system will be deleted and the person who owns that deletion, it is not a rewrite. It is a second system.

The one-pager to write before you start

Before the first unit is migrated, write a page that answers these questions, and keep it updated until the old thing is gone.

What is the unit of migration, and how many are there? What does migrating one cost now, by hand, and what should it cost after tooling? Which two or three units are the derisk cases, and is the ugliest one among them? What is the ratchet: the lint rule, the count, the dashboard? Who owns the long tail, by name, and after what date may they open pull requests against other teams' code? What is the deletion date? And what does finished mean: the old code deleted, the instrumentation at zero, the lint rule removed, the facade gone or explicitly kept?

If any of those has no answer, the migration is not ready to leave derisk.

Share

Comments

Loading comments…

Keep reading

This blog exists thanks to the support of our sponsors:

GitdailiesQA.techAppSignalSuperlinked