Module 7: Measuring Migration Progress
Project: measure and finish the Mercado migration
Overview
You reached the module's capstone. In the seven previous lessons you built a migration's instrument panel, piece by piece: you learned to measure the result and not the effort (L2), you built the burn-down of calls to the legacy with its velocity and its projection (L3), you wrote the fitness function that fails if the legacy grows (L4), you turned it into a ratchet that only drops (L5), you defined the done as a list of conditions that must all be met (L6), and you saw the disaster the panel exists to avoid: the eternal migration (L7). You know how to use each instrument separately. In this project you join them into a single one: a MigrationTracker that integrates the burn-down, the fitness function with the ratchet, and the done criterion, run as a migration journal of Mercado's catalog, sprint by sprint, until the legacy is turned off.
The journal is the most honest way to see the complete panel work together, because it shows what the isolated lessons can't: how the three instruments support each other over time. You're going to see the burn-down dropping from 1000 calls to 0; the fitness function catching, in sprint 3, someone who added new code to the legacy —putting the sprint on HOLD until it's reverted—; the ratchet tightening the baseline sprint by sprint (4→3→2→1→0) so the progress doesn't roll back; and, on reaching legacy_calls == 0 with all the conditions green, the done criterion triggering the deletion of the legacy. It's the whole movie of the module, not the loose snapshots.
And this project has a deliverable, like every capstone: (1) the progress panel —the executed journal with the three metrics per sprint—, (2) the executed code —the complete MigrationTracker class with its literal output—, and (3) the justification of why measuring the result is what makes a migration finish instead of dragging on forever. When you finish, you'll hold in your hands the instrument that turns a migration from "we worked a lot" to "here are the numbers, and here's the legacy deleted".
Connection with the module. This project integrates lessons 2 to 7 into one continuous execution, and closes module 7. It takes the burn-down (L3), the fitness function with the ratchet (L4-L5), and the done criterion (L6), and puts them to work together over a single migration that does reach zero —the opposite of the eternal migration of L7—. Notice the boundary: here we measure a migration whose real movement —the strangler that diverts the traffic, the service extraction, the data migration— was built by modules 3, 5, and 6. This tracker doesn't move anything; it puts on top the panel that says whether the movement advances and when it finished. Module 8, the capstone of the whole guide, will integrate the movement and the measurement into a single end-to-end modernization.
The project statement
It's your turn to build the MigrationTracker for Mercado's catalog migration and run it as a journal that takes it to the close. The tracker receives, sprint by sprint, the state of the migration —how many calls to the legacy remain, how many references to the legacy there are in the code, how many endpoints and tables were cut— and must:
- Measure the advance with the burn-down: for each sprint, the
traffic_percenton the new route and the velocity (how manylegacy_callswere burned relative to the previous sprint). - Protect the advance with the fitness function: for each sprint, verify that the references to the legacy (
legacy_refs) don't exceed the baseline, and tighten the ratchet (drop the baseline to the new minimum) on each healthy sprint. Iflegacy_refsexceeds the baseline, mark FAIL and put the sprint on HOLD (it doesn't advance until the code added to the legacy is reverted). - Declare the done with the complete criterion:
legacy_calls == 0, all the endpoints cut (6 of 6), all the tables cut (3 of 3), andlegacy_refs == 0. Only when all are met,done = True, and the action is to delete the legacy.
The journal you produce must make visible, in one line per sprint, the three things at once: the burn-down dropping, the fitness watching, and the done approaching. And it must end in legacy_deleted = True.
The rubric
Your deliverable is evaluated against these conditions —all verifiable by running the code, none of opinion—:
| # | Criterion | How it's verified | Passes if |
|---|---|---|---|
| 1 | The burn-down reaches zero | The journal's legacy_calls column | Drops from 1000 to 0, with traffic_percent from 0% to 100% |
| 2 | The velocity is calculated correctly | The vel column | It's the delta of legacy_calls vs the previous sprint; 0 in the HOLD sprint |
| 3 | The fitness function catches the growth | The sprint where legacy_refs rises | Marks FAIL and HOLD when legacy_refs > baseline |
| 4 | The ratchet only drops | The base column | Follows legacy_refs downward (4→3→2→1→0) and never rises |
| 5 | Done is the conjunction of all the conditions | The decision column | done = True only with the four boxes green |
| 6 | Done's action is to delete | The final line | legacy_deleted = True |
| 7 | Fixed data and reproducible output | Run it twice | The output is identical on each run |
A tracker that drops the burn-down but doesn't catch the code added to the legacy fails criterion 3 —it measures the advance at the front but doesn't protect the rear—. One that declares done with the traffic at zero but a table uncut fails criterion 5 —it confuses the most visible box with the complete list—.
An analogy: the instrument panel of a plane on approach
A pilot landing doesn't look at a single needle. They look at a panel where three instruments tell them, at once, three distinct and complementary things: the altimeter (what altitude they're at, dropping toward zero, toward the runway), the configuration alarm (if someone left the landing gear up or the flaps wrong, it sounds and won't let them continue), and the landing checklist (gear down, flaps, speed, clearance: all the boxes, or you don't touch ground). None of the three is enough alone. The altimeter says you're descending, but not that you're configured to land. The alarm says something is wrong, but not how much is left. The checklist says what's needed, but not whether you're getting there. Together, and only together, they take the plane to the runway safely.
The MigrationTracker is that panel, applied to a migration. The burn-down is the altimeter: the legacy_calls dropping toward zero, toward the runway, with the velocity telling you at what pace you descend. The fitness function is the configuration alarm: if someone adds code to the legacy —the equivalent of raising the gear when you should lower it—, it sounds (FAIL) and won't let you continue (HOLD) until you fix it. And the done criterion is the landing checklist: legacy_calls == 0, endpoints cut, tables cut, legacy_refs == 0 —all the boxes, or you don't declare you arrived—. A pilot who only looked at the altimeter could land with the gear up; a team that only looked at the burn-down could "finish" with the legacy still on. The complete panel is what avoids both.
Reference solution
Here is the complete MigrationTracker, integrating the module's three instruments, run as the journal of the catalog migration. The data of each sprint is fixed (so the output is reproducible); the metrics —traffic_percent, velocity, fitness, ratchet, and done— are derived from it. Try to build it yourself before opening the solution.
See the reference solution (complete code)
# The MigrationTracker integrates the module's three instruments -burn-down of
# legacy_calls (+traffic_percent), fitness function with ratchet, and done
# criterion- run as a journal of Mercado's catalog migration.
SAMPLE = 1000 # requests per sprint (base of the burn-down and the traffic_percent)
ENDPOINTS_TOTAL = 6 # catalog endpoints to cut from the monolith
TABLES_TOTAL = 3 # catalog tables to migrate
class MigrationTracker:
def __init__(self, baseline):
self.baseline = baseline # the fitness function's ratchet (only drops)
self.prev_calls = None # to calculate the velocity
def traffic_percent(self, legacy_calls):
return (SAMPLE - legacy_calls) / SAMPLE * 100
def velocity(self, legacy_calls):
return 0 if self.prev_calls is None else self.prev_calls - legacy_calls
def fitness(self, legacy_refs):
# PASS if the references to the legacy didn't exceed the baseline (ratchet).
return legacy_refs <= self.baseline
def ratchet(self, legacy_refs):
# the ratchet tightens: if we drop, the baseline drops with us and stays.
if legacy_refs < self.baseline:
self.baseline = legacy_refs
def done(self, s):
checks = {
"legacy_calls == 0": s["legacy_calls"] == 0,
"endpoints cut": s["endpoints_cut"] == ENDPOINTS_TOTAL,
"tables cut": s["tables_cut"] == TABLES_TOTAL,
"legacy_refs == 0": s["legacy_refs"] == 0,
}
return checks, all(checks.values())
# The migration journal, sprint by sprint (fixed data, reproducible).
# In sprint 3 someone adds code to the legacy (refs rise): the fitness CATCHES it
# and the burn-down stays still (HOLD) until revert.
sprints = [
# sprint, legacy_calls, legacy_refs, endpoints_cut, tables_cut
(0, 1000, 4, 0, 0),
(1, 700, 3, 2, 0),
(2, 450, 2, 4, 1),
(3, 450, 3, 4, 1), # someone added promo.py to the legacy: refs 2 -> 3
(4, 300, 2, 4, 1), # reverted: refs back to 2, the burn-down resumes
(5, 150, 1, 5, 2),
(6, 40, 1, 6, 2),
(7, 0, 0, 6, 3), # close: everything at zero
]
t = MigrationTracker(baseline=4)
print("Journal of Mercado's catalog migration (measured and finished)\n")
print(f"{'spr':>3}{'calls':>7}{'traf%':>7}{'vel':>6}{'refs':>6}{'base':>6}"
f"{'fit':>6}{'endp':>6}{'tab':>5} decision")
print("-" * 92)
deleted = False
for sprint, legacy_calls, legacy_refs, endpoints_cut, tables_cut in sprints:
state = {"legacy_calls": legacy_calls, "legacy_refs": legacy_refs,
"endpoints_cut": endpoints_cut, "tables_cut": tables_cut}
tp = t.traffic_percent(legacy_calls)
vel = t.velocity(legacy_calls)
fit_ok = t.fitness(legacy_refs)
checks, is_done = t.done(state)
if not fit_ok:
decision = f"fitness FAIL (refs {legacy_refs}>base {t.baseline}): HOLD, revert"
elif is_done:
decision = "done = True -> DELETE the legacy"
deleted = True
else:
pend = [n for n, ok in checks.items() if not ok]
decision = f"advances; {len(pend)} of done missing"
# the ratchet only tightens on healthy states (fitness PASS)
if fit_ok:
t.ratchet(legacy_refs)
t.prev_calls = legacy_calls
fit = "PASS" if fit_ok else "FAIL"
print(f"{sprint:>3}{legacy_calls:>7}{tp:>6.0f}%{vel:>6}{legacy_refs:>6}"
f"{t.baseline:>6}{fit:>6}{endpoints_cut:>4}/6{tables_cut:>3}/3 {decision}")
print("-" * 92)
print(f"\nDeliverable: the burn-down reached 0 calls to the legacy from 1000; the fitness")
print(f"function caught the new code added to the legacy in sprint 3 (HOLD until")
print(f"revert); the ratchet tightened the baseline 4->3->2->1->0; and with the four")
print(f"done conditions green, the legacy was DELETED. legacy_deleted = {deleted}.")
What to expect. When you run the file, the output is exactly this:
Journal of Mercado's catalog migration (measured and finished)
spr calls traf% vel refs base fit endp tab decision
--------------------------------------------------------------------------------------------
0 1000 0% 0 4 4 PASS 0/6 0/3 advances; 4 of done missing
1 700 30% 300 3 3 PASS 2/6 0/3 advances; 4 of done missing
2 450 55% 250 2 2 PASS 4/6 1/3 advances; 4 of done missing
3 450 55% 0 3 2 FAIL 4/6 1/3 fitness FAIL (refs 3>base 2): HOLD, revert
4 300 70% 150 2 2 PASS 4/6 1/3 advances; 4 of done missing
5 150 85% 150 1 1 PASS 5/6 2/3 advances; 4 of done missing
6 40 96% 110 1 1 PASS 6/6 2/3 advances; 3 of done missing
7 0 100% 40 0 0 PASS 6/6 3/3 done = True -> DELETE the legacy
--------------------------------------------------------------------------------------------
Deliverable: the burn-down reached 0 calls to the legacy from 1000; the fitness
function caught the new code added to the legacy in sprint 3 (HOLD until
revert); the ratchet tightened the baseline 4->3->2->1->0; and with the four
done conditions green, the legacy was DELETED. legacy_deleted = True.
Read the journal sprint by sprint, because each line is the complete panel at an instant, and the three metrics together tell the story of a migration that finishes.
Sprints 0 to 2 — the healthy descent. The burn-down drops 1000 → 700 → 450, with the traffic% rising from 0% to 55% and the velocity marking 300, 250 —the migration advances at a good pace—. In parallel, the fitness function gives PASS in each sprint (the references to the legacy drop 4 → 3 → 2), and the ratchet tightens the baseline behind them: 4 → 3 → 2. Each advance is fixed. The endp and tab columns also rise (0/6 → 4/6 endpoints, 0/3 → 1/3 tables): the other fronts of the migration progress. Nothing flashy, and that's exactly what you want to see: the three instruments pointing downward, toward zero.
Sprint 3 — the alarm sounds. Here's the heart of the project. The burn-down doesn't advance: legacy_calls stays at 450 (velocity 0). Why? Because the references to the legacy rose from 2 to 3 —someone added catalog/promo.py, a new feature built on top of the monolith we're killing—. The fitness function evaluates 3 <= 2 (the baseline the ratchet had tightened to 2 in sprint 2): FALSE, FAIL. The decision is HOLD, revert: the CI is red, the sprint doesn't advance until that legacy dependency is removed. Notice what the panel caught: without the fitness function, the traffic burn-down could have kept dropping at the front while the legacy grew at the back, and nobody would have noticed. The alarm sounded just in time.
Sprint 4 — the correction. promo.py was reverted (the feature is rebuilt on the modern, not on the legacy). The references go back to 2, the fitness gives PASS (2 <= 2), and the burn-down resumes: legacy_calls drops from 450 to 300 (velocity 150). The ratchet doesn't tighten (2 isn't less than 2, it stays at 2), but it didn't give up ground either: the progress of sprint 2 stayed protected. The migration returns to the descent.
Sprints 5 and 6 — closing fronts. The burn-down continues: 300 → 150 → 40, with the traffic% at 85% and then 96%. The references drop to 1, and the ratchet tightens to 1. The endpoints reach 6/6 (all cut) and the tables 2/3. Notice the decision column of sprint 6: "3 of done missing" —the panel counts how many done conditions are still open—. Even though the endpoints are all cut, legacy_calls == 0, the third table, and legacy_refs == 0 are missing.
Sprint 7 — the runway. The burn-down hits zero: legacy_calls == 0, traffic% = 100. The references reach 0 (ratchet to 0), the third table is cut (3/3), and with the six endpoints already cut, the four done conditions are green. done = True, and the action it triggers isn't "turn off" but DELETE the legacy. The final line seals it: legacy_deleted = True. The migration wasn't declared finished by feeling or by the most visible box; it was declared finished because the complete panel —burn-down at zero, fitness green, all the done conditions met— authorized it, and the legacy was really retired.
The journal tells the complete story of a measured migration in eight lines: a burn-down that descended to zero, a fitness function that caught an attempt to make the legacy grow and forced its reversion, a ratchet that fixed each advance, and a done criterion that only triggered the deletion when everything was at zero. Each instrument did its job, and —this is what the whole module wanted to demonstrate— together they made the migration finish, instead of staying at 98.5% forever.
The project deliverable
A capstone delivers artifacts, not just understanding. Here are the three:
1. The progress panel. The executed journal of the catalog, with the three metrics per sprint: the burn-down (legacy_calls, traffic%, velocity), the fitness function with the ratchet (refs, base, fit), and the advance toward the done (endp, tab, and the count of pending conditions). A single panel where you read, at a glance, whether the migration advances, whether the legacy is growing at the back, and how much is left to be able to delete it.
2. The executed code. The complete MigrationTracker class —traffic_percent, velocity, fitness, ratchet, and done— run with its literal output, the eight-sprint journal. It's not pseudocode or a description: it's the simulated tracker, reproducible, that you can run and modify (change the HOLD sprint, raise the references more, leave a table uncut at the end, and watch how the journal changes —or how the done refuses to trigger—).
3. The justification: why measuring the result is what makes a migration finish. The tracker didn't move a single call; the strangler (M3), the extraction (M5), and the data migration (M6) did the movement. What the tracker added was the termination. Without it, this migration would have had two paths to failure, both seen in L7. The legacy could have grown at the back (the sprint 3's promo.py) without anyone noticing, lengthening on its own. Or it could have been declared "finished" in sprint 6 —with the traffic almost at zero and the endpoints cut— leaving the third table and the last reference hanging, and the legacy on "just in case" forever. The tracker closed both doors: the fitness function prevented the silent growth, and the done criterion prevented the premature victory. That's the justification, and now you can back it with an executed journal that reaches legacy_deleted = True.
Transfer exercises
Exercise 1 — The sprint that wanted to declare done early. Imagine that in sprint 6, with traffic% = 96 and the endpoints at 6/6, the team wants to declare the migration finished. (a) What done conditions is it missing according to the journal? (b) What would happen if it deleted the legacy at that moment? (c) Which of the module's lessons predicted exactly this mistake?
See solution
(a) In sprint 6 three conditions are missing (the decision column says so: "3 of done missing"): legacy_calls == 0 (there are still 40 calls to the legacy), tables cut (2 of 3 —a table is missing—), and legacy_refs == 0 (1 reference remains in the code). Only the endpoints (6/6) are complete.
(b) If it deleted the legacy in sprint 6, it would break three things: the 40 requests the legacy still serves would be left without a response; the shared table the modern and the legacy still use would disappear (breaking the modern, which still depends on it); and the code reference that remains to the legacy would point to deleted code. Declaring done by the most visible box (the traffic almost at zero, the endpoints cut) with open fronts is exactly what breaks a migration on deletion.
(c) Lesson 6 (defining the done) predicted it: done isn't a single metric but a list of conditions that must all be met (all(...)), because the migration cuts the legacy on several fronts that finish at different moments —the traffic and the endpoints first (visible), the tables and the references at the end (less visible)—. The journal shows exactly that: in sprint 6 the visible fronts are closed but the invisible ones aren't. And lesson 7 (the eternal migration) showed the cost of giving in to this temptation: declaring "finished" with the legacy still on.
Exercise 2 — The ratchet that was missing. Suppose the tracker used the fitness function of lesson 4 (fixed baseline at 4) instead of the ratchet of lesson 5. (a) What would have happened in sprint 3 with legacy_refs = 3? (b) Why is that worse? (c) Which line of the journal would change?
See solution
(a) With a baseline fixed at 4, sprint 3 (legacy_refs = 3) would give PASS, because 3 <= 4. The fitness function would not catch the promo.py someone added: the legacy grew from 2 to 3 references, but since 3 is still below the starting baseline (4), the alarm doesn't sound.
(b) It's worse because the progress already gained would be lost silently. In sprint 2, the migration had dropped to 2 references —a real advance—. With the fixed baseline, rising back to 3 in sprint 3 passes without an alarm: the legacy recovered ground and nobody noticed. The ratchet turns each advance into an irreversible floor (baseline tightened to 2 in sprint 2), so rising to 3 in sprint 3 evaluates 3 <= 2 → FAIL, and it's caught. The fixed baseline tolerates the back-and-forth under its ceiling; the ratchet only tolerates progress.
(c) The sprint 3 line would change: instead of FAIL ... HOLD, revert, it would show PASS ... advances, and the burn-down would probably have "advanced" while the legacy grew at the back —the worst of worlds, a burn-down that drops at the front hiding a legacy that fattens behind—. The ratchet is what makes the journal tell the truth in sprint 3.
Exercise 3 — Extend the tracker to payments. You're going to start the migration of Mercado's payments module, which —unlike the catalog— has nightly batch processes and more writes. (a) What done condition would you add to the checklist for payments? (b) Why could the payments burn-down get stuck in a different last stretch than the catalog's? (c) What forcing function would you put from day one so it doesn't become eternal?
See solution
(a) I'd add a condition for the nightly batch processes: "all the nightly jobs that touched payments repointed to the modern and verified in at least one cycle (a month-end close)". The catalog was almost all synchronous reads; payments has batch flows that run outside the normal traffic and are easy to forget —a legacy_calls == 0 measured only over the synchronous traffic could give zero while a nightly job keeps touching the legacy once a month—. The done checklist has to include those less visible fronts, or the done would be false.
(b) Because the catalog's last stretch was the rare price cases (the volume discount); payments's would be those nightly batch processes and the exception flows (refunds, chargebacks, reconciliations) that run little and are hard to migrate. They're different rare cases, but the pattern is the same (lesson 3): the last stretch is the least visible and least frequent flows, and the burn-down flattens there if they're not attacked on purpose. Besides, having more writes, payments's data migration (dual-write, parallel-run) is more delicate, and its part of the burn-down may take longer to reach zero.
(c) From day one I'd put a committed and visible done deadline ("the payments legacy is deleted in such-and-such sprint, with an owner") and I'd make visible the cost of keeping the two payment systems alive on a dashboard each sprint —because in payments the cost of duplicating the operation (and the risk of reconciliation between two systems) is high and hurts fast—. And, preventively, I'd attack the last stretch early: I'd migrate the nightly batch processes in the first sprints, when there's energy and urgency, instead of leaving them for the end where the incentives abandon them (lesson 7). The tracker with the done criterion as the project's gate would do the rest: payments isn't declared finished until its legacy is deleted.
Summary and next step
In this capstone you integrated the whole module into a single executed instrument: you built the MigrationTracker that combines the burn-down (with velocity and traffic_percent), the fitness function with the ratchet, and the done criterion, and you ran it as the journal of Mercado's catalog migration. You saw the complete panel work together —like the altimeter, the alarm, and the checklist of a plane on approach—: the burn-down dropping from 1000 to 0, the fitness function catching in sprint 3 someone who added code to the legacy (HOLD until revert), the ratchet tightening the baseline 4→3→2→1→0, and the done criterion triggering the deletion only when the four conditions reached green. And you produced the capstone deliverable: the progress panel, the executed code, and the justification —backed by the journal— of why measuring the result is what makes a migration finish.
With this you close module 7. You master a migration's instrument panel: you know how to measure the result and not the effort, build and interpret the burn-down with its velocity and projection, write a fitness function with a ratchet that keeps the legacy from growing, define the done as a list of verifiable conditions, and recognize and avoid the eternal migration. You know, in one phrase, how to know you migrated —with numbers, not feelings—.
What follows is the end of the guide. Module 8 is the capstone of everything: so far you learned each technique separately —characterizing (M2), strangler (M3), branch by abstraction (M4), extracting a service (M5), migrating data (M6), measuring the progress (this module)—. Module 8 chains them all, in order, over a single slice of the catalog, end to end: it puts it under characterization tests, gets it behind a strangler facade, extracts it with an anti-corruption layer, migrates its data with dual-write and parallel-run, and —using the panel you just built— measures the progress until the legacy is turned off. This tracker you made will be one of the six instruments of the complete method. You're going to do the whole summit.
Resources
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — on measuring the progress of a monolith's decomposition by the functionality and calls retired from it, and on the discipline of finishing instead of coexisting forever; this project's
MigrationTrackeris that idea made a panel. In English. - Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. The pattern whose sign of success is that the legacy serves less and less until it serves nothing and is retired: exactly what the burn-down measures and the done criterion declares. In English.
- Neal Ford, Rebecca Parsons, and Patrick Kua, Building Evolutionary Architectures (O'Reilly, 2nd ed., 2022) — the book that defines the fitness functions as tests that guard architectural properties; this tracker's ratchet is how the property "the legacy doesn't grow" is made irreversible. In English.
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — the basis of the characterization that makes it possible to measure a migration safely; this module's panel measures the retirement of the code Feathers teaches to pin first. In English.