Architecture

Legacy Code Modernization: Migration Discipline That Reaches Deletion

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.

12 min read · 8 Principles ·
Three-tiered mint-green staircase platform on a mint background, viewed from an angle.

TL;DR

  • All calls into a legacy subsystem pass through one adapter that exposes the new code's own types. Two entry points is not a migration, it is a fork.
  • A legacy code path gets a test that pins its current behavior before it gets modified, in the same change. The test records what the code does, not what it should do.
  • Every replaced implementation sits behind a named feature flag with the legacy path retained. A migration with no runtime path back is a deploy with no rollback.
  • Schema changes and the code changes that stop using the old shape ship in separate deployments, with both stores written inside one unit of work in between.
  • Backfills process bounded batches, record progress durably, and produce the same result when a batch is reprocessed. A backfill that must complete in one pass will not.
  • Every deprecated interface names its replacement, every shim states the condition of its own removal, and the legacy implementation is deleted in the change that removes its last caller.
  • Migration completion is a fleet property, not a team property. A team can finish its own migration while the estate accumulates half-migrated repositories faster than anyone retires them.

What Migration Discipline Actually Protects

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.

1. Every Legacy Call Goes Through One Adapter

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.

ANTI-PATTERN: legacy types leaking into every call site
// 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.
CORRECTED: one adapter, new types outward
// 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.

2. Pin Current Behavior Before You Change It

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.

ANTI-PATTERN: a test asserting what the code should do
# 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
CORRECTED: a test pinning what the code actually does
# 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.

3. Every Migrated Path Keeps a Way Back

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.

CORRECTED: named flag, legacy path retained until removal
// 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.

4. Schema and Code Migrate in Separate Deployments

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.

RULE OF THUMB
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.

5. Backfills Resume, Repeat, and Finish

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.

ANTI-PATTERN: one pass, position held in memory, blind append
# 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.
CORRECTED: bounded batches, durable cursor, idempotent write
# 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.

6. Every Shim Declares the Condition of Its Removal

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.

7. Unfinished Migrations Are a Fleet Property

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.

How Pandorian Enforces This Guide

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.

Caught at PR level

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.

Full scans

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.

Scheduled scans

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.

A Taste of the Guideline Package

RULE WHAT IT FLAGS
ARC_28095
A call site added to a function, endpoint, or field carrying a deprecation annotation or attribute, where a named replacement already exists in the same module.
ARC_32217
A migration that removes or renames a column or field in the same change that updates the code reading it, rather than splitting into an additive migration, a code change, and a later removal.
CQM_37763
A compatibility shim, adapter, or translation layer added for a migration with no named flag or constant stating the condition for its removal, referenced from the shim itself.
OBS_36652
A deprecated function, endpoint, or legacy flag branch marked for removal that emits no usage counter labelled with its code path identifier, leaving removal readiness unmeasurable.

FAQ

What is legacy code modernization?

+

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.

What is the strangler fig pattern?

+

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.

What is a characterization test?

+

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.

Is it ever acceptable to rewrite from scratch instead of migrating incrementally?

+

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.

How do I tell whether my migrations are actually finishing?

+

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.

Can a linter or code review catch this?

+

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.

Get the Guideline Package

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.

From Principles to Enforcement

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.