Module 4: Branch by Abstraction
Project: modernize Mercado's ShippingCalculator
Overview
This is the module's capstone. Over seven lessons you installed the complete branch-by-abstraction pattern: insert the abstraction layer as a seam, build the new implementation behind it, switch with a feature flag, validate with a parallel-run, delete the old implementation, and —the deep reason— do everything in main in small steps to avoid the merge hell of a long branch. Now you apply it end to end to a real case: Mercado's shipping calculation, that tangled legacy function called from half a dozen places in the monolith and that has no network boundary to put a proxy. It's your job to modernize it with branch by abstraction, executed, and produce a migration log a team can read and reproduce.
The project's work is the real work of modernizing live internal code: taking the ShippingCalculator from "a legacy function that's scary to touch" to "a modern, validated implementation, with the old one deleted" —without shutting down the system and without a long-lived branch—. It integrates the pattern's six phases into a single executed program, as a migration log: each phase leaves a measured piece of evidence (transparency verified, discrepancies caught and fixed, legacy burn-down to zero, moving parts reduced, merge conflicts at zero). Notice the boundary, which is deliberate: this project does not extract the shipping into its own service (that's module 5), nor migrate data to another store (module 6), nor formally decide with an ADR whether to migrate (that's the decisions guide). It does one thing thoroughly: change the internal implementation of the shipping calculation, from legacy to modern, inside the code, incrementally and measured.
Connection with the module. It's the integration of the seven lessons into a single executed deliverable. Phase 1 uses the abstraction and the transparency test of lesson 2; Phase 2, the construction of ModernShipping of lesson 3 and the parallel-run of lesson 5 (which catches the discrepancy); Phase 3, the fix to parity of lesson 5; Phase 4, the feature flag and the rollout of lesson 4, with the gate that only promotes if the parallel-run is clean; Phase 5, the deletion of the legacy of lesson 6; and Phase 6, the conflict measurement of lesson 7. When you finish it, you'll have the complete pattern applied to a case, executed and measured, with the justification of why incremental in main beat the long branch.
The reference solution, executed
We're going to build the solution in a single program that runs the six phases over Mercado's ShippingCalculator, leaving a log. All with fixed data —300 deterministic orders—, reproducible.
Part 1 — The data and the pieces
We have the ShippingCalculator abstraction, the LegacyShipping implementation (today's shipping logic), and two versions of the new one: ModernShippingV1 (with a parity bug, forgets that free shipping doesn't apply to international) and ModernShippingV2 (fixed). The traffic is 300 deterministic orders that cover the three zones, different weights and totals. A _base_formula function shares the structure of the calculation, parameterized by the free-shipping rule, so the three implementations are diffable and the difference is exactly in that rule.
import zlib
from typing import Protocol
# ======================================================================
# Abstraction + implementations (the piece we migrate: ShippingCalculator)
# ======================================================================
class ShippingCalculator(Protocol):
def cost(self, order: dict) -> float: ...
def _base_formula(order, free_rule):
base = {"local": 5.0, "national": 10.0, "international": 25.0}[order["zone"]]
cost = base + max(0.0, order["weight_kg"] - 1.0) * 2.0
if free_rule(order):
cost = 0.0
return round(cost, 2)
class LegacyShipping:
def cost(self, order):
return _base_formula(order, lambda o: o["order_total"] >= 50.0 and o["zone"] != "international")
class ModernShippingV1: # with the bug: free shipping also to international
def cost(self, order):
return _base_formula(order, lambda o: o["order_total"] >= 50.0)
class ModernShippingV2: # fixed
def cost(self, order):
return _base_formula(order, lambda o: o["order_total"] >= 50.0 and o["zone"] != "international")
# ----- Fixed traffic (300 deterministic orders) -----
ZONES = ["local", "national", "international"]
ORDERS = [{
"id": i, "zone": ZONES[i % 3],
"weight_kg": round(0.5 + (i % 5) * 0.5, 1),
"order_total": 20.0 + (i % 7) * 10.0,
"items_subtotal": 20.0 + (i % 7) * 10.0,
} for i in range(1, 301)]
def bucket(oid): return zlib.crc32(str(oid).encode()) % 100
def checkout_total(order, calc): return round(order["items_subtotal"] + calc.cost(order), 2)
def parallel_run(orders, legacy, modern):
return [(o["id"], o["zone"], legacy.cost(o), modern.cost(o))
for o in orders if legacy.cost(o) != modern.cost(o)]
Part 2 — The complete program: the six phases
The six phases, chained, each leaving its evidence in a log:
ledger = []
legacy = LegacyShipping()
# ======================================================================
# PHASE 1 - Insert the abstraction. Transparency: the callers don't change.
# ======================================================================
def inline_legacy(order): # the "pre-abstraction" formula, loose in the monolith
return _base_formula(order, lambda o: o["order_total"] >= 50.0 and o["zone"] != "international")
transparent = all(inline_legacy(o) == legacy.cost(o) for o in ORDERS)
print(f"PHASE 1 insert abstraction -> transparent in {len(ORDERS)} orders: {transparent}")
ledger.append(("1 abstraction", f"transparency {transparent}"))
# ======================================================================
# PHASE 2 - Build modern alongside. parallel-run v1 gives away a discrepancy.
# ======================================================================
mm_v1 = parallel_run(ORDERS, legacy, ModernShippingV1())
print(f"PHASE 2 parallel-run modern v1 -> discrepancies: {len(mm_v1)} e.g.: {mm_v1[0] if mm_v1 else '-'}")
ledger.append(("2 parallel-run v1", f"{len(mm_v1)} discrepancies -> do NOT switch"))
# ======================================================================
# PHASE 3 - Fix (v2). Clean parallel-run: safe to switch the flag.
# ======================================================================
mm_v2 = parallel_run(ORDERS, legacy, ModernShippingV2())
print(f"PHASE 3 parallel-run modern v2 -> discrepancies: {len(mm_v2)} (safe to raise the flag)")
ledger.append(("3 parallel-run v2", f"{len(mm_v2)} discrepancies -> switch OK"))
# ======================================================================
# PHASE 4 - Flag ramp 0 -> 25 -> 50 -> 100 with gate and legacy burn-down.
# ======================================================================
modern = ModernShippingV2()
def resolve(order, pct): return modern if bucket(order["id"]) < pct else legacy
print("\nPHASE 4 flag ramp (gate: only goes up if parallel-run clean)")
print(f" {'rollout':>8}{'-> modern':>11}{'-> legacy':>11}{'callers OK':>12}{'gate':>8}")
for pct in (0, 25, 50, 100):
counts = {"modern": 0, "legacy": 0}
callers_ok = True
for o in ORDERS:
calc = resolve(o, pct)
counts["modern" if calc is modern else "legacy"] += 1
callers_ok = callers_ok and (checkout_total(o, calc) == checkout_total(o, legacy))
gate = "PROMOTE" if len(mm_v2) == 0 else "BLOCK"
print(f" {pct:>7}%{counts['modern']:>11}{counts['legacy']:>11}{str(callers_ok):>12}{gate:>10}")
ledger.append(("4 ramp 0->100", "legacy burn-down to 0, callers OK at every level"))
# ======================================================================
# PHASE 5 - Delete the legacy. Moving parts: 4 -> 2.
# ======================================================================
before_parts, after_parts = 4, 2 # legacy+modern+flag+wiring -> modern+wiring
print(f"\nPHASE 5 delete the legacy -> moving parts: {before_parts} -> {after_parts} (flag and legacy out)")
ledger.append(("5 delete legacy", f"parts {before_parts}->{after_parts}, a single source of truth"))
# ======================================================================
# PHASE 6 - All this happened in main, in small steps: 0 merge conflicts.
# ======================================================================
main_weekly = [{10, 11, 12}, {12, 20, 21}, {5, 6, 30}, {21, 22, 40}]
long_branch = set(range(5, 25))
running = set().union(*main_weekly)
conflicts_long = len(running & long_branch)
print(f"PHASE 6 integration -> long branch: {conflicts_long} conflicts | branch-by-abstraction: 0")
ledger.append(("6 integration", f"long branch {conflicts_long} conflicts vs 0 in main"))
# ======================================================================
# Migration log
# ======================================================================
print("\n=== migration log (ShippingCalculator, legacy -> modern) ===")
for phase, evidence in ledger:
print(f" [{phase:<18}] {evidence}")
print("\n Incremental won: each step, measured and always integrable in main.")
What to expect. When you run the complete file, the output is exactly this:
PHASE 1 insert abstraction -> transparent in 300 orders: True
PHASE 2 parallel-run modern v1 -> discrepancies: 57 e.g.: (5, 'international', 25.0, 0.0)
PHASE 3 parallel-run modern v2 -> discrepancies: 0 (safe to raise the flag)
PHASE 4 flag ramp (gate: only goes up if parallel-run clean)
rollout -> modern -> legacy callers OK gate
0% 0 300 True PROMOTE
25% 77 223 True PROMOTE
50% 156 144 True PROMOTE
100% 300 0 True PROMOTE
PHASE 5 delete the legacy -> moving parts: 4 -> 2 (flag and legacy out)
PHASE 6 integration -> long branch: 8 conflicts | branch-by-abstraction: 0
=== migration log (ShippingCalculator, legacy -> modern) ===
[1 abstraction ] transparency True
[2 parallel-run v1 ] 57 discrepancies -> do NOT switch
[3 parallel-run v2 ] 0 discrepancies -> switch OK
[4 ramp 0->100 ] legacy burn-down to 0, callers OK at every level
[5 delete legacy ] parts 4->2, a single source of truth
[6 integration ] long branch 8 conflicts vs 0 in main
Incremental won: each step, measured and always integrable in main.
Part 3 — The log, read phase by phase
Phase 1 inserted the abstraction and verified the transparency: transparent in 300 orders: True. For the 300 orders, the "pre-abstraction" formula (the loose function that lived in the monolith) was compared against LegacyShipping.cost through the abstraction, and they all matched. It's the lowest-risk step: you changed the code's structure —now everything passes through ShippingCalculator— without changing a single result. The seam got installed, with LegacyShipping on the other side, ready to switch.
Phase 2 built ModernShippingV1 and validated it with the parallel-run —and the parallel-run failed it—: discrepancies: 57, with the example (5, 'international', 25.0, 0.0). Notice the number: 57 of the 300 orders differ. It's not a coincidence or a weird case: it's all the portion of international orders with a total high enough to trigger V1's (wrong) free shipping. The example shows it —order 5, international, the legacy charges 25.0 and V1 charges 0.0—: V1 forgot that free shipping doesn't apply to international, and the parallel-run caught it in 57 concrete orders. The evidence in the log is blunt: 57 discrepancies -> do NOT switch. A single discrepancy would be enough not to raise the flag; with 57, it's obvious V1 isn't ready.
Phase 3 fixed the bug (ModernShippingV2, which adds the and zone != international) and reran the parallel-run: discrepancies: 0. The 57 differences disappeared —V2 replicates the legacy's tacit rule—. The log records 0 discrepancies -> switch OK: now, and only now, is it safe to raise the flag. The contrast between Phase 2 (57) and Phase 3 (0) is the value of the parallel-run in one line: it caught a bug that would have charged improper free shipping to 57 groups of orders, before exposing it to a single user.
Phase 4 raised the flag from 0 to 100% with a gate. Read the table top to bottom: at 0% the 300 orders go to the legacy; at 25%, 77 to the modern and 223 to the legacy; at 50%, 156 and 144; at 100%, the 300 to the modern and 0 to the legacy —the legacy's burn-down reached zero—. The callers OK column gives True at all the levels: switching the implementation didn't break the callers at any point of the rollout. And the gate column says PROMOTE at every level because the parallel-run of Phase 3 was clean (0 discrepancies); if it had had discrepancies, the gate would say BLOCK and the rollout wouldn't advance. That's the discipline: the flag goes up over what's validated, not blind.
Phase 5 deleted the legacy: moving parts: 4 -> 2. With the flag at 100% and the parallel-run clean, LegacyShipping no longer received traffic or contributed anything. Deleting it —along with the flag and the selection wiring— dropped the moving parts from four (legacy, modern, flag, wiring) to two (modern, trivial wiring). The log sums it up: a single source of truth. The double maintenance ended; the drift became impossible.
Phase 6 closed the arc with the measurement that names the pattern: long branch: 8 conflicts | branch-by-abstraction: 0. Everything before —insert, build, validate, switch, delete— happened in main, in small steps, each immediately integrable. If the same refactor had been done on a long-lived branch, it would have accumulated 8 lines in conflict on merging (the divergence of 4 weeks without integrating); done with branch by abstraction in main, it had 0. The final log says it whole, phase by phase, and closes with the module's thesis: Incremental won: each step, measured and always integrable in main.
This project is the complete pattern applied to a case, and it fits into the guide's arc like this:
flowchart LR
P["Project M4<br/>ShippingCalculator: legacy -> modern<br/>(internal migration, by code)"] --> M5["M5<br/>extract a service<br/>(anti-corruption layer)"]
M5 --> M6["M6<br/>migrate the data"]
M6 --> M7["M7<br/>measure the progress"]
Read it like this: here you modernized the implementation of the shipping calculation inside the monolith, without taking it anywhere. Module 5 teaches the next step —extract a piece into its own service with an anti-corruption layer—, for when the goal isn't just to change the implementation but to move the piece out of the monolith.
Your deliverable
Reproduce and adapt the reference solution. Your deliverable has three pieces:
- The executed six-phase migration: the program that runs the six phases over the
ShippingCalculator, with the literal output. You can use the example's implementations or —better— add a rule of your own to the shipping calculation (for example, a volume discount or a new zone) and make sure thatModernShippingreplicates it, verified by the parallel-run. - The migration log: the list of phases with their measured evidence (transparency, discrepancies caught and fixed, burn-down, moving parts, conflicts). If you changed the rules, your numbers will be different; what doesn't change is the structure: each phase leaves evidence, not an assertion.
- The justification: a paragraph that explains, with the numbers of your run, why branch by abstraction in
mainbeat the long-branch alternative —using the conflict contrast (yours vs 0) and the fact that each step was immediately integrable—. Don't extract the service or migrate data; justify why the internal migration was incremental, measured, and without merge hell.
Common mistakes
Skipping the parallel-run and raising the flag "because the modern looks fine." What happens: the project builds ModernShipping, tests it by eye with a couple of cases, and jumps straight to the flag rollout without running the parallel-run over all the orders. Why it happens: the parallel-run feels redundant ("I already checked, it's fine"). How to spot it: if your log doesn't have a phase of measured discrepancies before the rollout, you skipped it. The reference solution makes it evident: ModernShippingV1 "looked fine" and had 57 discrepancies. How to fix it: the parallel-run goes before the flag, over all the traffic (or a large sample), and the rollout's gate only promotes if it gave 0 discrepancies. Phase 2 exists precisely to catch the bug that "by eye" isn't seen. Without that phase, you raise the flag with a modern that differs from the legacy in dozens of cases, and those cases are real users being charged wrong.
Ending at Phase 4 (flag at 100%) and not deleting the legacy. What happens: the project reaches the flag at 100%, sees callers OK: True, and declares itself finished —leaving LegacyShipping and the flag in the code—. Why it happens: reaching 100% feels like the end, and deleting code commands respect. How to spot it: your log has five phases, not six; the reduction of moving parts is missing. How to fix it: the migration ends at Phase 5, with the legacy deleted and the moving parts from 4 to 2. The flag at 100% is "the new one already works"; deleting the legacy is "the migration finished." Without that step, you're left with two implementations, double maintenance, and latent drift —the eternal migration—. The complete log has six phases, and the fifth is the one that pays.
Doing the whole project on a long branch and merging at the end. What happens: the project uses the pattern's mechanics —abstraction, two implementations, flag— but does all the work on a branch that isn't integrated until the end, contradicting Phase 6. Why it happens: the habit of "I work on my branch and merge when it's complete" survives the theory. How to spot it: if your justification says "0 conflicts because I integrated into main in small steps" but in practice you did everything on a branch and merged once, your log is lying. How to fix it: the point of Phase 6 is that each phase is a commit (or several) immediately integrable into main —the abstraction today, ModernShipping off tomorrow, the flag at 25% the day after—. The justification "incremental beat the long branch" is only honest if you really integrated each step. Locking the pattern in a long branch gives you the branch's divergence and the pattern's complexity: the worst of both.
Exercises
Exercise 1 — Add a rule and revalidate. Mercado wants a shipping discount: for orders with 3 or more items (item_count >= 3), the shipping gets a 50% discount (unless it's already free). (a) Add the rule to ModernShipping and decide whether it also goes in the legacy for this migration. (b) What would the parallel-run show if you add the rule only to the modern? (c) How should you introduce this rule if it's a new behavior change and not part of the migration to parity?
See solution
(a) It depends on what the rule is. If the volume discount already existed in the legacy (it was current behavior the reimplementation must preserve), it goes in both for this migration —the goal is parity—. If it's a new requirement the business wants to debut, it does not go in this migration: the migration is to parity with the legacy, and putting a new rule in contaminates it. The key question is: did the legacy already do this? If yes, replicate it in the modern to match; if no, it's a separate change.
(b) If the discount didn't exist in the legacy and you add it only to the modern, the parallel-run would show discrepancies in all the orders with item_count >= 3: the legacy charges the full shipping and the modern charges half. The parallel-run can't distinguish "bug" from "deliberate behavior change" —it only sees that they differ—, so it would report those differences as discrepancies, blocking the gate. That's correct: it forces you to explicitly decide whether that difference is intentional.
(c) As a separate change, after the migration. First you complete branch by abstraction to exact parity (parallel-run at 0), delete the legacy, and are left with ModernShipping as the sole source of truth. Then, in a deliberate change with its own validation, you add the volume discount —ideally behind its own flag to be able to raise it gradually—. That way you separate "I migrated the implementation" (same output, better code) from "I changed the business rule" (new output, approved). If you mixed the two, the parallel-run couldn't validate the migration (there'd always be "discrepancies" that are actually the new rule), and you wouldn't know whether a problem came from the change of implementation or of behavior.
Exercise 2 — Read the log. The final log recorded [2 parallel-run v1] 57 discrepancies -> do NOT switch and [3 parallel-run v2] 0 discrepancies -> switch OK. (a) Why did exactly 57 of 300 orders discrepate with V1? (b) What would have happened if Phase 4's gate had run with V1 instead of V2? (c) Why does the log record measured evidence in each phase instead of just "done / not done"?
See solution
(a) Because 57 is the number of international orders that exceed the free-shipping threshold (total >= 50.0) in the batch of 300. ModernShippingV1 makes them free (0.0) because it forgot to exclude international; the legacy charges them normally. All those orders —and only those— differ. It's not an arbitrary number: it's exactly the portion of traffic V1's bug affects, measured. (The international orders that don't exceed 50.0, or those from other zones, match, that's why it's not the full 100 international orders.)
(b) The gate would say BLOCK instead of PROMOTE, and the rollout wouldn't advance from 0%. The gate is conditioned on len(mm_v2) == 0; if the current parallel-run had 57 discrepancies, the condition would be false and the flag wouldn't go up. It's the protection working: the gate doesn't let you expose to users an implementation that differs from the legacy in 57 cases. Only after fixing to V2 (0 discrepancies) does the gate promote.
(c) Because "done / not done" proves nothing —a team can say it validated without having done it—. The measured evidence (transparency True in 300, 57 discrepancies, 0 discrepancies, burn-down to 0, parts 4→2, 8 conflicts vs 0) is verifiable and reproducible: anyone can run the code and get the same numbers. An evidence log turns the migration from "trust that we did it well" into "here are the measurements of each step." It's the difference between asserting and demonstrating —the principle of the whole guide: nothing is asserted from memory, everything is executed—.
Exercise 3 — Close the arc: from the implementation to the service. This project modernized the implementation of the shipping calculation inside the monolith. The next possible step is to extract it into its own service (module 5). (a) What difference is there between what you did here and "extracting the shipping service"? (b) Why does it make sense to do branch by abstraction before extracting? (c) What new piece would appear when extracting that didn't exist in this internal migration?
See solution
(a) Here you changed the internal implementation of the shipping calculation (from LegacyShipping to ModernShipping) without taking it out of the monolith: it's still code that runs in the same process, called as a function. Extracting the service (module 5) is moving that piece to its own process —a separate service, with its own deployment and maybe its own database—, so that the monolith talks to it over the network instead of via a function call. One changes the how it's calculated; the other changes the where it lives and how it's talked to.
(b) Because branch by abstraction leaves the shipping calculation behind a clean abstraction (ShippingCalculator) and in a modern and validated implementation (ModernShipping). Extracting to a service is much easier from there: you already have a clear boundary (the interface) and a tidy implementation to move, instead of a tangled legacy function you'd first have to untangle. Modernize from the inside first, extract after, is the order that reduces the risk —each step starts from a cleaner state than the previous—.
(c) The anti-corruption layer would appear: a layer that translates between the monolith's model and the extracted shipping service's model, so the monolith can keep talking in its old terms while the new service uses its own, without the quirks of one contaminating the other. In this internal migration it wasn't needed —everything lived in the same model and the same process—; on crossing the process boundary, the translation between models becomes necessary. It's the central piece of module 5.
Summary and next step
You closed the module by applying complete branch by abstraction to a real case. You took Mercado's shipping calculation —an internal legacy function, without a network boundary— and modernized it end to end, executed and measured, in a six-phase log: you inserted the abstraction with transparency verified in 300 orders; you built ModernShipping and the parallel-run caught 57 discrepancies of v1 (the international bug); you fixed them in v2 and the parallel-run went to 0; you raised the flag from 0 to 100% with a gate that only promotes validated and the legacy's burn-down to zero, without breaking the callers; you deleted the legacy and dropped the moving parts from 4 to 2; and you demonstrated that, by living all in main in small steps, the refactor had 0 merge conflicts against the 8 of a long branch. You didn't extract the service or migrate data: you did the internal migration of the implementation, incremental, measured, and without merge hell.
With this the module 4 ends. You already have the two complementary incremental migration techniques: the strangler fig (module 3) for when there's a network boundary to put an external proxy, and branch by abstraction (this module) for when the migration is inside the code, without that boundary. You know how to insert the abstraction as a seam, build the new one behind it, switch with a flag, validate with a parallel-run, delete the old one, and why all that in main avoids merge hell.
Module 5 takes the next step: extract a service. So far you modernized inside the monolith —diverting traffic (M3) or changing the implementation from the inside (M4)—. Module 5 takes a piece out: extract a bounded context (like the catalog) into its own service, with the anti-corruption layer that translates between the old model and the new one, the ownership of the data, and the transient shared database and how it's cut. The clean abstraction you left in this module is, often, the starting point of that extraction.
Resources
- Martin Fowler, "BranchByAbstraction" (2014) — martinfowler.com/bliki/BranchByAbstraction.html. The complete pattern this project executes end to end: abstraction, two implementations, switching and removal of the old one, all in the mainline. In English.
- Paul Hammant, "branchbyabstraction.com" — branchbyabstraction.com. The pattern's step-by-step by the author who has documented it most, with real cases of incremental application in
main. In English. - Jez Humble and David Farley, Continuous Delivery (Addison-Wesley, 2010) — the framework of continuous integration and trunk-based development where branch by abstraction is the technique for big changes; the measured justification of why incremental in
mainbeats the long branch. In English. - Martin Fowler, "Patterns of Legacy Displacement" — martinfowler.com/articles/patterns-legacy-displacement. The validation technique that caught v1's 57 discrepancies before exposing them: run the two implementations and compare, with the old one as the source of truth; the parallel run as a named pattern is from Sam Newman (Monolith to Microservices). In English.
- Sam Newman, Monolith to Microservices (O'Reilly, 2019), ch. 3 — where this work continues: extract the modernized piece into its own service with an anti-corruption layer, module 5 of this guide. In English.