Module 1: Why Not Rewrite

The tacit knowledge buried in the legacy

Overview

The two previous failure modes were about time: the business doesn't stop (lesson 2) and the scope inflates (lesson 3). This one is different and more treacherous, because it's not about how long the rewrite takes, but that it comes out incorrect without anyone noticing. The cause is tacit knowledge: the business rules that live buried in the legacy code and that nobody wrote in any document. When you look at an old system, there are parts that look "weird" —a strange if, a special case for a specific customer, a rounding that doesn't match the manual—, and the rewrite's temptation is to treat them as garbage to clean up: "this is wrong, in the new version we'll do it right." The problem is that those quirks are almost never bugs. They're scars: each one is a patch to a real complaint, a rule an audit imposed, a promise made to a big customer, an edge case that cost money to discover. The ugly code is ugly because the real world is ugly, and the legacy absorbed that ugliness line by line over years.

There's an old idea that captures this: Chesterton's fence. If you come across a fence crossing a road and you don't understand what it's for, the naive answer is "it's useless, let's remove it"; the wise answer is "don't remove it until you understand why they put it there." Legacy code is full of Chesterton's fences, and a blind rewrite tears them all down, because it reimplements the "clean" specification —the one in the docs, or the one the team believes to be correct— and throws away every rule the docs never mentioned. The result is silent regressions: behaviors the legacy had right (for the business) that the new system changes without any test screaming, because the legacy has no tests. This lesson measures it: we're going to replay a handful of Mercado's historical orders through the legacy and through a "clean" rewrite, and count how many come out different.

Connection with the module. It's the third and most dangerous of the big rewrite's failure modes: it's not the calendar that fails, it's the correctness, and silently. The central idea —the legacy is the specification— is also the bridge to module 2: the way to capture that tacit knowledge before touching the code is the characterization test, which pins the current behavior (quirks included) so that any change that alters it jumps out immediately. Here we measure the knowledge at stake; module 2 teaches the technique to protect it. The boundary is clear: the in-depth testing theory is the Testing ecosystem; here, replay as a thermometer of the risk.

An analogy: grandma's recipe with the margin note

You inherited your grandma's recipe book. Her famous bread has the recipe written in a clear hand: flour, water, yeast, salt, times, temperatures. But in the margin, in pencil, there are loose notes: "in summer, 10 min less," "if the flour is the new brand, a splash more water," "the top oven runs hot, lower it 15 degrees." You decide to modernize: you copy the recipe out clean, onto a pretty card, and —because the margin notes look messy and "unprofessional"— you omit them. You keep the "official" recipe, the clean one.

The first bread you bake with the new card comes out wrong: in summer, with the new flour, in your oven that runs hot. Each margin note you erased was a rule won over years of failed loaves —your grandma didn't write them on a whim, she wrote them because she learned the hard way that without them the bread comes out wrong—. The "clean" recipe was the theory; the notes were the real knowledge, the one that makes the bread come out right in the messy world of summers, flour brands, and uneven ovens.

Legacy code is that recipe book. The "official" specification —what's in the docs, what the team believes the system does— is the clean recipe. The code's quirks —the special ifs, the strange roundings, the per-customer cases— are the margin notes: tacit knowledge won the hard way, that nobody moved to the docs because they seemed like details. A rewrite that reimplements the clean specification and throws away the quirks is baking with the pretty card, and its loaves are going to come out wrong —only in Mercado a badly baked loaf is a customer overcharged, an order that breaks, an audit that fails—.

Worked example: counting the regressions of a 'clean' rewrite

We're going to model the calculation of an order's total in Mercado's checkout. The legacy accumulated, over years, several rules nobody documented: a frozen price for an old bundle, a coupon that doesn't stack with the volume discount (a fix after a complaint), free shipping for founder customers (an old promise), and a truncation to the peso (a historical bug the accounting reconciliations now depend on). The "clean" rewrite implements the obvious specification —discounts that stack, everyone pays shipping, standard rounding— and knows nothing about the hidden rules. We replay seven historical orders through both and count the differences:

ORDERS = [
    # (id, customer_id, sku, qty, unit_price, coupon)
    ("A", 42,  "SHOES-01",      2, 300.00, None),       # founder customer (id<100)
    ("B", 500, "LEGACY-BUNDLE", 5, 200.00, None),       # sku with frozen price
    ("C", 700, "PHONE-09",     10, 150.00, "WELCOME"),  # coupon + volume discount
    ("D", 800, "MUG-03",        1, 500.00, None),        # case with no hidden rules
    ("E", 900, "BOOK-02",       3, 333.33, None),        # triggers the truncation
    ("F", 50,  "TV-40",         4, 400.00, None),        # founder + volume
    ("G", 600, "CASE-01",       2, 250.00, "WELCOME"),  # coupon only, no conflict
]


def legacy_total(cid, sku, qty, price, coupon):
    # The REAL behavior, with all its quirks won over years.
    if sku == "LEGACY-BUNDLE":
        subtotal = 799.0                 # old promo never retired; ignores qty
    else:
        subtotal = qty * price
    volume = 0.10 if subtotal > 1000 else 0.0
    welcome = 0.15 if coupon == "WELCOME" else 0.0
    # 2019 fix: the WELCOME coupon does NOT stack with the volume discount.
    discount = max(volume, welcome) if (volume and welcome) else volume + welcome
    total = subtotal * (1 - discount)
    total += 0.0 if cid < 100 else 99.0  # inherited clause: founders no shipping
    return float(int(total))             # historical bug: truncates to the peso


def clean_rewrite_total(cid, sku, qty, price, coupon):
    # The "obvious and correct" spec the rewrite team would write
    # reading the docs (which mention none of the hidden rules above).
    subtotal = qty * price
    volume = 0.10 if subtotal > 1000 else 0.0
    welcome = 0.15 if coupon == "WELCOME" else 0.0
    discount = volume + welcome          # the discounts "should" stack
    total = subtotal * (1 - discount)
    total += 99.0                        # everyone pays shipping
    return round(total, 2)               # standard rounding


HIDDEN_RULE = {
    "A": "free shipping for founders",
    "B": "frozen bundle price",
    "C": "coupon doesn't stack with volume",
    "E": "truncated to the peso",
    "F": "free shipping for founders",
}

print(f"{'order':>6}{'legacy':>10}{'rewrite':>10}{'match':>7}  lost rule")
print("-" * 60)
regressions = 0
for oid, cid, sku, qty, price, coupon in ORDERS:
    lg = legacy_total(cid, sku, qty, price, coupon)
    rw = clean_rewrite_total(cid, sku, qty, price, coupon)
    match = "OK" if lg == rw else "DIFF"
    if lg != rw:
        regressions += 1
    note = "" if lg == rw else HIDDEN_RULE.get(oid, "?")
    print(f"{oid:>6}{lg:>10.2f}{rw:>10.2f}{match:>7}  {note}")

print("-" * 60)
print(f"\n  Orders replayed: {len(ORDERS)}")
print(f"  Silent regressions of the 'clean' rewrite: {regressions}"
      f" of {len(ORDERS)}")
print(f"  Each DIFF is a customer charged differently than the legacy - without")
print(f"  any test screaming, because the legacy has no tests yet.")

What to expect. When you run the file, the output is exactly this:

 order    legacy   rewrite  match  lost rule
------------------------------------------------------------
     A    600.00    699.00   DIFF  free shipping for founders
     B    898.00   1099.00   DIFF  frozen bundle price
     C   1374.00   1224.00   DIFF  coupon doesn't stack with volume
     D    599.00    599.00     OK  
     E   1098.00   1098.99   DIFF  truncated to the peso
     F   1440.00   1539.00   DIFF  free shipping for founders
     G    524.00    524.00     OK  
------------------------------------------------------------

  Orders replayed: 7
  Silent regressions of the 'clean' rewrite: 5 of 7
  Each DIFF is a customer charged differently than the legacy - without
  any test screaming, because the legacy has no tests yet.

Read the table row by row, because each DIFF is a margin note the rewrite erased.

Order A (founder customer 42): the legacy charges 600, the rewrite 699. The difference is 99 pesos of shipping that the rewrite charges and the legacy doesn't. Why? Because there's an old rule —free shipping for founder customers (id < 100)— that the docs never mentioned. The rewrite, without knowing it, started charging shipping to Mercado's oldest and most loyal customers. Nobody noticed it in the code; the founders will notice it on their next purchase.

Order B (the old bundle): legacy 898, rewrite 1099. The LEGACY-BUNDLE had a frozen price of 799 —an old promo that was never retired and that some customers depend on—. The rewrite calculated the "correct" price by quantity (5 × 200 = 1000) and added shipping. Two hundred pesos of difference over a special case that looked like "dead code to clean up."

Order C (coupon + volume): legacy 1374, rewrite 1224. Here the legacy is more expensive than the rewrite, and that's the surprise: not all regressions undercharge. The legacy has a 2019 fix —the WELCOME coupon doesn't stack with the volume discount— that was put in after discovering that the stacking gave away margins. The rewrite, "doing the right thing," stacked them again, and it's giving away 150 pesos to every customer who combines coupon and volume. Multiply that by thousands of orders: it's a margin leak nobody authorized.

Order E (the truncation): legacy 1098, rewrite 1098.99. Ninety-nine cents of difference. It seems like nothing —who cares about 99 cents?—. The accounting reconciliation cares, which for years matched the legacy's truncated totals. The rewrite rounds "right" and now the totals don't match the history, and the finance team spends a week chasing a cents difference that multiplies with every order. The historical bug became, over the years, a tacit contract.

Orders D and G came out OK: not every order triggers a hidden rule, and that matters —the rewrite isn't all wrong, which makes it more dangerous, because the 71% that matches gives a false sense that it works—. Five of seven orders came out different, each one for a rule the rewrite didn't know existed. And the punchline is in the last line: no test screamed, because the legacy has no tests. The five regressions would have reached production invisible, to be discovered one by one through annoyed customers, margin leaks, and broken reconciliations —the worst place and the worst time to discover them—.

Deep dive: the legacy is the specification

The conclusion of this lesson is a phrase worth engraving: in a legacy system without documentation, the code doesn't implement the specification; the code is the specification. There's no superior document against which the legacy could be "wrong" about the rules that matter to the business —what the system does is, for the business, correct by definition, because it's what the customers expect, what the accounting reconciles, and what the processes assume—. When the rewrite reimplements the "clean" specification and differs from the legacy, it's not correcting the legacy: it's breaking the contract the legacy embodies.

This completely inverts the rewrite's intuition. The rewrite is sold as "doing it right this time," with the idea that the legacy is full of errors the new version will correct. But most of those "corrections" are disguised regressions: the legacy wasn't wrong, it was fitted to reality in a way the docs didn't capture. Mercado's true specification doesn't fit in any document; it lives distributed across thousands of lines of code, and the only honest way to know it is by asking the code what it does —replaying its outputs, as you just did— before changing it.

This is where module 2 comes on stage. The technique to capture that tacit specification is called a characterization test: instead of writing tests against what the system should do (which you don't know), you write tests that pin what the system currently does —quirks included—. Order A's test doesn't say "the total should be X"; it says "today the legacy returns 600 for this order, and I want it to keep returning 600." With that net stretched over the five hidden rules, any reimplementation that breaks them fails instantly, and the Chesterton's fences stop being invisible. The point of this lesson is understanding why that net is indispensable; the how to stretch it is module 2.

flowchart TD
    L["Legacy code<br/>(the real specification,<br/>with its Chesterton fences)"]
    D["Documentation<br/>(the 'clean' specification,<br/>incomplete)"]
    R1["Blind rewrite<br/>from the docs"]
    R2["Modernization with a net<br/>(characterization tests, M2)"]
    D --> R1 --> X["5 silent regressions<br/>in production"]
    L -.captures the real behavior.-> R2 --> Y["safe changes:<br/>if you break a rule, the test screams"]

Common mistakes

Treating the "weird" parts of the legacy as garbage to clean up. What happens: reading the old code, the team sees an if with a special case and no comment and concludes "this is wrong / it's dead code / we'll do it right in the new version," and removes it. Why it happens: ugly code looks like an error, and cleaning it up feels like an obvious improvement. How to spot it: if you're about to delete a branch of code because "it makes no sense" and you can't explain why they put it there, you're facing a Chesterton's fence. How to fix it: reverse the burden of proof. In a live legacy system, assume every quirk had a reason until you prove otherwise —search the commit history, ask whoever's been there longest, check whether any customer or process depends on it—. And before touching it, pin it with a characterization test (module 2): if it really was dead code, the test will confirm it; if it wasn't, the test will save you from a regression.

Trusting the documentation as if it were the truth. What happens: the rewrite is planned against the system's docs —the diagram, the manual, what the team remembers— assuming that faithfully describes what the system does. Why it happens: the docs are what's written, and they're easier to read than 50,000 lines of code. How to spot it: ask "when was this doc last updated, and who guarantees it matches today's code?". If the answer is uncomfortable —and in a legacy it always is—, the docs are an approximation, not the specification. How to fix it: the source of truth of what a legacy system does is the running system, not its documentation. Replay its behavior with real inputs (as in the example) and treat those outputs as the contract. The docs serve as a hint, never as a guarantee. The example's five rules weren't in any doc; they were only in the code and in the customers' memory.

Believing the regressions "will be noticed in testing." What happens: the team assumes that, if the rewrite breaks something, QA or the tests will catch it before production. Why it happens: in projects with good coverage, that assumption is reasonable. How to spot it: ask "against what do we compare the rewrite's output to know if it's correct?". If the answer is "against what we believe it should do," there's no net —you're comparing against the clean specification, which is exactly the one without the hidden rules—. How to fix it: the only comparison that catches these regressions is rewrite against legacy, not rewrite against the docs. Running the same inputs through both and demanding they match (what module 6 calls parallel-run) is what turns a silent regression into a visible difference. Without that comparison, a legacy without tests guarantees the example's five regressions reach production quietly.

Exercises

Exercise 1 — Diagnose the fence. You're modernizing Mercado's shipping module and you find this code: if destination_zip.startswith("90") and weight > 5: shipping_days += 2. There's no comment, and the docs don't mention it. A colleague says "this is arbitrary, delete it in the new version." Give three plausible hypotheses of why someone might have written that rule, and describe what you'd do before deciding whether to remove it.

See solution

Three plausible hypotheses of the Chesterton's fence (any real reason counts):

  1. A geographic zone with difficult logistics. The zip codes starting with "90" may correspond to a mountainous, insular, or remote region where heavy packages (>5 kg) really do take two more days because they require a different carrier. The rule encodes an operational reality learned the hard way.
  2. An agreement with a carrier. Maybe the contract with the courier covering that zone specifies longer times for heavy freight, and the rule reflects the contractual promise Mercado makes to the customer so as not to over-promise.
  3. A patch after a wave of complaints. There may have been a batch of customers from that zone annoyed because heavy packages arrived late and the estimate said otherwise; someone adjusted the estimate to be honest so the complaints would stop.

What I'd do before deciding: (a) find the commit that introduced the line and read its message and date —sometimes the reason is there—; (b) ask the most senior person in logistics or support if they remember anything about that zone; (c) look at real data: do heavy shipments to that zone in fact take longer? If the data confirms it, the rule is correct and must be preserved; (d) whatever happens, pin the behavior with a characterization test (module 2) before touching the module, so that if the rule mattered, breaking it jumps out immediately. What I would not do is delete it because "it looks arbitrary": that's exactly the Chesterton's fence you don't tear down without understanding.

Exercise 2 — The 71% that deceives. In the example, 2 of 7 orders (29%) matched between legacy and rewrite. Explain why that match percentage, far from being reassuring, makes the rewrite more dangerous. What would have happened if we had tested only with orders D and G?

See solution

The 29% match (orders D and G) is dangerous precisely because it works just enough to inspire false confidence. If the rewrite failed in 100% of the cases, the problem would be obvious at first glance and nobody would put it in production. But a rewrite that gets the "normal" cases right (a simple order with no hidden rules, like D and G) and only fails in the "weird" cases passes the superficial tests, the demos, and the team's intuition —"I tested it and it works"—, and hides its regressions right where nobody looks: at the edges.

If we had tested only with orders D and G, the rewrite would have come out OK in 100% of the tests, and the team would have concluded with complete peace of mind that the total calculation was correct. The five regressions (founder shipping, frozen bundle, non-stackable coupon, truncation) would have reached production intact, because the test sample didn't touch any hidden rule. This is the trap of testing with "typical cases": the tacit rules live in the atypical cases, and a sample that only covers the typical gives a false green verdict.

The method lesson: to catch tacit-knowledge regressions, you have to replay real and historical inputs —which do contain the weird cases because the real world produced them—, not "example" inputs invented by the team, which tend to be all typical. Module 2 (characterization tests over real data) and module 6 (parallel-run) attack exactly this.

Exercise 3 — The legacy is the specification. Explain, in your words, the phrase "in a legacy system without documentation, the code is the specification." Does this mean the legacy never has real bugs? Distinguish between a quirk to preserve and a bug that does need correcting, and say how you'd decide which is which.

See solution

The phrase means that, lacking a superior and up-to-date document, what defines "correct behavior" of a legacy system is what the system actually does, because that's what the surrounding world (customers, accounting, processes, integrations) already assumes and depends on. There's no external authority against which the legacy could be wrong about the rules the business already absorbed: if the legacy charges a certain way and the accounting reconciles with it, that way is the de facto specification.

No, this doesn't mean the legacy never has real bugs. It does have them. The distinction is subtle but decidable:

  • A quirk to preserve is a behavior something or someone depends on: a customer expects it, a process assumes it, the accounting reconciles with it, an integration consumes it. Even if it seems "incorrect" in the abstract (like the truncation to the peso), changing it breaks that something. The example's truncation is a quirk: ugly, but the reconciliation depends on it.
  • A real bug is a behavior nobody depends on and that produces a result everyone —including the business— recognizes as undesired: an intermittent crash, a calculation that gives absurd results nobody has seen because the case hasn't occurred, a piece of data that gets corrupted. Correcting it breaks no tacit contract because there was no contract.

How to decide which is which: the key question is "does someone or something depend on this behavior as it is?". It's answered by investigating (history, real data, asking support and finance), not by guessing. And the golden rule of modernization: preserve first, correct later. In a migration, your immediate goal is parity —replicate the current behavior, bugs included—; once the new system matches the old and is under tests, then —and only then— you fix the real bugs as conscious and separate changes, each with its test. Fixing bugs during the migration mixes two things and makes it impossible to know whether a difference is an intentional improvement or an accidental regression.

Summary and next step

In this lesson you closed the trio of the big rewrite's failure modes with the most treacherous: the tacit knowledge buried in the legacy. You saw, with grandma's recipe book, that the old code's quirks are almost never garbage but margin notes —business rules won the hard way that nobody documented—, and that a "clean" rewrite that throws them away bakes with the incomplete recipe. And you measured it: replaying seven historical orders through the legacy and through a clean rewrite revealed 5 silent regressions of 7, each from a hidden rule (founder shipping, frozen bundle, non-stackable coupon, accounting truncation), and none would have screamed in a legacy without tests. The conclusion to engrave: the legacy is the specification, and knowing it demands asking the code what it does, not trusting the docs.

Before moving on you should be able to: explain Chesterton's fence applied to code; distinguish a quirk to preserve from a bug to correct; argue why a high match percentage makes the rewrite more dangerous, not less; and say why the comparison that matters is rewrite-against-legacy, not rewrite-against-docs.

With this the case against the rewrite ends: you saw the three failure modes —the business that doesn't stop, the second system, the lost knowledge—. Lesson 5 turns the argument around and builds the case for the alternative: the case for incremental. You'll measure why transforming by slices delivers value early and risks little, with two concrete metrics —the accumulated value-periods and the risk at stake per deploy— that show, in numbers, the advantage of the path that does work.

Resources

  • Michael Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004) — the definitive reference. Its definition of "legacy" (code without tests) and its characterization-test technique are the direct answer to this lesson's problem: capture the tacit knowledge before touching it. Bridge to module 2. In English.
  • G. K. Chesterton, The Thing (1929), ch. "The Drift from Domesticity" — the origin of "Chesterton's fence": don't tear down a fence until you understand why it was put there. The principle that governs how to treat the legacy's quirks. In English.
  • Michael Feathers, characterization tests — term coined in Working Effectively with Legacy Code; summary on Wikipedia: en.wikipedia.org/wiki/Characterization_test. The technique of pinning the current behavior as a safety net, developed in module 2 of this guide. In English.
  • Sam Newman, Monolith to Microservices (O'Reilly, 2019), on data and behavior migration — how parallel-run (running old and new in parallel and comparing) catches these regressions before the switch. Developed in module 6. In English.