Module 7: Measuring Migration Progress

Defining "done": when the legacy gets deleted

Overview

You have the burn-down dropping (lesson 3), the fitness function watching that the legacy doesn't grow (lesson 4), and the ratchet fixing each advance (lesson 5). All the instruments point downward, toward zero. And here comes the question that gives meaning to the whole dashboard, the most important and most neglected of any migration: when is it over? It seems obvious —"when it reaches zero"—, but it hides two traps. The first: zero of what? The traffic can be at zero while there's still a shared table and a loose reference in the code. The second, deeper one: what does "finished" mean? For too many teams it means "turn off the legacy". And this lesson maintains that finished means delete —eliminating the legacy's code, deploy, and tables—, not leaving it off "just in case".

Defining a migration's "done" is writing, in advance and in a verifiable way, the list of conditions that all must be met to declare the migration complete. It's not a single metric; it's a checklist. legacy_calls == 0 (nobody calls the legacy), yes, but also: all the endpoints cut from the monolith, all the tables migrated, legacy_refs == 0 (the new code no longer depends on the legacy). And only when every box is checked, the migration is done, and the action that done authorizes is to delete the entire legacy. A checklist with objective boxes, and a concrete action that triggers on completion. Without that definition written beforehand, "finished" is a feeling, and feelings stay at 99% forever.

Connection with the module. This lesson gives the termination criterion all the previous instruments use: the burn-down reaches legacy_calls == 0 (a done condition), the fitness function with the ratchet reaches legacy_refs == 0 (another condition), and here we join them into a single verifiable definition. Lesson 7 shows what happens when there's no done criterion —the eternal migration that gets stuck without ever declaring itself finished—, and why having this criterion is what avoids it. Lesson 8 integrates it into the capstone's MigrationTracker. Notice the boundary: the mechanism of retiring a slice —deleting the legacy code, simplifying the facade— was executed in M3 (lesson 7 of that module) for an individual slice. Here we define the criterion that declares the migration complete as a set of measurable conditions, the point that triggers that retirement. M3 did the cut; here we define when it's authorized.

An analogy: the project handover with a sign-off and no scaffolding

When you hire a remodel, there's a formal moment called the project handover: the day you declare the work finished, sign a sign-off, and make the final payment. And that moment doesn't depend on it "looking finished" or on the contractor saying "it's done". It depends on a checklist: do the electrical installations work? are there no leaks? are the finishes complete? did they remove all the scaffolding, the trash, and the tools? did they hand you the keys? Only when all the boxes are checked do you sign the sign-off. If one is missing —a room left unpainted, there's a leak, they left the scaffolding "in case they come back"— the work is not received, no matter how impeccable 95% looks.

Notice two details of the handover that are exactly this lesson's point. First: it's a list, not a single thing. You don't sign because "the kitchen turned out nice"; you sign because everything on the list is done. A work can have a perfect kitchen and a leak in the bathroom, and it's not finished. Second, and more important: the handover requires that they take away the scaffolding and the tools. A work where everything functions but they left the scaffolding up "in case they have to come back" isn't finished —it's half done, with the house occupied by structures that no longer serve, getting in the way and costing the scaffolding's rent—. The work finishes when you can live in the house and the scaffolding is gone.

A migration's "done" is that handover. The list is the conditions (legacy_calls == 0, endpoints cut, tables cut, legacy_refs == 0): all checked, or it's not finished. And taking away the scaffolding is deleting the legacy —the code, the deploy, the tables—. A migration where the traffic is at zero but the legacy is still deployed "just in case" is the work with the scaffolding up: it looks finished, but it isn't, and the scaffolding costs. Done is signing the sign-off and seeing the scaffolding truck drive away.

Worked example: "almost" versus "truly finished"

We're going to execute the done criterion as a checklist of four conditions over Mercado's catalog. The done_report function evaluates each condition and only declares done = True if all are met. We run two states: state A, "almost" —the traffic no longer touches the legacy, but a shared table and a reference in the code remain—, and state B, truly finished —everything at zero—. Only the second authorizes deleting.

# Define the migration's "done". It's not a single metric: it's a list of
# conditions that ALL must be met. And done means DELETE the legacy,
# not leave it "off just in case".

ENDPOINTS_TOTAL = 6
TABLES_TOTAL = 3

def done_report(state):
    checks = {
        "legacy_calls == 0":     state["legacy_calls"] == 0,
        "endpoints cut == total": state["endpoints_cut"] == ENDPOINTS_TOTAL,
        "tables cut == total":    state["tables_cut"] == TABLES_TOTAL,
        "legacy_refs == 0":      state["legacy_refs"] == 0,
    }
    return checks, all(checks.values())

def show(title, state):
    checks, done = done_report(state)
    print(f"{title}")
    for name, ok in checks.items():
        mark = "[x]" if ok else "[ ]"
        print(f"    {mark} {name}")
    if done:
        print("    => done = True  ->  DELETE the legacy (code + deploy + tables)\n")
    else:
        pend = [n for n, ok in checks.items() if not ok]
        print(f"    => done = False ->  missing: {', '.join(pend)}\n")

print("The done criterion: ALL the conditions, not just one\n")

# State A: the traffic no longer touches the legacy, but 1 shared table and
# 1 reference in the code remain. It looks finished; it's NOT.
show("State A - 'almost' (traffic at 0, but remnants remain):", {
    "legacy_calls": 0,
    "endpoints_cut": 6,
    "tables_cut": 2,
    "legacy_refs": 1,
})

# State B: everything cut. Now yes: done -> delete.
show("State B - truly finished:", {
    "legacy_calls": 0,
    "endpoints_cut": 6,
    "tables_cut": 3,
    "legacy_refs": 0,
})

print("  'Traffic at 0' isn't done: the slice is migrated when the legacy")
print("  can be DELETED entirely, not when it stopped receiving requests.")

What to expect. When you run the file, the output is exactly this:

The done criterion: ALL the conditions, not just one

State A - 'almost' (traffic at 0, but remnants remain):
    [x] legacy_calls == 0
    [x] endpoints cut == total
    [ ] tables cut == total
    [ ] legacy_refs == 0
    => done = False ->  missing: tables cut == total, legacy_refs == 0

State B - truly finished:
    [x] legacy_calls == 0
    [x] endpoints cut == total
    [x] tables cut == total
    [x] legacy_refs == 0
    => done = True  ->  DELETE the legacy (code + deploy + tables)

  'Traffic at 0' isn't done: the slice is migrated when the legacy
  can be DELETED entirely, not when it stopped receiving requests.

Read the two checklists, because the difference between them is the whole lesson.

State A is the most dangerous of a migration, because it's the one that feels finished without being so. Look at its boxes: legacy_calls == 0 checked (the traffic no longer touches the legacy —the burn-down reached zero—), endpoints cut checked (the six endpoints already live in the modern). Two of four green, and the two most visible. A team that only looked at the burn-down and the endpoints would say "we finished": the traffic is at zero, the endpoints migrated. But two boxes are still unchecked: tables cut == total (2 of 3 —one table the modern and the legacy still share remains—) and legacy_refs == 0 (one reference in the new code that still calls the legacy remains). The verdict: done = False, the tables and references are missing. The migration looks finished wherever you look first (traffic, endpoints), but it isn't: if you deleted the legacy now, you'd break the modern, which still depends on that shared table and that reference. It's the work with the perfect kitchen and a leak in the bathroom.

State B is the complete handover: the four boxes checked. legacy_calls == 0, the six endpoints cut, the three tables cut, zero references in the code. done = True. And notice the action it triggers: not "turn off the legacy", but DELETE the legacy (code + deploy + tables). Now the entire legacy module can be eliminated —the repository code, the deployed service, and the tables nobody uses anymore— without breaking anything, because nothing depends on it anymore. It's signing the sign-off and seeing the scaffolding truck leave.

The final line is the heart of the lesson: "traffic at 0" isn't done. The slice is migrated when the legacy can be deleted entirely, not when it stopped receiving requests. The burn-down at zero is a necessary condition of done, but not sufficient —it's the most visible box, not the only one—. An honest done requires that all the legacy's dependencies be cut, because the legacy isn't dead while something, however small, still depends on it.

Deep dive: why done is a list, and why done is delete

Two ideas of this lesson deserve development, because they're the ones that resist most in practice: that done is a list and not a metric, and that done is delete and not turn off.

Why a list. A migration cuts the legacy on several fronts at once, and those fronts don't finish at the same time. The traffic is diverted (strangler front, M3); the endpoints are extracted (service front, M5); the tables are migrated (data front, M6); the references in the code are eliminated (code front). Each front has its own burn-down, and they reach zero at different moments. It's perfectly possible —and common— for the traffic to reach zero first (it's the most visible and what the business pushes) while a shared table or a code dependency lag behind, because they're less visible and "not urgent". If you define done by the most visible front (the traffic), you declare it finished with open fronts, and those open fronts are what prevent deleting the legacy. The list forces you to verify all the fronts, not just the one you see first. Done is the conjunction (all(...)), not the flashiest box.

   Front               Metric               Typically reaches 0...
   ─────────────────   ──────────────────   ────────────────────────
   traffic (M3)        legacy_calls == 0    first (visible, business pushes it)
   endpoints (M5)      endpoints_cut        soon (seen in the code)
   tables (M6)         tables_cut           late (less visible, "not urgent")
   code (fitness)      legacy_refs == 0     at the end (the last dependency)
                                            ┌──────────────────────────┐
   done = AND of the four  ─────────────────┤ only then: DELETE        │
                                            └──────────────────────────┘

Why delete and not turn off. "Turn off just in case" sounds prudent and is, in reality, the entryway to the eternal migration (lesson 7). Leaving the legacy deployed but disconnected has three real costs that don't disappear from being "off": the operating cost (it keeps consuming servers, licenses, maintenance —you pay for a system that serves no one—), the cognitive cost (every new developer finds it in the code, doesn't know it's dead, and loses time understanding it or modifies it thinking it's alive), and the risk cost (dead code without maintenance rots —unupdated dependencies, unpatched vulnerabilities— and is a trap: someone could reactivate it by mistake and reintroduce the bugs the migration left behind). Deleting eliminates the three costs; turning off eliminates none. And the fear that motivates the "just in case" —"what if we need it?"— has a technical answer: the code isn't lost when you delete it, git's history preserves it. Deleting the legacy from the live system isn't throwing it in the trash; it's taking it out of where it gets in the way, with a copy saved in the history in case the improbable case occurs. Done is delete because only delete reaps the migration's prize —shrink the system, stop maintaining two—.

There's an intermediate and healthy version, which you already saw in M3: the reversible retirement in stages. Instead of deleting all at once, you first disconnect the legacy (the modern no longer calls it, but the code remains) for a short period with an expiration date —one or two weeks that cover the rare cases (a month-end close, a nightly process)—, and if nothing breaks, you delete. That gives a margin of confidence without falling into permanence. The key is the expiration date: "disconnected two weeks and then deleted" is a plan; "disconnected forever just in case" is the eternal migration by another name. Done is declared when the deletion happens, not when the grace period begins.

A final clarification: done is defined at the start, not at the end. The checklist of conditions must be written on day one of the migration, in the plan, so everyone knows what finish line they're running toward. Defining done at the end —when you're already "almost"— is too late: by then everyone has their own idea of "finished", and the temptation to declare victory on the most visible front is maximum. The done written in advance is a contract with the future: "we won't say we finished until these four boxes are checked and the legacy deleted". That contract is what resists the pressure to declare finished a migration that's only at 99%.

Common mistakes

Declaring "migrated" with the old route still on. What happens: the traffic reaches zero, the team declares the migration finished, and the legacy stays deployed "just in case". Why it happens: legacy_calls == 0 is the most visible box and the one the business pushes, so it's taken as the end; and deleting the legacy is extra work without an immediate visible reward. How to spot it: the migration was declared "done" but the legacy is still in production, appears in the dashboards, and consumes resources months later. How to fix it: done isn't "traffic at zero", it's all the conditions met and the legacy deleted. Traffic at zero is necessary but not sufficient —the tables, the references, the deploy remain—. The migration finishes when the legacy is deleted, not when it stopped receiving requests. Putting "deleted" in the definition of done from day one avoids this premature declaration.

Defining done by the most visible metric instead of by the complete list. What happens: the team looks only at the traffic burn-down and, on seeing it at zero, considers the migration finished, without verifying the shared tables or the code references. Why it happens: the traffic is the most visible and easiest to measure; the shared tables and code dependencies are less visible and "not urgent". How to spot it: the migration is declared done with open fronts —a table the modern and the legacy still share, an import to the legacy that remained—; on trying to delete the legacy, something breaks. How to fix it: done is the conjunction of all the conditions (all(...)), not the flashiest box. Each front of the migration (traffic, endpoints, tables, code) has its own condition, and they reach zero at different moments; the list forces you to verify all. If on trying to delete the legacy something breaks, it's proof that done was badly defined —a box was missing—.

Leaving the legacy "off but present" indefinitely. What happens: the team disconnects the legacy but leaves the code and the deploy "just in case", with no date to delete it. Why it happens: deleting is scary ("what if we need it?"), and "off but present" seems a prudent middle ground. How to spot it: there are legacy modules disconnected months or years ago, without an owner, without maintenance, appearing in every code search and confusing every new developer. How to fix it: "off but present" is valid only as a grace period with an expiration date (one or two weeks to gain confidence, covering the rare cases). After that period without incidents, it's deleted. Permanent dead code is debt: it rots, becomes a trap, and contradicts the reason for the migration (to simplify). git's history preserves the code if it's really needed, so deleting isn't losing —it's taking out of the live system what no longer serves—. Done is declared with the deletion, not with the disconnection.

Exercises

Exercise 1 — Done or not? For each state of the catalog (endpoints total 6, tables total 3), say whether it's done and what's missing: (a) legacy_calls=0, endpoints_cut=6, tables_cut=3, legacy_refs=0; (b) legacy_calls=0, endpoints_cut=6, tables_cut=3, legacy_refs=2; (c) legacy_calls=5, endpoints_cut=6, tables_cut=3, legacy_refs=0; (d) legacy_calls=0, endpoints_cut=4, tables_cut=1, legacy_refs=3.

See solution
  • (a) DONE. The four conditions green: traffic at 0, the 6 endpoints cut, the 3 tables cut, zero references. The legacy can be deleted. It's state B of the example.
  • (b) NOT done. legacy_refs == 0 is missing: 2 references in the new code that still call the legacy remain. Even though the traffic, the endpoints, and the tables are at zero, those 2 references mean the modern still depends on the legacy —deleting it would break the modern—.
  • (c) NOT done. legacy_calls == 0 is missing: the legacy still serves 5 requests. Even though everything else is cut, there are 5 cases that depend on the legacy; turning it off would leave them unserved.
  • (d) NOT done. Three conditions are missing: only 4 of 6 endpoints cut, only 1 of 3 tables, and 3 references in the code. Only the traffic is at 0 —it's the most deceptive state, the one that "looks" advanced by the most visible box but has three open fronts—.

The rule is always all(...): done only if the four are checked. A single unchecked box means something still depends on the legacy, and while something depends, it can't be deleted.

Exercise 2 — The cost of "off just in case". A team declared the catalog's migration finished with the traffic at zero, but left the legacy deployed "just in case". (a) Name the three concrete costs of leaving it that way. (b) What's the technical answer to the fear "what if we need it?"? (c) How does a grace period combine with the requirement to delete?

See solution

(a) The three costs: operation (the legacy stays deployed, consuming servers, licenses, and maintenance —you pay every month for a system that serves no one—); cognitive (every new developer finds the legacy in the code, doesn't know it's dead, and loses time understanding it or modifies it thinking it's alive); and risk (dead code without maintenance rots —unpatched dependencies and vulnerabilities— and is a trap: someone could reactivate it by mistake and reintroduce old bugs).

(b) That the code isn't lost when you delete it: git's history preserves it. Deleting the legacy from the live system isn't throwing it in the trash; it's taking it out of where it gets in the way, with a copy saved in the history in case the improbable case of needing it occurs. The fear assumes deleting is irreversible, and it isn't —version control is exactly the net that makes deleting safe—.

(c) The grace period is a reversible retirement in stages with an expiration date: you first disconnect the legacy (the modern no longer calls it, but the code remains) for one or two weeks that cover the rare cases (a month-end close, a nightly process); if nothing breaks in that period, you delete. The combination is "disconnected with an expiration date, and then deleted". What isn't valid is "disconnected forever just in case" —that's the eternal migration—. Done is declared when the deletion happens, not when the disconnection begins; the grace period only gives confidence, it doesn't replace the deletion.

Exercise 3 — Write the done. You're going to start the migration of Mercado's orders module and you're asked to define its "done" on day one. (a) Write the checklist of conditions. (b) Why is it better to define it now than when you're "almost finishing"? (c) What concrete action does checking all the boxes trigger?

See solution

(a) The done checklist for orders: (1) legacy_calls == 0 —no orders request touches the legacy monolith—; (2) all the orders endpoints cut from the monolith and served from the modern; (3) all the orders tables migrated and none shared with the legacy; (4) legacy_refs == 0 —the new orders code has no reference to the monolith—. (orders-specific conditions could be added, like "all the nightly processes that touched orders repointed to the modern", because orders has more writes and batch flows than the catalog.) Done only if all are met.

(b) Because defining done at the start is a contract with the future: it fixes the finish line before the pressure and divergent opinions appear. When you're "almost finishing", everyone will have their own idea of "finished", and the temptation to declare victory on the most visible front (the traffic) will be maximum —exactly the weakest moment to define the criterion—. A done written on day one resists that pressure: "we agreed we don't finish until these boxes are checked and the legacy deleted". Defining it late is letting the migration be declared finished out of exhaustion, not out of criterion.

(c) Checking all the boxes triggers deleting the entire orders legacy: the module's code in the repository, the service/deploy if it had its own, and the legacy tables nobody uses anymore —with a possible short grace period with an expiration date before the definitive deletion—. The action isn't "declare in a meeting that we finished" or "turn off just in case"; it's the concrete deletion that reaps the migration's prize: orders stops being part of the legacy monolith, and the monolith is one slice smaller.

Summary and next step

In this lesson you defined a migration's done: not a single metric, but a list of conditions that must all be met (legacy_calls == 0, endpoints cut, tables cut, legacy_refs == 0), and a concrete action that triggers on completion: delete the legacy. You saw, with the project handover —the sign-off that's only signed with the whole list checked and the scaffolding removed—, that done is a checklist (not the most visible box) and that done is delete (not turn off just in case). And you executed it: state A "almost" (traffic and endpoints at zero, but a table and a reference pending) giving done = False, and state B truly finished (everything at zero) giving done = True → delete. You learned why done is a list (the migration cuts the legacy on several fronts that finish at different moments), why done is delete (the three costs of leaving it off, and that git's history makes deleting safe), and why done is defined at the start and not at the end.

Before moving on you should be able to: write a migration's done checklist; evaluate a state and say whether it's done and what's missing; explain why "traffic at zero" isn't done; and defend why done is delete and not turn off against the "what if we need it?".

Lesson 7 shows, in all its rawness, what happens when there's no done criterion: the eternal migration, the worst possible outcome —two systems maintained forever—. You're going to see, executed side by side, two migrations with the same speed in the first sprints: one without a done criterion that gets stuck in the last stretch (98.5%) and keeps paying the cost of two systems indefinitely, and another with a forcing function (a done deadline) that reaches zero and deletes the legacy. The accumulated cost that shoots up in one and stops in the other is the proof, in numbers, of why this lesson's definition of done isn't bureaucracy: it's what makes a migration finish.

Resources

  • Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. Fowler insists that the strangler's goal is for the old system to die —be retired, not coexist—: this lesson's done is the criterion that declares that death and authorizes the deletion. In English.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — the last mile of the migration: retiring (not just disconnecting) the functionality from the monolith, and why leaving it "just in case" perpetuates the problem. The source of why done is delete. In English.
  • Neal Ford, Rebecca Parsons, and Patrick Kua, Building Evolutionary Architectures (O'Reilly, 2nd ed., 2022) — on defining objective and verifiable criteria for an architecture's state; done as a checklist of measurable conditions is that idea applied to the end of a migration. In English.
  • Martin Fowler, martinfowler.com — the bliki with the entries on strangler fig and retirement of legacy systems that ground why "finished" means deleted and not off. In English.