Module 7: Measuring Migration Progress
Keeping the legacy from growing back
Overview
The fitness function of lesson 4 has a subtle weakness, and this lesson closes it. As we built it, the fitness function compares against a fixed baseline: as long as the baseline is 4, any state with 4 or fewer references passes. But that allows something we don't want. Imagine a sprint reduces the references from 4 to 2 —real progress—. The baseline is still 4, so in the next sprint someone could raise it back from 2 to 4 (add two new dependencies to the legacy) and the fitness function would pass, because 4 is still less than or equal to the baseline of 4. The gained progress would be lost without the alarm sounding. The legacy shrank and grew back, and nobody noticed.
The solution is an old and powerful idea: the ratchet. A ratchet is a mechanism that only turns in one direction —it tightens, but doesn't loosen—. Applied to the fitness function: every time the references to the legacy drop, the baseline drops with them and stays there; it never rises again. If this sprint you reached 2, the baseline becomes 2, and from now on 3 would already be FAIL. The ratchet turns each advance into an irreversible floor: you don't just measure that the legacy doesn't grow relative to the last healthy state, you fix each improvement so it can't be undone. The migration becomes monotonic toward zero —each step forward is locked in—.
Connection with the module. This lesson perfects the instrument of lesson 4: the fitness function measured "the legacy didn't grow"; the ratchet guarantees "the progress doesn't roll back". Together they make the burn-down (lesson 3) reverse-proof: it not only drops, but can't rise again. And this lesson connects with the discipline that sustains every healthy migration: no new features are added to the legacy —all new functionality is born in the modern—, and the ratchet is how that discipline is enforced automatically. Lesson 6 will use the ratchet at zero (references to the legacy that can no longer rise above zero) as one of the done conditions. Notice the boundary: the ratchet is a technique of how to apply the fitness function of lesson 4; it's not a new architecture concept, but the discipline of moving the baseline in a single direction.
An analogy: the zip tie that only tightens
Think of a zip tie —those clamps you use to tie cables—. They have a ratchet mechanism: you put the tip through the head, pull, and the tie tightens. And here's the magic: you can keep tightening as much as you want, but you can't loosen it. The little internal teeth only let the strap advance in one direction. If you tighten it to point 5, it stays at 5; you can tighten it to 6, to 7, but it never goes back to 4. To loosen it you'd have to cut it —there's no going back by accident—.
That "only tightens, never loosens" mechanism is exactly what the fitness function with a fixed baseline lacks. With a fixed baseline, it's like a cord with a slipknot: you tighten it to 2, but it can be loosened back to 4 with nothing stopping it. With the ratchet, it's a zip tie: every time you tighten (drop the references to the legacy), the baseline stays at that point and can't go back. The progress is mechanically fixed, not by good will.
The beauty of the zip tie is that it doesn't require anyone to remember to keep it tight —the mechanism does it for you—. Same with the baseline ratchet: you don't depend on the team remembering to "not let the legacy grow back beyond our best point"; the number is stored, versioned, and the fitness function enforces it on every PR. Each advance of the migration is locked in. And like the zip tie, the only way to "loosen" the baseline would be a deliberate and visible act —raising the number by hand in a commit, which everyone would see and question—, not a silent slip.
Worked example: the baseline that only drops, and the blocked attempt
We're going to execute the ratchet. The baseline starts at 4 (the initial count of references to the legacy). Sprint by sprint, the team reduces the references, and every time they drop, the ratchet tightens the baseline to the new minimum. At the end, with the baseline already at 0, someone tries to add a new reference to the legacy —and the ratchet blocks it, because 1 is no longer less than or equal to 0—.
# The baseline as a RATCHET: only drops, never rises. Each green sprint
# fixes the new allowed maximum; this way the legacy can't grow back and the
# progress toward 0 is monotonic. An attempt to add code to the legacy is blocked.
def migration_fitness(legacy_refs, baseline):
return legacy_refs <= baseline
# Ratchet: starts at the initial count and only tightens when we drop.
baseline = 4
print(f"The baseline ratchet (starts at {baseline}, only drops)\n")
print(f"{'sprint':>7}{'legacy_refs':>13}{'baseline':>10}{'fitness':>9} action")
print("-" * 62)
# Each sprint reports how many references to the legacy remain in the code.
sprint_refs = [4, 3, 2, 1, 0]
for sprint, legacy_refs in enumerate(sprint_refs):
ok = migration_fitness(legacy_refs, baseline)
if ok and legacy_refs < baseline:
action = f"green -> tighten baseline to {legacy_refs}"
baseline = legacy_refs # the ratchet drops, never rises again
elif ok:
action = "green (no change)"
else:
action = "RED -> block the merge"
fit = "PASS" if ok else "FAIL"
print(f"{sprint:>7}{legacy_refs:>13}{baseline:>10}{fit:>9} {action}")
print("-" * 62)
# --- Now the baseline is at 0. Someone tries to add a feature that
# imports from the monolith again: legacy_refs would go from 0 to 1. ---
print("\nAttempt to add new code to the legacy with the baseline already at 0:")
attempted_refs = 1
ok = migration_fitness(attempted_refs, baseline)
print(f" legacy_refs={attempted_refs} baseline={baseline} "
f"fitness={'PASS' if ok else 'FAIL'} -> "
f"{'gets in' if ok else 'BLOCKED: nobody revives the legacy'}")
print("\n The ratchet makes the progress irreversible: each advance is")
print(" fixed, and the legacy can't grow back even one import.")
What to expect. When you run the file, the output is exactly this:
The baseline ratchet (starts at 4, only drops)
sprint legacy_refs baseline fitness action
--------------------------------------------------------------
0 4 4 PASS green (no change)
1 3 3 PASS green -> tighten baseline to 3
2 2 2 PASS green -> tighten baseline to 2
3 1 1 PASS green -> tighten baseline to 1
4 0 0 PASS green -> tighten baseline to 0
--------------------------------------------------------------
Attempt to add new code to the legacy with the baseline already at 0:
legacy_refs=1 baseline=0 fitness=FAIL -> BLOCKED: nobody revives the legacy
The ratchet makes the progress irreversible: each advance is
fixed, and the legacy can't grow back even one import.
Read the baseline column from top to bottom: 4, 3, 2, 1, 0. That stepped descent is the ratchet tightening. Each sprint that reduces the references to the legacy (legacy_refs column) drags the baseline down with it and leaves it there. Notice the mechanism, sprint by sprint:
- Sprint 0:
legacy_refs = 4, equal to the baseline. There was no reduction, so the ratchet doesn't tighten: "green (no change)". The baseline stays at 4. - Sprint 1:
legacy_refs = 3, less than the baseline of 4. Progress. The ratchet tightens: the baseline drops to 3. From now on, 4 references would be FAIL —the state that used to pass is now forbidden—. - Sprints 2, 3, 4: the same, step by step. The baseline follows
legacy_refsdownward —2, 1, 0—, fixing each improvement. In sprint 4, withlegacy_refs = 0, the baseline reaches 0: the code no longer depends on the legacy at all, and that achievement is locked in.
And now the moment that shows the power of the ratchet. With the baseline already at 0, someone tries to add a new feature that imports from the monolith —legacy_refs would go from 0 to 1—. The fitness function evaluates 1 <= 0: FALSE, FAIL. The attempt is BLOCKED. With a baseline fixed at 4, that same attempt would have passed (1 is less than 4); with the ratchet at 0, it's impossible. Nobody can revive the legacy with even a single import, because the best point reached (zero) was fixed as the new ceiling.
Compare the two versions to see what the ratchet adds. With the fixed baseline of lesson 4, the fitness function said "don't grow beyond the starting point (4)" —which allows the legacy to oscillate between 2 and 4 forever—. With the ratchet, it says "don't grow beyond your best point so far" —which forces the migration to be monotonic: each improvement is permanent, and the only way is down—. The fixed baseline tolerates the back-and-forth; the ratchet only tolerates progress.
Deep dive: the discipline the ratchet enforces
The ratchet isn't just a technical trick; it's the automatic way of imposing a discipline that almost every successful migration needs and almost every eternal migration violates: no new features are added to the legacy. It's worth understanding why that discipline is so important and why it's so hard to sustain without mechanical help.
During a migration, the team lives with two systems: the legacy that's dying and the modern that's growing. When a new requirement arrives —a feature, a change—, there's an implicit decision in each PR: do I build it on the legacy or on the modern? Building it on the legacy is almost always faster today (the code is already there, the infrastructure already exists) and more expensive tomorrow (you just added something you'll have to migrate, lengthening the migration). Building it on the modern is slower today (sometimes you have to create the capability first) and cheaper tomorrow (it's born already migrated). The short-term pressure pushes toward the legacy; the migration's health demands the modern.
New requirement arrives during the migration
│
┌─────────┴──────────┐
on the LEGACY on the MODERN
(fast today) (sometimes slow today)
the legacy GROWS the legacy doesn't grow
more to migrate born already migrated
│ │
fitness FAIL fitness PASS
(the ratchet (the correct
blocks it) path)
Without the ratchet, that decision is made case by case, and under pressure the short term wins: "this time yes on the legacy, it's urgent". Each "this time" makes the legacy grow and lengthens the migration, and since the burn-down still drops at the front (traffic keeps being cut), nobody notices it's growing at the back. It's exactly how a migration that was going well becomes eternal: not through one big mistake, but through many small features slipped into the legacy "just this once". The ratchet takes that decision out of the hands of pressure: if building on the legacy raises the references above the baseline (which is now your best mark), the CI turns red and there's no "this time". The feature has to be born in the modern. The discipline stops depending on willpower and becomes a property of the system.
There's a practical detail about how the baseline is stored. In a real repository, the baseline doesn't live in a Python variable that resets each run; it lives versioned in the repository —a file with the current allowed number, committed alongside the code—. This way, tightening the ratchet (dropping the baseline) is an explicit and visible change in the history: when a sprint reduces the references, the same PR that makes the reduction updates the baseline file to the new minimum. And "loosening" the ratchet —raising the baseline by hand— would be a deliberate commit anyone would see in review and question ("why are you raising the limit of references to the legacy?"). The ratchet isn't infallible against bad faith (someone could raise the number), but it turns the silent regression into a visible and defensible act —which is exactly what keeps it from happening by carelessness—.
A clarification about when the ratchet shouldn't tighten too fast. If you measure the references sprint by sprint and the number oscillates for legitimate and temporary reasons (an intermediate refactoring that raises the count one day and lowers it the next), a ratchet that tightens at every instantaneous minimum could block work in progress. In practice, the baseline is tightened on stable states (at the close of a sprint, on merging to the main branch), not on every intermediate commit of a working branch. The rule stays "only drops", but it's applied on the checkpoints where the code is healthy, not on every instant. The spirit is to fix the real and consolidated progress, not to punish the natural back-and-forth of the work within a sprint.
Common mistakes
Leaving the baseline fixed and allowing the back-and-forth. What happens: the team sets the fitness function with a baseline that never updates, so the legacy can shrink and grow back within the baseline's range without the alarm sounding. Why it happens: a fixed baseline is simpler —one number and done—, and updating the baseline on each improvement seems an extra step. How to spot it: the references to the legacy rise and fall sprint by sprint with no clear direction, oscillating under the baseline; the reference burn-down isn't monotonic. How to fix it: turn the baseline into a ratchet that only drops. Every time the references reach a new stable minimum, drop the baseline to that minimum and store it versioned. This way each advance is fixed and the legacy can't recover ground. A fixed baseline measures "don't grow from the start"; a ratchet measures "don't grow from your best point", which is what makes the migration irreversible.
Continuing to build new features on the legacy "just this once". What happens: during the migration, each urgent requirement slips into the legacy because it's faster, with the promise of migrating it later. Why it happens: building on the legacy is always cheaper today (the code already exists), and the urgency of the short term weighs more than the migration's health. How to spot it: the references to the legacy rise in the sprints with new features; the burn-down at the front drops but the legacy grows at the back; the migration lengthens with no clear explanation. How to fix it: all new functionality is born in the modern, and the ratchet enforces it —if a feature raises the references above the baseline, the CI turns red and it doesn't get in—. It's not that the urgent features aren't done; it's that they're done on the new system, not on the one you're killing. "Just this once" repeated is how a healthy migration becomes eternal; the ratchet eliminates the "just this once".
Loosening the ratchet by hand to "unblock" a PR. What happens: a needed PR fails the fitness function, and instead of removing the legacy dependency, someone raises the baseline by hand so it passes. Why it happens: raising the number is the path of least resistance when there's a hurry and the CI is red. How to spot it: in the history, commits appear that raise the baseline of references to the legacy —the ratchet turning backward—. How to fix it: raising the baseline must be an exceptional, visible, and justified event, not a routine way to unblock PRs. Since the baseline is versioned, each increase is a commit the review must question: "why are we allowing more references to the legacy in the middle of a migration?". The answer is almost always "we shouldn't —remove the dependency instead—". The ratchet only protects if the team treats turning it backward as what it is: reverting the migration's progress, not a CI annoyance you silence by raising a number.
Exercises
Exercise 1 — The ratchet in action. The baseline starts at 5. The sprints report these references to the legacy: 5, 4, 4, 2, 3. (a) How does the baseline evolve sprint by sprint? (b) In which sprint would the fitness function give FAIL, if any? (c) What would have happened in the last sprint with a baseline fixed at 5 instead of a ratchet?
See solution
(a) The baseline follows the reached minimum, only downward: sprint 0 (refs=5) → baseline 5; sprint 1 (refs=4) → tightens to 4; sprint 2 (refs=4) → no change, stays at 4; sprint 3 (refs=2) → tightens to 2; sprint 4 (refs=3) → here 3 > baseline 2.
(b) In sprint 4. After the ratchet tightened to 2 in sprint 3, sprint 4 rises to 3 references —someone added a dependency back to the legacy—. Since 3 > 2, the fitness function gives FAIL and blocks the merge. The progress gained in sprint 3 (reaching 2) was fixed, and the attempt to roll back to 3 is stopped.
(c) With a baseline fixed at 5, sprint 4 (refs=3) would give PASS, because 3 <= 5. The regression from 2 to 3 would pass without an alarm: the legacy recovered a reference and nobody noticed. This is exactly the weakness of the fixed baseline that the ratchet closes —the fixed baseline tolerates the back-and-forth under its ceiling; the ratchet doesn't, because its ceiling is the best point reached (2), not the starting point (5)—.
Exercise 2 — The urgent feature. You're halfway through the catalog's migration (baseline at 1) and an urgent feature arrives whose fastest way to implement imports a function from the legacy monolith. (a) What would happen to the fitness function if you build it on the legacy? (b) What are your two legitimate options? (c) Why is it good that the CI forces you to choose one of them instead of letting you slip in the dependency?
See solution
(a) If you build it on the legacy, the references would rise from 1 to 2. Since 2 > baseline 1, the fitness function gives FAIL and the CI turns red: the feature's PR can't be merged. The ratchet blocks the feature as it's built (on the legacy).
(b) The two legitimate options are: (1) build the feature on the modern —create the capability in the new service, even if it takes more time today, so the feature is born already migrated—; or (2) first migrate the legacy function the feature needs (move discount_rules, or whatever, to the modern) and then build the feature on it —which additionally drops the references to the legacy—. Both maintain or reduce the count; neither makes the legacy grow.
(c) Because without that obligation, under the pressure of urgency, the shortcut would almost always win ("on the legacy, it's faster, we'll migrate it later"), and that repeated shortcut is how the migration becomes eternal. The red CI turns the implicit and pressured decision into an explicit and healthy one: it doesn't let you slip in the dependency by carelessness; it forces you to do the work on the right side. It doesn't prevent the feature from existing —it prevents it from existing on the system you're killing—. The mechanical constraint protects the migration from your own haste.
Exercise 3 — Storing the baseline. The text says the baseline must live "versioned in the repository". (a) Why isn't it enough to have it in a variable computed on the fly each run? (b) What advantage does it give that tightening the ratchet is an explicit commit? (c) How does this make an attempt to loosen the ratchet visible?
See solution
(a) If the baseline is computed on the fly (for example, "the baseline is the current count"), then it can never fail: it would always be comparing the code against itself, and any count would pass because it's always equal to itself. The baseline has to be a value saved from the past (the best point reached so far) against which to compare the present. And to persist between runs and between developers, that value must be in a versioned file, not in memory.
(b) That tightening the ratchet —dropping the baseline— is left in the history as a deliberate change. The same PR that reduces the references to the legacy updates the baseline file to the new minimum, so the progress is recorded and fixed in the repository, not in someone's memory. Anyone can see, in the history, how the baseline dropped from 4 to 3 to 2 to 1 to 0 —the record of the migration's advance—.
(c) Since the baseline is versioned, raising it (loosening the ratchet) would also be a visible commit: it would appear in the diff as "baseline: 1 → 2", and any reviewer would see it and ask "why are you allowing more references to the legacy?". A regression stops being silent —a dependency that slips in without anyone noticing— and becomes an explicit act that has to be justified in the review. That visibility is what keeps the regression from happening by carelessness: you can't loosen the ratchet without it being seen.
Summary and next step
In this lesson you closed the weakness of the fixed baseline with the ratchet: a baseline that only drops, never rises. You saw, with the zip tie that only tightens, that each advance of the migration is mechanically fixed —not by good will, but by a versioned number the fitness function enforces—. And you executed it: the baseline tightening step by step (4→3→2→1→0) as the references to the legacy dropped, and an attempt to add a dependency with the baseline already at 0 being blocked (1 <= 0 is FAIL). You learned the discipline the ratchet enforces —all new functionality is born in the modern, never in the legacy that's being killed—, why that discipline is so hard to sustain by hand (the short-term pressure pushes toward the legacy), and how storing the baseline versioned turns any regression into a visible and questionable act.
Before moving on you should be able to: explain why a fixed baseline tolerates the back-and-forth and a ratchet doesn't; simulate the evolution of a baseline-ratchet given a sequence of counts; argue why every new feature must be born in the modern; and explain why the baseline is stored versioned.
Lesson 6 answers the question that gives meaning to the whole dashboard: when is it over?. With the burn-down at zero and the ratchet at zero, it seems you finished —but "seems" isn't enough—. You're going to see that a migration's done isn't a single metric but a list of conditions that must all be met (legacy_calls == 0, endpoints cut, tables cut, legacy_refs == 0), and that done means something concrete and uncomfortable: deleting the legacy —code, deploy, and tables—, not leaving it "off just in case". You'll see, executed, an "almost" state that looks finished but isn't, and the truly finished state that authorizes the deletion.
Resources
- Neal Ford, Rebecca Parsons, and Patrick Kua, Building Evolutionary Architectures (O'Reilly, 2nd ed., 2022) — the book that covers fitness functions and the idea of fixing architectural properties so they don't degrade; the ratchet is the way to make the property "the legacy doesn't grow" irreversible. In English.
- Martin Fowler, "StranglerFigApplication" (2004) — martinfowler.com/bliki/StranglerFigApplication.html. The pattern demands that the legacy only shrink, never grow; the ratchet is how that single direction is guaranteed. In English.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — on the rule of not continuing to add to the monolith during its decomposition, and why violating it lengthens (or eternalizes) the migration. The discipline the ratchet automates. In English.
- Martin Fowler, martinfowler.com — the bliki with the entries on evolutionary architecture that frame the idea of constraints that only tighten (ratchets) to protect system properties. In English.