Legacy code modernization is usually planned as a risk problem: how do we avoid breaking production during the cutover. The cutover is rarely what breaks. What breaks is the year after it, when the old path is still running, the new path is still guarded by a flag nobody remembers, and both are now load-bearing.
Legacy code modernization is the practice of replacing a working implementation incrementally: routing calls through a controlled seam, keeping the old path available until the new one is proven, and then removing the old path deliberately rather than leaving it in place.
That last clause is the one that gets dropped. Every well-run migration produces an intermediate state where both implementations exist, and that state is genuinely safer than a big-bang rewrite. The problem is that the intermediate state has no natural end. Nothing crashes when a migration stalls at eighty percent. The flag stays. The shim stays. The old table stays, because deleting it requires someone to prove nothing reads it, and nobody is measured on proving that. The failure a VP recognizes is not an incident. It is a team that estimates a small feature at three weeks because the feature touches subscriptions, and subscriptions live in two places for reasons that predate everyone on the team.
A legacy subsystem should have exactly one entry point from new code, and that entry point should speak the new code's types rather than the legacy system's. When five modules call legacy functions directly, you have not created a migration boundary, you have created five migration boundaries, each retired separately by someone who understands both sides.
The symptom shows up when you try to replace the subsystem and find the work is not one ticket but eleven, spread across four teams, three of which are unaware they are in the migration. This is the state the strangler fig pattern exists to prevent: the new system grows around a controlled seam, and the seam is what you eventually delete.
Route everything through a single adapter module that translates legacy types into your domain types. The adapter is allowed to be ugly. It is the only place that is. The companion rule is that you never copy legacy code into a new module in order to change it there. Copying feels like progress and produces a second implementation with the same bugs and none of the same callers.
// billing/InvoiceService.java
LegacyCustomerRecord rec = LegacyBilling.lookup(custId);
if (rec.getStatusCode() == 4) { suspendBilling(custId); }
// reports/MonthlyReport.java
LegacyCustomerRecord rec = LegacyBilling.lookup(custId);
// ...and four more modules, each its own migration boundary.// legacy/BillingAdapter.java
public Customer lookup(CustomerId id) {
LegacyCustomerRecord rec = LegacyBilling.lookup(id.value());
return new Customer(id, Status.fromLegacyCode(rec.getStatusCode()));
}
// Every other module depends on Customer.
// Nothing outside this file names LegacyCustomerRecord.The adapter adds a hop and a translation layer that has to be maintained while both systems live. That cost is the point: it is visible, it sits in one file, and it is deletable.
A legacy code path gets a test recording its current observable behavior before it gets modified, and in the same change. Not a test of what the code should do. A test of what it does today, including the rounding that has been wrong since 2019 and the customers who now depend on it.
Michael Feathers named these characterization tests, and the naming matters because it removes the argument that blocks the work. Engineers avoid testing legacy code because they cannot say what the correct answer is. A characterization test does not need a correct answer. You write the assertion with a placeholder, run it, read the actual value out of the failure message, and encode that value. The test now documents reality, and any future change that alters reality announces itself.
# tests/test_pricing.py
def test_gold_discount():
# Asserts the intended rule, which this code has never implemented.
# Fails on day one, gets marked xfail, protects nothing.
assert legacy_discount(1000, "GOLD") == 900# tests/test_pricing_characterization.py
def test_gold_discount_current_behavior():
# Read out of the failure message, not out of the spec.
# Legacy rounds down to the nearest 5 before applying the rate.
assert legacy_discount(1000, "GOLD") == 895
# Any future change to legacy_discount now announces itself.The decision rule is small enough to enforce: if a diff modifies a legacy function and adds no test covering the modified behavior, the diff is not ready. That is stricter than a coverage threshold and easier to reason about, because it is scoped to the lines actually changing rather than to a percentage of the file.
A new implementation of existing behavior goes behind a named feature flag resolved from configuration, and the legacy path stays in the codebase until the flag is removed. Replacing a working implementation in a single change leaves exactly one recovery mechanism, which is a revert and a redeploy, at whatever hour the problem surfaces.
The distinction that matters is between deploy and release, which is the whole argument of feature toggles and the reason branch by abstraction beats a long-lived migration branch. Code ships dark. Behavior changes when a value changes.
// pricing/quote.go
func Quote(ctx context.Context, req Request) (Money, error) {
if cfg.Flags.Enabled("pricing_engine_v2") {
return v2.Quote(ctx, req)
}
return legacy.Quote(ctx, req)
}
// Recovery is a configuration write, not a build and a redeploy.For a migration that changes an answer rather than a mechanism, run both paths and compare before you trust the new one. GitHub's Scientist formalized this: call the legacy path, call the candidate, return the legacy result, and report every mismatch. Stripe used exactly this pattern to verify read paths during a subscriptions migration across hundreds of millions of objects.
The tradeoff is real. Every flag is a branch that has to be tested, reasoned about, and eventually removed, and flags that outlive their migration are the exact debt this guide is about. That is why principle 6 exists.
DIAGNOSTIC: take the last migration your team shipped. If reverting it today requires a code change rather than a configuration change, it was not a migration, it was a replacement with optimistic assumptions.
Removing or renaming a column never ships in the same change that updates its readers. Split it into three deployments: an additive migration, a code change, and a later removal. During the overlap, write to both the old and the new shape inside the same unit of work, and read from one designated source.
This is parallel change, also called expand and contract, and the reason it is not optional is ordering. Old application instances and new ones run simultaneously for some window during any rollout, so the database and the code cannot cut over at the same instant. Stop designing as though they can.
A deployment is not atomic. Old and new instances run at the same time, so a schema that satisfies only one of them is an outage window you scheduled on purpose.
The dual-write requirement carries a subtlety that gets missed: both writes belong inside one transaction or one unit of work, and exactly one store is designated as the read source. Two independent writes leave a window where a crash puts the stores out of agreement, and reconciling that afterwards is strictly harder than avoiding it.
The cost is three deployments instead of one, and a period where the schema carries redundant columns that a reviewer will ask about. In exchange, no deployment window has a wrong answer, and the removal step becomes a small change you can schedule rather than a risk you keep deferring.
A data backfill is a job that processes bounded batches, records its progress durably, and produces the same result when a batch is reprocessed. A backfill written as a single pass over a large table will be interrupted, and the recovery question then becomes where it stopped, which is unanswerable if the job kept its position in memory.
Idempotency is the second half, and it is the half that gets skipped. A backfill that appends unconditionally double-counts on retry. Upsert on a natural key so the operation expresses the desired end state rather than a delta. Bounded batching also protects the production database from the backfill itself, since finding the rows is often more load than writing them.
# backfill.py
rows = db.fetch("SELECT id, plan_id FROM subscriptions")
for r in rows:
db.execute(
"INSERT INTO subscription_prices (sub_id, price_id) "
"VALUES (%s, %s)", r["id"], r["plan_id"])
# Dies at row 4 million. Rerunning double-counts everything before it.# backfill_price_ids.py
cursor = checkpoint.load() or 0
while True:
rows = db.fetch(
"SELECT id, plan_id FROM subscriptions "
"WHERE id > %s ORDER BY id LIMIT 5000", cursor)
if not rows:
break
db.execute_many(
"INSERT INTO subscription_prices (sub_id, price_id) "
"VALUES (%s, %s) ON CONFLICT (sub_id) DO UPDATE "
"SET price_id = EXCLUDED.price_id", rows)
cursor = rows[-1]["id"]
checkpoint.save(cursor)If you cannot kill the process at a random moment, restart it, and arrive at the same final state, it is not a backfill. It is a one-shot script with optimistic assumptions, and the retry is not hypothetical: it is what the on-call engineer does at two in the morning when the job dies.
A superseded interface carries the language's deprecation marker with its replacement named in the message, no new call sites are added to it, and the implementation is deleted in the change that removes its last caller. Deprecation is not a label you apply and walk away from. Google's engineering handbook treats it as an orderly process of migration and removal rather than a state, and the difference between those two readings is whether anything ever ends.
Three mechanisms make the ending happen, and all three are code shapes rather than intentions. The first is direction. A deprecation marker with no named replacement tells a developer the thing is bad and gives them nothing to do about it, so they use it anyway. Name the successor in the message and the annotation becomes actionable. The second is a ratchet. Existing callers of a deprecated interface are debt; new callers are a policy failure, and they are why deprecation counts climb during migrations instead of falling. Adding a call to something marked deprecated should fail the build, not emit a warning that scrolls past.
The third is measurement with an expiry. Emit a counter on every deprecated path scheduled for removal, labelled with the path identifier, so the question of whether anyone still calls it is a dashboard query rather than a two-day investigation. Then give every compatibility shim a named constant or flag stating the condition under which it dies, referenced from the shim itself. An unmarked shim is indistinguishable from architecture six months later. The tradeoff is that the deletion commit has the least visible value and the highest perceived risk of any change in the migration. It is also the only one that pays the migration back.
Every principle above is a per-repository decision, and none of them are per-repository problems. A team can execute all six correctly, finish its migration, delete its shims, and the estate is still worse this quarter than last, because forty other repositories each carry a flag that has been on in production for eleven months and a deprecated client that three new services adopted last sprint.
This is the asymmetry that separates modernization from most engineering standards. A configuration mistake hurts the service that made it. An unfinished migration hurts everything downstream, because the deprecated interface stays callable and the old path stays a plausible thing to copy. A service scaffolded from a template that predates the migration inherits the pre-migration shape and resets the counter. The debt is contagious in a way that a slow query is not.
Doing this by review does not work at scale, and the reason is visibility rather than effort. A reviewer looking at one pull request cannot see that the flag it adds is the fourteenth open migration flag in that repository, or that the client it imports was deprecated in favor of one that already exists. Which migrations in this organization are stalled is not a question any individual reviewer is positioned to answer. Migration discipline shares this shape with authorization enforcement and with idempotency for event-driven applications: properties that are trivially correct in one file and only observable across an estate over time.
Every principle above resolves to a code shape rather than a judgment call. A direct call into a legacy subsystem from outside its adapter is a shape. A diff that modifies a legacy function and adds no covering test is a shape. So are a schema removal shipped with its readers, a backfill loop with no durable cursor, a new call site on a deprecated interface, and a shim with no stated removal condition.
A new caller of a deprecated interface, a column removal shipped alongside the code that reads it, a replaced implementation with no flag and no retained legacy path, a modified legacy function with no pinning test, a backfill that appends unconditionally. Violations surface with the line, the rule, and a suggested fix, while the decision is still being made.
An on-demand scan across every repository establishes the day-one baseline, including everything that predates the guideline. This is where the estate picture comes from: how many migration flags are open and for how long, which deprecated interfaces still have callers and how many, which shims carry no declared removal condition, which legacy subsystems are reached from more than one module.
Drift that no pull request introduced. A flag on in production for a year with the legacy branch still in place. A deprecated path whose usage counter has read zero for two months and could have been deleted. A new service scaffolded from a template that predates the migration. Drift here is measured in the absence of change, which is exactly what review cannot see.
Legacy code modernization is the practice of replacing a working implementation incrementally rather than rewriting it: routing calls through a controlled seam, pinning current behavior with tests, keeping the old path behind a flag until the new one is proven, and then deliberately deleting the old path. The deletion step is what separates modernization from accumulation.
The strangler fig pattern replaces a legacy system by growing a new one around it, intercepting calls at a boundary and routing an increasing share to the new implementation until the old one carries no traffic and can be removed. Its characteristic failure is stopping partway, which leaves both systems running and both requiring maintenance.
A characterization test records what a piece of code currently does rather than what it is supposed to do. You write an assertion with a placeholder, run it, read the actual value out of the failure output, and encode that as the expectation. The result is a regression net for code whose correct behavior nobody can state, which is the normal condition for legacy systems.
Yes, when the system is small enough that a full rewrite fits inside one release cycle, or when it carries no production traffic worth preserving. The threshold is honesty about duration. If the rewrite runs longer than one release cycle, old and new will coexist, and coexistence without a seam, flags, and a deletion plan is the worst of both approaches. The real question is not rewrite versus migrate, it is whether anything in your process forces an ending.
Pick the three oldest feature flags in your largest repository and answer four questions for each. How long has it been on in production? Is the legacy branch still present in the code? Does anything emit a usage metric for that branch? Is there a named condition, anywhere in the repository, stating when the flag gets removed? If you cannot answer the fourth for any of the three, your migrations are not finishing, they are pausing.
A linter catches roughly one and a half of these seven principles. Deprecation annotations are checkable, and some ecosystems warn on new callers, though almost none fail the build by default. Nothing in a linter knows that a flag has been on for eleven months, that a shim has no removal condition, that a schema change shipped alongside its readers three deploys ago, or that a service scaffolded last week inherited a pre-migration shape. Review catches less, because a reviewer sees one diff and the question here is about state across an estate over time. That is the gap a code governance platform closes: the same guideline enforced on every pull request and re-checked across every repository on a schedule.
You'll get this as a guideline MD file. Hand it straight to your own coding agent or CI setup. Or skip the DIY and let Pandorian enforce all thirteen authorization rules on every pull request and every scan.
Every rule in this guide is easy to agree with in a design review and easy to lose in the fourth service created this quarter. Pandorian turns them into checks that run on every pull request and across every repository you own.