Module 4: Branch by Abstraction
The abstraction layer as a seam
Overview
In the previous lesson you saw the complete cycle of branch by abstraction and ran it in miniature. Now we start building it step by step, and the first is the most important and the one most people skip: inserting the abstraction layer. Before writing a line of the new implementation, before switching a single flag, you need a place where the "old or new?" decision can be made. That place, when there's no network boundary, is inserted in the code: it's an interface —a Protocol— between the callers and the implementation. It's what in the legacy-code literature is called a seam: a point where you can change the program's behavior without editing at that point, because you inserted a seam to reach in through.
The wonderful thing about the abstraction —and the reason it's the first step— is that it can be inserted without changing absolutely anything. The moment you put it in, on the other side there's only LegacyShipping, which wraps the old logic as is, without touching a formula. The callers now ask the abstraction for .cost() instead of calling the function directly, but the result is identical. The system behaves exactly the same. The only thing that changed is that now something that didn't exist before exists: a switch point through which all the calls to the shipping calculation pass, where tomorrow you'll be able to decide which implementation runs. You installed the shutoff valve closed on "old"; turning it is everything that follows.
This lesson executes it with a transparency test. We're going to compare, case by case, the result of the old shipping function called directly against the result of the same logic through the abstraction, and verify they're identical —including the edge cases—. That verification is what gives you the confidence to insert the abstraction in production without fear: mathematically, you didn't change the behavior.
Connection with the module. Lesson 1 gave you the complete cycle; this one does its first step: insert the abstraction as a switch point, with zero risk. Lesson 3 does the second step (build ModernShipping behind this same abstraction); lesson 4 does the third (switch with the flag) using this abstraction with the flag now different from "old." Everything that follows in the module assumes this step is done: without the abstraction there's nowhere to decide old vs new. Notice the boundary: the abstraction here is an internal switch point —it lives inside the code, inside the process—. Module 3's facade was an external proxy, interposed at an endpoint's network boundary. Here the shipping calculation isn't an endpoint: it's an internal function, so the switch point is inserted as an interface in the code, not as a proxy on the network.
An analogy: the shutoff valve before changing the pipe
Imagine you have to change a section of old pipe in your house —rusty, leaking— for a new one. The constraint, as always, is that the house is inhabited: you can't cut the water to the whole building during the days the change takes. The kitchen has to keep having water, the other bathroom too.
The first thing a good plumber does has nothing to do with the new pipe: they install a shutoff valve right before the section they're going to change. A tap that isolates that piece from the rest of the installation. When they install it, it changes nothing for the house: the water keeps passing through the old pipe via the open valve, with the same pressure, the same flow. Nobody in the house notices there's now a valve there. But for the plumber everything changed: now they have a point where they can shut off that section's flow, change the pipe behind the valve, and open it again —without touching the rest of the house's water—.
The shutoff valve is the abstraction. The section of pipe behind it is the implementation (LegacyShipping today, ModernShipping tomorrow). And the fact that installing the valve doesn't change anyone's water is exactly the transparency test we're going to execute. The valve doesn't carry different water or filter it or change its pressure: it only opens or closes the flow to what's on the other side. An abstraction that does more than that —that validates, transforms, "takes the chance to fix along the way"— is a valve that changes the water's taste: it stopped being transparent, and you lost the property that made it safe to install.
Worked example: the transparency test of the abstraction
We're going to set up the abstraction and demonstrate that inserting it is transparent. We start from the real situation: the shipping logic lives today as a loose function (legacy_shipping_fn), called from many places in the monolith. We insert the Protocol ShippingCalculator and a class LegacyShipping that wraps exactly the body of that function, without changing a line of calculation. The callers now receive the abstraction. Then we pass a set of orders through the two paths —direct function and via abstraction— and verify that the results match. The set includes the edge cases a future reimplementation could break: the exact weight of 1 kg (the surcharge boundary), free shipping, and the tacit edge that free shipping does not apply to international.
from typing import Protocol
# === BEFORE: the shipping logic is a LOOSE FUNCTION, called from many places
# in the monolith (checkout, cart, admin panel, confirmation emails...). ===
def legacy_shipping_fn(order):
base = {"local": 5.0, "national": 10.0, "international": 25.0}[order["zone"]]
surcharge = max(0.0, order["weight_kg"] - 1.0) * 2.0
cost = base + surcharge
if order["order_total"] >= 50.0 and order["zone"] != "international":
cost = 0.0
return round(cost, 2)
# === AFTER: we insert an ABSTRACTION (an interface) as a switch point.
# LegacyShipping wraps the SAME logic, without changing a single line of calculation. ===
class ShippingCalculator(Protocol):
def cost(self, order: dict) -> float: ...
class LegacyShipping:
def cost(self, order: dict) -> float:
# Exactly the body of legacy_shipping_fn, now behind the interface.
base = {"local": 5.0, "national": 10.0, "international": 25.0}[order["zone"]]
surcharge = max(0.0, order["weight_kg"] - 1.0) * 2.0
cost = base + surcharge
if order["order_total"] >= 50.0 and order["zone"] != "international":
cost = 0.0
return round(cost, 2)
# The callers now receive the abstraction instead of calling the function directly.
def checkout_total(order, calc: ShippingCalculator):
return round(order["items_subtotal"] + calc.cost(order), 2)
# Cases, including the EDGE ones: exact weight of 1kg, free shipping, and the tacit
# international-no-free edge that the future reimplementation could break.
ORDERS = [
{"id": 1, "zone": "local", "weight_kg": 0.5, "order_total": 20.0, "items_subtotal": 20.0},
{"id": 2, "zone": "national", "weight_kg": 1.0, "order_total": 30.0, "items_subtotal": 30.0},
{"id": 3, "zone": "international", "weight_kg": 3.0, "order_total": 80.0, "items_subtotal": 80.0},
{"id": 4, "zone": "local", "weight_kg": 1.0, "order_total": 60.0, "items_subtotal": 60.0},
{"id": 5, "zone": "national", "weight_kg": 4.0, "order_total": 55.0, "items_subtotal": 55.0},
]
# --- Transparency test: the abstraction returns EXACTLY what the old function does. ---
calc = LegacyShipping()
print("Transparency test: direct function vs through the abstraction\n")
print(f"{'order':>6}{'zone':>16}{'direct':>10}{'via abstraction':>18}{'equal?':>9}")
print("-" * 59)
all_equal = True
for o in ORDERS:
direct = legacy_shipping_fn(o)
through = calc.cost(o)
same = direct == through
all_equal = all_equal and same
print(f"{o['id']:>6}{o['zone']:>16}{direct:>10}{through:>18}{('YES' if same else 'NO'):>9}")
print("-" * 59)
print(f"\nAll responses identical: {all_equal}")
print("\n You inserted the abstraction without changing a single result (zero risk),")
print(" and now the point exists where tomorrow the flag will choose legacy vs modern.")
What to expect. When you run the file, the output is exactly this:
Transparency test: direct function vs through the abstraction
order zone direct via abstraction equal?
-----------------------------------------------------------
1 local 5.0 5.0 YES
2 national 10.0 10.0 YES
3 international 29.0 29.0 YES
4 local 0.0 0.0 YES
5 national 0.0 0.0 YES
-----------------------------------------------------------
All responses identical: True
You inserted the abstraction without changing a single result (zero risk),
and now the point exists where tomorrow the flag will choose legacy vs modern.
Read the equal? column: YES in the five rows. With paid shipping (local at 5.0, national at 10.0), with international shipping that does not become free despite exceeding 50.0 (row 3: 29.0, not 0.0), and with the two free-shipping cases (rows 4 and 5, at 0.0), the result through the abstraction is identical to that of the direct function. And the line below confirms it in a single verification: All responses identical: True. That's transparency: the abstraction didn't reimplement anything, it just wrapped the logic that already existed and exposed it behind an interface.
Notice row 3, the tacit-edge one. international with an order_total of 80.0 exceeds the free-shipping threshold (50.0), but it does not become free: the legacy's rule says "free only if NOT international." The result, 29.0 (base 25.0 + surcharge of 4.0 for the 2 kg extra), comes out identical through both paths because LegacyShipping copied that rule without removing it. Keep this row in mind: it's precisely the case where a careless reimplementation would slip —forget "and not international"—, and it's the discrepancy we'll catch in lesson 5. Here, with the abstraction wrapping the legacy as is, the quirk is preserved and transparency gives True.
The lesson's point is in the combination of those outputs: you changed the code's structure —now all the calls to shipping pass through an abstraction that wasn't there— without changing a single result. In deployment terms, this is gold: you can insert the abstraction, verify that the transparency test gives True, and know you didn't break anything. Inserting the abstraction is the lowest-risk step of the whole migration, and it's the one that enables all the others.
Deep dive: what a seam is, and what the abstraction must NOT do
The term seam comes from Michael Feathers, in Working Effectively with Legacy Code: a seam is a place where you can alter the behavior of your program without editing at that place. It sounds paradoxical, but it's exactly what you just created. checkout_total calls calc.cost(order). To change which calculation runs, you don't have to edit checkout_total: you change which object calc is. The point where checkout_total asks for .cost() is the seam —the seam you reach in through to switch the implementation—. In branch by abstraction, inserting the abstraction is, literally, creating the seam where you're going to switch.
Here's the structure, before and after inserting the abstraction:
BEFORE (no abstraction):
checkout ─────> legacy_shipping_fn(order) (direct call, no switch point)
cart ─────> legacy_shipping_fn(order)
admin ─────> legacy_shipping_fn(order)
AFTER (abstraction inserted, only the legacy on the other side):
checkout ──┐
cart ──┼──> ShippingCalculator.cost() ──> LegacyShipping (the seam: here it will switch)
admin ──┘ (modern doesn't exist yet)
The golden rule of the abstraction in this step is one of discipline: the abstraction must do nothing but expose the operation. It doesn't validate, doesn't transform the result, doesn't add logic, doesn't cache, doesn't "take the chance to fix along the way" the format. As soon as the abstraction starts to do things, it stops being transparent —the transparency test would give False— and you lose the property that makes it safe. An abstraction that only delegates can be inserted without fear; one that also transforms is a behavior change disguised as a refactor, and that's where branch by abstractions break. All the new logic lives in ModernShipping (lesson 3), never in the abstraction or in the LegacyShipping that wraps the old one.
And there's a second, subtler requirement, which the mistakes lesson develops: the abstraction must not leak. That is, its interface must not expose internal details of the legacy that tie the callers to it. If ShippingCalculator.cost() returned, say, an internal object of the legacy with its particular structure, the callers would depend on that structure, and when ModernShipping didn't have it, they'd break. The abstraction must speak in neutral terms —receives an order, returns a float— so that any implementation behind it can meet it without leaking how it does it inside.
Common mistakes
The leaky abstraction that exposes the legacy. What happens: when designing the interface, the team models it a carbon copy of how the legacy works today —it returns the old calculation's internal object, or exposes a method that only makes sense in the old implementation—. Why it happens: it's the fastest; the implementation that exists is the old one, and modeling the interface "as it already is" saves thinking. How to spot it: when you try to write ModernShipping behind the same abstraction, you discover you have to drag along details of the legacy that make no sense in the new one —an internal field, an inherited format— just to meet the interface. The abstraction has a leak: the callers, through it, are still tied to the legacy. How to fix it: design the interface in neutral and minimal terms —what the business needs ("give me the cost of this order"), not how the old one calculates it—. cost(order) -> float exposes nothing of the legacy: any implementation can meet it. A good abstraction is one ModernShipping can implement without inheriting a single quirk of LegacyShipping.
Taking the chance to "improve along the way" the result with the abstraction. What happens: when inserting the abstraction, the team sees an opportunity —"since everything passes through here, let's round differently," "let's add this field we always lacked"—. Why it happens: the abstraction is a tempting point; it touches all the calls, it seems the perfect place for cross-cutting changes. How to spot it: the transparency test stops giving True. If the result via abstraction differs from that of the direct function even in a decimal, the abstraction stopped being transparent. How to fix it: in this step, zero behavior changes. The abstraction only delegates. The improvements to the format or the new calculations are ModernShipping's job —there, yes, because the new implementation can behave differently and you control with the flag what percentage of calls sees it—. Keep transparency as a test that runs every time you insert or touch the abstraction: if it turns red, the abstraction got dirty.
Skipping the transparency test "because the abstraction only delegates." What happens: the team assumes that inserting an interface is trivially transparent and doesn't verify it. Why it happens: "it only wraps the old function, what could go wrong?". How to spot it: differences nobody noticed —when copying the function's body to the method, someone "cleaned up" a round, or changed a >= to a >, or reordered a condition—. These differences are invisible until an edge case gives them away in production. How to fix it: the transparency test isn't optional, it's cheap. Compare the direct function against the abstraction for a set of cases that covers the edges (exact weight of 1 kg, free shipping, the international one that doesn't become free) and verify total equality. It's an afternoon's test that saves you an incident. That the abstraction "only delegates" is exactly what you have to prove, not assume —especially when you copied logic from one place to another—.
Exercises
Exercise 1 — The transparent valve. In the pipe analogy, the plumber installs a shutoff valve before the section they're going to change. (a) What "transparency test" would they do to make sure that installing the valve didn't change the house's water? (b) Give an example of something the valve could "do along the way" that would break transparency. (c) Why must that improvement, even if good, not be done by the valve in this step?
See solution
(a) They would compare, for the house's taps, the water before and after installing the valve: same pressure, same flow, same temperature, same quality. If they open the kitchen and the other bathroom and everything comes out just like before —including the weird cases, like opening two taps at once or the stream at maximum pressure—, the valve is transparent. It's exactly the direct function == via abstraction comparison of the example, with the edge cases included.
(b) The valve could "take the chance to" install a filter it always thought was a good idea, or a pressure reducer, or a mixer that changes the temperature. Any of those things makes the after-the-valve water differ from the direct water: it breaks transparency. In the code, the equivalent is the abstraction rounding differently, adding a field, or normalizing the result.
(c) Because in this step the goal is to install the switch point without changing anyone's experience —that's exactly what makes it safe to deploy—. The improvements (the filter, the new format) are legitimate, but they belong to the new pipe (ModernShipping), where you can control with the flag what percentage of calls they reach. If the valve improves the water, the whole house sees the change at once, without gradualness and without being able to go back: it's the big-bang we wanted to avoid, snuck in through the back door.
Exercise 2 — Diagnose the transparency. A team inserts the abstraction and runs the transparency test. For four of five orders it gives YES, but for the international order (row 3) it gives NO: the direct function returns 29.0 and the abstraction returns 0.0. (a) Is the abstraction transparent? (b) What's the most likely cause, given what you know of the legacy's tacit rule? (c) Why is this error more dangerous than one that failed on all the rows?
See solution
(a) No. One row in NO is enough for the abstraction not to be transparent. Transparency is total equality in all the cases, not "in most."
(b) The most likely cause is that, when copying the legacy's logic to the cost method, someone omitted the condition and order["zone"] != "international" of the free shipping. Without that piece, the international order with total 80.0 exceeds the threshold of 50.0 and becomes free (0.0), when the legacy's rule says international is never free (29.0). It's exactly the tacit rule that row 3 exists to protect: this lesson's LegacyShipping copied it right, but a slip transcribing it would lose it.
(c) Because an error that fails on all the rows is noticed immediately —the whole system breaks, someone sees it the first day—. An error that fails only on an edge case (the international one with potential free shipping) goes unnoticed: most orders keep coming out fine, and the abstraction seems transparent until the weird order arrives, maybe weeks later, and undercharges. The most expensive parity errors are always the tacit-edge ones, precisely because they hide. That's why the transparency test must cover the edge cases on purpose, not just the happy path.
Exercise 3 — Design the leak-free interface. A colleague proposes that ShippingCalculator have this method: cost_breakdown(order) -> LegacyCostRecord, where LegacyCostRecord is the internal class the old calculation uses to represent the breakdown (with fields like raw_base, surcharge_cents, legacy_flags). (a) Why does this interface have a leak? (b) What problem will you have when you write ModernShipping? (c) Propose a leak-free interface that serves both the legacy and the modern.
See solution
(a) It has a leak because it exposes LegacyCostRecord —a class internal to the legacy— in the public interface. The callers that use cost_breakdown will depend on that class and its fields (raw_base, surcharge_cents, legacy_flags). The abstraction, which was supposed to be neutral, is leaking how the old one calculates: the callers stay tied to the legacy through it.
(b) When you write ModernShipping, you'll have to fabricate a LegacyCostRecord even though your new implementation doesn't think in those terms —maybe you model the breakdown differently, with other names, in another unit—. You'll be forced to drag along the old one's structure just to meet the leaky interface, contaminating the new implementation with the old one's quirks. The abstraction, instead of freeing you from the legacy, chains you to it.
(c) A neutral and minimal interface: cost(order) -> float, like the example's (or, if the breakdown really is needed, cost_breakdown(order) -> dict with agreed neutral keys: {"base": float, "surcharge": float, "total": float}, not the legacy's internal class). The key is that the interface speaks in terms of the domain (what the business needs to know about the cost), not of the implementation (how the old one calculates it inside). That way, LegacyShipping meets it by translating its LegacyCostRecord to those neutral terms, and ModernShipping meets it with its own structure, without either of the two leaking its internals to the callers. A leak-free abstraction is one both implementations can meet without sharing a single internal detail.
Summary and next step
In this lesson you did the first step of branch by abstraction: inserting the abstraction layer as a seam. You saw, with the shutoff valve installed before changing the pipe, that the abstraction is a switch point that decides which implementation runs without changing anyone's water. And you executed it with the transparency test: you compared, case by case, the direct shipping function against the same logic via the abstraction —including the edge cases, like the international one that doesn't become free— and verified All responses identical: True. That's the lowest-risk step of the whole migration: you changed the code's structure without changing a single result, and in exchange you got the seam where everything else will be possible. And you learned the discipline that keeps it safe: the abstraction only delegates —it doesn't transform— and doesn't leak —it speaks in neutral terms, doesn't expose the legacy's internals—.
Before moving on you should be able to: explain what a seam is and why inserting the abstraction creates one; state the golden rule of the abstraction (only delegates, no leaks) and why breaking it breaks transparency; run a transparency test that covers the edge cases and know why the tacit edge is the most dangerous; and design a neutral interface that serves both the legacy and the modern.
Lesson 3 does the second step: building the modern implementation behind the abstraction. Now that the abstraction is in place and transparent, you're going to raise a ModernShipping with a different internal structure —named rates, separated rules— but the same contract as LegacyShipping. You're going to execute the coexistence of the two and verify that the new one meets the contract the abstraction expects. The legacy will stay intact; the new one will live beside it; and the abstraction —the one you just inserted— will be the seam that tomorrow chooses which to call.
Resources
- Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004), ch. "Sensing and Separation" and the catalog of seams — the source of the concept of seam this lesson applies: a point where you alter the behavior without editing at that point. The direct reference for why inserting the abstraction creates the place to switch. In English.
- Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. Fowler describes the abstraction layer as the pattern's first move: it's inserted wrapping the existing implementation, without changing behavior. This lesson's step. In English.
- Paul Hammant, "branchbyabstraction.com" — branchbyabstraction.com. The pattern's step-by-step, starting by introducing the abstraction over the code that already exists. In English.
- Robert C. Martin, "The Dependency Inversion Principle" — the principle that sustains why the callers should depend on the abstraction (
ShippingCalculator) and not on the concrete implementation (LegacyShipping): the "D" of SOLID, which makes it possible to switch the implementation without touching the callers. In English.