Module 4: Branch by Abstraction
Deleting the old implementation
Overview
You reached the last step, and it's the one almost nobody does. You inserted the abstraction (lesson 2), built ModernShipping behind it (lesson 3), set up the flag (lesson 4), and validated with a parallel-run that the new one matches the old one (lesson 5). The flag is at 100%: all the traffic uses ModernShipping, the parallel-run has been at 0 discrepancies for days, and LegacyShipping receives not a single call. And this is where most migrations stop —"the new one already works, why touch anything more?"— and leave the legacy in the code "just in case." That "just in case" is the trap. The step that pays for the migration is the one that follows: deleting the old implementation.
Deleting the legacy isn't an optional cleanup detail. It's what turns a migration into something finished instead of a permanent state of two-systems-at-once. As long as LegacyShipping and ModernShipping coexist in the code, you pay a continuous tax: every future change to the shipping calculation has to be made in both implementations, and every time someone forgets one, the drift appears —the two diverge and the behavior depends on which one you landed on—. Deleting the legacy eliminates that tax at the root: there's once again a single source of truth. And with the legacy out, the flag no longer chooses between two —only one remains—, so the flag is also deleted, and the abstraction is left with a single implementation (and maybe it collapses, if it no longer earns its place).
This lesson executes it in two parts. First, an inventory of moving parts before and after the deletion: how many things you have to maintain with the two implementations and the flag, against how many are left after deleting. Second, a measurement of the cost of leaving the two "just in case": a new requirement arrives —an express zone— and it has to be put into each live implementation; if you edit only the modern and forget the legacy, an order routed to the legacy produces a real KeyError —the drift, executed—. Deleting the legacy makes that error impossible: there's only one place to apply the change.
Connection with the module. Lessons 2-5 built and validated the migration; this one finishes it, deleting the old. It's the step equivalent to "retiring the legacy" in the strangler (module 3, lesson 7): there the old service was deleted when the HTTP traffic reached 0; here the old implementation is deleted when the flag reached 100% and the parallel-run is clean. Notice the boundary: in the strangler, retiring the legacy is turning off and deleting a service (a process). Here it's deleting a class and a flag from the code. In both cases, the goal is the same and so is the resistance: the temptation to leave the old "on just in case," which turns the migration eternal.
An analogy: disassembling the plane's old engine
Return to the plane one last time. The valve (the flag) is at 100% on the new engine: all the thrust comes from it, and the old engine idles, doing nothing. You've been flying like this for days, with the new engine's readings perfect. Now you have two options.
The comfortable —and wrong— option is to leave the old engine mounted, off, "just in case". It sounds prudent: if the new one fails someday, there's the old one. But look at the real cost of carrying that dead engine. It weighs: the plane burns more fuel dragging it. It takes up space and wiring. And —worst of all— every time the maintenance team makes a change to the propulsion system, they have to apply it to both engines, including the one that doesn't fly, so they stay "the same just in case." If one day they forget to update the old engine and suddenly it has to be used, it starts with an old and incompatible configuration —and there the "just in case" kills you instead of saving you—.
The correct option is to disassemble the old engine. Once the new one has demonstrated, for enough time, that it pushes well, you remove the old one from the plane. The plane weighs less, burns less, and the maintenance team now only looks after one engine. And the safety? It doesn't come from carrying a dead engine: it comes from the new engine being well built and monitored, and from being able to land and fix it if needed (in the code: the legacy stays in the git history, recoverable if it were really needed, without weighing on the plane). An old engine mounted and unmaintained isn't a safety net; it's dead weight that also rots.
Disassembling the old engine is deleting LegacyShipping. Leaving it mounted "just in case" is the eternal migration. And the maintenance team that has to touch both engines for every change is the double maintenance we're going to measure.
Worked example: the deletion inventory and the measured drift
We're going to execute two things. First, an inventory of the moving parts —the things you have to maintain— before and after deleting the legacy. Second, a demonstration of the drift: a new requirement arrives (an express zone with rate 15.0), and we see what happens when the two implementations are still alive and someone edits only one.
# --- Part 1: inventory of moving parts, before and after deleting the legacy. ---
before = {
"implementations": ["LegacyShipping", "ModernShipping"],
"flag": ["FeatureFlag(rollout)"],
"wiring": ["resolve_calculator (chooses legacy vs modern)"],
}
after = {
"implementations": ["ModernShipping"],
"flag": [],
"wiring": ["get_calculator -> ModernShipping (direct)"],
}
def count_parts(inv):
return sum(len(v) for v in inv.values())
print("=== Part 1: moving parts before and after the deletion ===")
for k in before:
print(f" {k:<18} before : {before[k]}")
print(f" {'':<18} after : {after[k] or '(deleted)'}")
print(f"\n total moving parts: {count_parts(before)} -> {count_parts(after)}")
print(" the flag disappears; the abstraction is left with a single implementation.")
# --- Part 2: the cost of leaving the two "just in case". A new requirement arrives:
# an "express" zone with rate 15.0. With both alive, you have to edit EACH one;
# if you edit only modern and forget legacy, an order routed to legacy DRIFTS. ---
class LegacyShipping:
RATES = {"local": 5.0, "national": 10.0, "international": 25.0} # without 'express'
def cost(self, order):
return round(self.RATES[order["zone"]] + max(0.0, order["weight_kg"] - 1.0) * 2.0, 2)
class ModernShipping:
RATES = {"local": 5.0, "national": 10.0, "international": 25.0, "express": 15.0} # edited
def cost(self, order):
return round(self.RATES[order["zone"]] + max(0.0, order["weight_kg"] - 1.0) * 2.0, 2)
express = {"id": 99, "zone": "express", "weight_kg": 2.0, "order_total": 40.0}
print("\n=== Part 2: new requirement (zone 'express') with BOTH alive ===")
try:
print(" legacy.cost(express):", LegacyShipping().cost(express))
except KeyError as e:
print(f" legacy.cost(express): KeyError {e} <- DRIFT: forgot to edit legacy")
print(" modern.cost(express):", ModernShipping().cost(express))
print(" leaving both => each future change is 2 edits and a risk of drift.")
print("\n=== with the legacy already deleted: a single implementation ===")
print(" modern.cost(express):", ModernShipping().cost(express),
" (a single edit, drift impossible: there's only one source of truth)")
What to expect. When you run the file, the output is exactly this:
=== Part 1: moving parts before and after the deletion ===
implementations before : ['LegacyShipping', 'ModernShipping']
after : ['ModernShipping']
flag before : ['FeatureFlag(rollout)']
after : (deleted)
wiring before : ['resolve_calculator (chooses legacy vs modern)']
after : ['get_calculator -> ModernShipping (direct)']
total moving parts: 4 -> 2
the flag disappears; the abstraction is left with a single implementation.
=== Part 2: new requirement (zone 'express') with BOTH alive ===
legacy.cost(express): KeyError 'express' <- DRIFT: forgot to edit legacy
modern.cost(express): 17.0
leaving both => each future change is 2 edits and a risk of drift.
=== with the legacy already deleted: a single implementation ===
modern.cost(express): 17.0 (a single edit, drift impossible: there's only one source of truth)
Read Part 1. Before the deletion, the system has four moving parts: two implementations (LegacyShipping, ModernShipping), a flag (FeatureFlag), and the wiring that chooses between the two (resolve_calculator). After the deletion, two are left: a single implementation (ModernShipping) and a trivial wiring that hands it over directly. The flag disappeared —there's no longer anything to choose between— and the abstraction was left with a single implementation behind it. total moving parts: 4 -> 2. Each part deleted is one less thing to maintain, understand, and that can fail. Deleting the legacy doesn't just remove LegacyShipping: it also removes the flag and simplifies the wiring, because those parts only existed to manage the coexistence.
Read Part 2, which is the heart of why the deletion matters. A new requirement arrives: an express zone with rate 15.0. The team adds it to ModernShipping.RATES —but forgets to add it to LegacyShipping.RATES, because they're two places and it's easy for one to slip—. Now look at what happens with an express order: modern.cost(express) returns 17.0 (base 15.0 + surcharge of 2.0 for the extra kg), but legacy.cost(express) throws KeyError 'express' —the legacy doesn't know that zone—. That KeyError is the drift, executed: the two implementations diverged, and now the behavior of an express order depends on whether the flag routed it to the modern (works) or the legacy (blows up). With the two alive, every future change is two edits and a risk of drift every time one is forgotten.
And read the third section: with the legacy already deleted, only ModernShipping exists. The express requirement is applied in a single place, modern.cost(express) gives 17.0, and the drift is impossible —there's no second implementation that can be forgotten to update—. That's the reward of the deletion: a single source of truth. Every future change is made once, in one place, with no risk of two copies diverging. The KeyError of Part 2 can't happen when there's only one implementation.
Deep dive: why 'just in case' is a trap, and what to do with the abstraction
The argument for leaving the legacy —"in case the modern fails, we have the old one as backup"— sounds reasonable but doesn't survive analysis. Let's break it down:
Argument: "I leave the legacy in case the modern fails"
│
├─ Is the legacy kept up to date with the changes?
│ YES -> you pay double maintenance for every change, forever
│ NO -> the legacy rots; if you ever need it, it no longer works (drift)
│
└─ Is it really your safety net?
The real net is: modern well built + parallel-run + git (recoverable).
The legacy mounted and unmaintained isn't a net; it's dead weight that also rots.
The "just in case" has only two endings, and both are bad. If you keep the legacy up to date, you pay the double maintenance eternally —every change in two places, with the drift risk of Part 2—. If you don't keep it up to date (the most common, because nobody wants to touch dead code), the legacy rots: it accumulates divergence with the modern, and the day "just in case" you'd come to use it, it would start with old and incompatible behavior —right when you need it most—. In neither of the two endings is the legacy a real safety net. The real safety net is another: a well-built and validated ModernShipping (the parallel-run), a flag to revert hot while the migration is in progress, and —once deleted— the git history, which keeps the legacy's code recoverable if it were really needed, without weighing on the live system.
One decision remains: what happens to the abstraction when there's only one implementation? Three paths:
- Keep it. If the
ShippingCalculatorabstraction earns its place for other reasons —there are plans for a third implementation (aPremiumShipping), or the decoupling makes the tests easier (you can inject aFakeShippingin the tests)— leave it. An abstraction with a single implementation today is fine if it's justified by the design, not just by the migration. - Collapse it. If the abstraction existed only for the migration —to be able to switch between legacy and modern— and adds nothing else, collapse it: the callers start using
ModernShippingdirectly (or the abstraction becomes the class itself). Less indirection, simpler code.
The rule: the abstraction is kept if it earns its place by the design; it's collapsed if it only served as migration scaffolding. There's no single answer —it depends on whether ShippingCalculator is useful beyond having allowed the switch—. What you do not do is leave the abstraction and the two implementations "just in case": that's not finishing.
Common mistakes
Leaving the two implementations "just in case," forever. What happens: the flag reaches 100%, the modern works, and the team leaves LegacyShipping and the flag in the code —"it doesn't bother anyone, and just in case"—. Why it happens: deleting code feels risky ("what if we need it?") and not deleting it doesn't cost today. How to spot it: you've spent months with the flag at 100% but LegacyShipping is still in the repository, and every change to the shipping calculation someone asks "does this go in the legacy too?". How to fix it: the deletion is part of the migration, not an optional extra. When the flag has been stable at 100% for enough time with the parallel-run clean, delete LegacyShipping, delete the flag, and simplify the wiring. The safety net isn't the legacy mounted (which rots): it's the validated modern and the git that keeps it recoverable. A migration without deletion isn't finished; it's frozen a step from the end, paying the double-maintenance tax indefinitely.
Deleting the legacy before the modern is stable. What happens: in a hurry to "finish," the team deletes LegacyShipping as soon as the flag hits 100%, without letting the modern be tested in production for a while. Why it happens: the satisfaction of "closing" the migration pushes to delete early. How to spot it: you deleted the legacy the same day you raised the flag to 100%, without days of stable operation or sustained parallel-run. How to fix it: the deletion goes after the modern demonstrated stability —the flag at 100% for enough time, the parallel-run at sustained 0 discrepancies, without incidents—. As long as that evidence doesn't exist, the legacy and the flag are your fast-reversion power (you lower the flag and go back to the old one on the spot). Deleting them too early takes away that reversion right when the modern is newest and least tested. Demonstrated stability first, deletion after: it's the symmetric of "validate before raising the flag."
Collapsing an abstraction that did earn its place. What happens: when deleting the legacy, the team also removes the ShippingCalculator abstraction "because there's only one implementation now," and makes the callers use ModernShipping directly —losing the decoupling that served for the tests or for a future third implementation—. Why it happens: "a single implementation doesn't need an interface" sounds like a sensible simplification. How to spot it: after collapsing, the tests that injected a FakeShipping can no longer, or the plan to add a PremiumShipping becomes a big refactor again. How to fix it: decide about the abstraction by its design value, not just by the current implementation count. If ShippingCalculator makes the tests easier (inject a double) or there are future implementations planned, keep it even though today it has a single implementation behind it. Collapse it only if it existed purely as migration scaffolding and adds nothing else. Deleting the legacy is mandatory; collapsing the abstraction is a separate design decision.
Exercises
Exercise 1 — The old engine mounted "just in case." In the analogy, leaving the old engine mounted and off "just in case" seems prudent. (a) What are the two possible endings of that decision, and why are both bad? (b) What is the plane's real safety net, if it isn't the old engine mounted? (c) Translate the two things to the code (the old engine and the real net).
See solution
(a) The two endings: (i) you keep the old engine up to date with every change to the propulsion system —you pay double maintenance eternally, and you risk drift every time you forget to update one—; or (ii) you don't keep it up to date —the old engine rots, accumulates incompatible configuration, and the day "just in case" you'd want to use it, it no longer starts well—. In (i) you pay forever; in (ii) your supposed net doesn't work when you need it. Neither of the two delivers the backup it promised.
(b) The real safety net is that the new engine is well built and monitored (well tested before trusting it with the flight, with its readings watched), and being able to land and fix it if needed. The safety comes from the quality and monitoring of the new one, not from dragging along a dead engine.
(c) The old engine mounted "just in case" = LegacyShipping left in the code after the flag at 100% (dead weight that rots and demands double maintenance). The real safety net = ModernShipping validated with the parallel-run + the flag to revert hot while the migration is in progress + the git history, which keeps the legacy's code recoverable if it were really needed, without weighing on the live system. "Land and fix" = being able to recover from git and correct, not carry the dead code in production.
Exercise 2 — The drift, read from the run. Part 2 showed legacy.cost(express): KeyError 'express' while modern.cost(express) gave 17.0. (a) What exactly caused the KeyError? (b) Why is this error an example of "drift" and not just any bug? (c) Why does deleting the legacy make this error impossible, instead of just improbable?
See solution
(a) It was caused by the express zone being added to ModernShipping.RATES but not to LegacyShipping.RATES. When legacy.cost does self.RATES["express"], the key doesn't exist and Python throws KeyError 'express'. The modern, which does have the key, computes 17.0 (base 15.0 + 2.0 surcharge). The error is born from a change being applied to one implementation and not to the other.
(b) It's "drift" because the two implementations, which were supposed to behave the same, diverged over time: they started identical (parity validated by the parallel-run), but a later change separated them. It's not an isolated bug in a function; it's the structural consequence of maintaining two copies that have to be synchronized by hand. Drift is the characteristic failure mode of leaving the two implementations alive: every change is an opportunity for them to desynchronize.
(c) Because the KeyError requires a second implementation (the legacy) that someone could forget to update to exist. If you delete the legacy, only ModernShipping remains: there's no second copy to synchronize, so there's nothing to forget. The express change is applied in the only place that exists, and that's it. Deleting the old one doesn't reduce the probability of the drift (as, say, a test that verifies the synchrony would); it makes it impossible, because it eliminates the condition that allows it —the existence of two sources of truth—. A single implementation can't diverge from itself.
Exercise 3 — Keep or collapse the abstraction? After deleting LegacyShipping, you're left with the ShippingCalculator abstraction and a single implementation (ModernShipping). For each scenario, decide whether you'd keep the abstraction or collapse it, and why: (a) the checkout's tests inject a FakeShipping that returns fixed costs, so as not to depend on the real calculation; (b) there's an approved plan to add PremiumShipping for VIP customers next quarter; (c) the abstraction was created only to be able to switch during the migration and no test or plan uses it.
See solution
(a) Keep. The tests inject a FakeShipping through the ShippingCalculator abstraction; that decoupling is valuable —it lets you test the checkout without depending on the real shipping calculation—. If you collapsed the abstraction, the tests would have to use the real ModernShipping or do fragile monkey-patching. The abstraction earns its place by the design (testability), not just by the migration: it stays.
(b) Keep. There's a second implementation planned (PremiumShipping) for next quarter. If you collapse the abstraction now, you'd have to reintroduce it in three months —repeating the work of lesson 2—. Keeping it leaves the switch point ready for when PremiumShipping arrives. The abstraction earns its place by a concrete future need: it stays.
(c) Collapse. The abstraction existed purely as migration scaffolding —to switch between legacy and modern— and nothing else uses it: no tests, no plans. With the migration finished, it's indirection without purpose. Collapse it: make the callers use ModernShipping directly (or make the abstraction be the class). Less indirection, simpler code. The rule that separates the three cases: keep the abstraction if it earns its place by the design (testability, concrete extensibility); collapse it if it only served as scaffolding. Don't leave it "in case someday" —that's the same "just in case" trap, now applied to the interface—.
Summary and next step
In this lesson you did the last step of branch by abstraction, the one that pays for the migration: deleting the old implementation. You saw, with the old engine disassembled from the plane instead of carried "just in case," that leaving the legacy isn't prudence but dead weight that rots and demands double maintenance. And you executed it in two parts: the inventory of moving parts dropping from 4 to 2 (the flag disappears, the abstraction is left with a single implementation), and the measured drift —a new requirement (express) edited only in the modern produced a real KeyError in the legacy, the drift executed, impossible once there's only one source of truth—. You learned why "just in case" is a trap with two bad endings (eternal double maintenance or rotted legacy), what the real safety net is (validated modern + git), and how to decide the abstraction's fate (keep if it earns its place by the design, collapse if it was only scaffolding).
Before moving on you should be able to: explain why deleting the legacy is part of the migration and not an extra; measure the cost of leaving the two implementations (double maintenance and drift); say why the real safety net isn't the legacy mounted; and decide whether to keep or collapse the abstraction according to its design value.
Lesson 7 goes up a level to explain the deep reason for the whole pattern: without a long-lived branch, to avoid merge hell. You've done the five steps (insert, build, switch, validate, delete) —but why do them in main, in small steps, instead of in a separate branch where you "do the refactor calmly" and merge at the end? You're going to execute the measured comparison: the same refactor on a long branch that doesn't integrate for weeks accumulates lines in conflict on merge, against 0 when each step integrates into main. Branch by abstraction is, precisely, what makes a big refactor possible without that long branch —and lesson 7 demonstrates it with numbers—.
Resources
- Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Fowler emphasizes that the pattern ends by removing the old implementation and, if appropriate, the abstraction: the migration isn't done until the old is deleted. This lesson's step. In English.
- Pete Hodgson, "Feature toggles are one of the worst kinds of technical debt" (martinfowler.com, 2017) — martinfowler.com/articles/feature-toggles.html. Why migration flags must be temporary and be removed as soon as they fulfill their function, instead of accumulating as debt —this lesson's deletion of the flag—. In English.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — on retiring the old code after the migration and not leaving dead paths "just in case," with the same reasoning applied to services. In English.
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — the legacy as code that weighs and scares; deleting what's no longer used is reducing that surface. The underlying motivation for not accumulating dead implementations. In English.