LegacyGENERALLIFETIME-SPECIFICCONTESTED

The Legacy Change Loop

Understand, characterize, seam, small refactor, change behaviour, verify — in that order, because each step is what makes the next one safe rather than brave.

The requirement, the obvious build, and why it breaks

Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.

The question

A ticket needs one behaviour changed in code with no tests. What is the actual sequence of moves, and why is that the order?

The requirement

Support has escalated: refunds on partially shipped orders refund the full amount. It must refund only the unshipped lines. The refund code is 900 lines, untested, and the last person to change it left.

The obvious build

Read the refund code, find the line that sums the order total, change it to sum unshipped lines, and ship. It is a one-line fix; everything else is ceremony that will not fit in a week anyway.

Why it breaks

The one-line fix is usually findable and usually correct. What breaks is everything adjacent: the same total feeds the accounting export, the customer email and the partial-refund reconciliation job, and none of that is visible from the line being edited.

How it breaks as requirements change
  • The one-line fix is usually findable and usually correct. What breaks is everything adjacent: the same total feeds the accounting export, the customer email and the partial-refund reconciliation job, and none of that is visible from the line being edited.
  • Without a baseline there is no way to answer "what else moved?", so the review becomes an argument between people's recollections of what the code used to do.
  • The refactor that makes the fix obvious — extracting the amount calculation — gets done in the same commit, so the diff mixes a structural move with a behaviour change and nobody can review either.
  • As requirements keep arriving, this repeats. Each one-line fix leaves the module exactly as unprotected as before, so the third such ticket costs the same as the first — the work never accumulates (Change Amplification).
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

What limits the solution, and what must never stop being true

This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.

Constraints
  • The fix is customer-visible and financially material, so it cannot be shipped and observed — it has to be right.
  • There is one week, which is enough for this loop over the refund path and not enough for it over the whole module.
  • No behaviour other than the refund amount may change, and there is currently no way to demonstrate that.
  • The team ships weekly, so the work must be decomposable into merges that are individually safe (Review Size).
Invariants
  • Structure and behaviour never change in the same commit. This is the load-bearing rule of the whole loop (What Refactoring Actually Is).
  • At every step the system remains shippable — an abandoned loop must leave the codebase better or neutral, never mid-surgery.
  • The verification step compares against the baseline captured *before* any edit, not against what the code did after step three.

Who owns what, and where the seams fall

Responsibilities decide boundaries; boundaries decide what an interface has to say.

Responsibilities
  • Step one owns a written statement of what the code currently does on the affected path. Not what it should do — what it does.
  • The characterization suite owns detection: after any edit, it is the thing that answers whether anything unintended moved (Characterization Tests).
  • The seam owns substitutability, and nothing else (Seams).
  • The behaviour-change commit owns exactly one behaviour, so that a revert is a precise instrument.
Boundaries
  • The loop is scoped to the *blast radius of one change*, not to a module. Scoping it to the module is how a one-week fix becomes a one-quarter project that is cancelled at week six.
  • The boundary between step four and step five is the one that matters: refactor commits and behaviour commits must be separately reviewable and separately revertable.
  • Verification happens at the same boundary the characterization tests were written at, or the comparison is not a comparison (Where a Test Must Be Real).

The loop, and what each step is protecting

Each step exists because the following step would otherwise be unverifiable. That is the only justification for the order, and it is enough: read the pipeline as a chain of preconditions rather than as a checklist.

The two steps people merge are four and five, and merging them destroys the property the whole sequence exists to create — that a reviewer can tell a structural move from a behaviour change by looking at the diff.

Six steps, and the way each one fails when skipped
  1. 1
    Understand

    Trace the affected path. Read production data shapes. Write down, in a paragraph, what the code currently does.

    fails by Starting from what the code was *supposed* to do, which is how the wrong line gets changed confidently.

  2. 2
    Characterization tests

    Pin current behaviour on the affected path, including behaviour that looks wrong, at a coarse boundary.

    fails by No baseline, so "nothing else moved" becomes an opinion rather than an observation (Characterization Tests).

  3. 3
    Create a seam

    Make the smallest substitution point that lets the net run — usually a defaulted parameter for the clock, the network or the store.

    fails by Building an elaborate injection layer before anything is protected, which is a large unverified edit (Seams).

  4. 4
    Small refactor

    Move structure only, in steps, running the net after each. Stop when the behaviour change would be small and obvious.

    fails by Refactoring past the target into a redesign nobody asked for, or refactoring with the net unrun (The Refactoring Loop).

  5. 5
    Change behaviour

    One commit, one behaviour, plus a test that asserts the new intent.

    fails by Riding along in the refactor commit, making both unreviewable and the revert imprecise.

  6. 6
    Verify

    Run the baseline. Review every red test and classify it: intended, or regression. Out loud, per test.

    fails by Reading green as success when tests were deleted or weakened during step four.

The loop is scoped to one path, not one module. Scoping it to a module is the reliable way to run out of week at 60% (Slicing a Feature).

Why the refactor and the change must be separate commits

This is the rule that feels like bureaucracy and is not. A refactor commit has a property no other commit has: it should be provably behaviour-neutral, and a reviewer can check that by reading. A behaviour commit should be small enough that its entire risk is visible.

Merge them and both properties vanish at once. The reviewer facing a 400-line diff containing one intended behaviour change cannot find it, and the revert — if production disagrees — takes the structural improvement with it.

The same week of work, committed two ways
One commit: "fix partial refunds"
1 commit, 412 lines changed
  - extracted RefundAmount from OrderRefundService
  - renamed 6 variables
  - moved shipping lookup out of the loop
  - changed total -> unshipped total        <-- the actual fix
  - reformatted the file

Review: "looks fine"
Production incident on an unrelated path: revert loses everything.
Four commits, one behaviour
1  test: characterize refund amounts on partial shipment  (+180, -0)
2  refactor: extract RefundAmount, no behaviour change      (+96, -84)
3  refactor: hoist shipping lookup out of the loop          (+11, -14)
4  fix: refund only unshipped lines (PRICING-419)           (+6, -2)

Review of 4 is six lines. Revert of 4 is six lines.
Commits 1-3 stay, and the next ticket starts from them.

The revert granularity is the argument. In the first arrangement, the cost of being wrong about the fix is losing a week of structural work; in the second it is losing six lines. That difference is what makes it rational to ship the change at all — and it is also why commit 1 comes first, since without a baseline commit 2 is a claim rather than a check (Reversible and Irreversible Decisions).

What the loop costs, honestly

The loop is not free and should not be sold as free. It converts one expensive ticket into one more-expensive ticket plus a much cheaper stream of subsequent ones — which is a good trade only if the stream exists.

Price it against the alternative on a path that is actually going to keep receiving tickets, and the case is straightforward. Price it on a path nobody will touch again and it loses, which is worth admitting before starting.

Refunds on partially shipped orders, and the two tickets after it
The change

Refund only unshipped lines; then, three weeks later, exclude non-refundable shipping fees; then, a month after that, support partial refunds of already-refunded orders.

One-line fix each time, no net, no structural change
OrderRefundServiceAccountingExportRefundEmailReconciliationJob
tests(manual scenario checklist, re-run by hand each time)
4 modules · 1 test file

Ticket one: 1 day plus a half-day of manual checking. Ticket two: the same. Ticket three: the same, plus an incident, because by then three one-line fixes interact in a way nobody modelled. The curve is flat and the risk compounds.

Full loop on ticket one; steps four to six only on tickets two and three
Refund.AmountOrderRefundService
testsrefund_characterization_suiterefund_unshipped_lines_testrefund_fee_exclusion_test
2 modules · 3 test files

Ticket one: 4 days. Ticket two: 1 day, because the net and the extracted calculation already exist. Ticket three: half a day, and the interaction between all three rules is expressible as a test rather than discovered in production.

what it cost Four days versus one on the first ticket, at a moment when support is escalating — the loop is most expensive exactly when pressure is highest, which is why it gets skipped. It also leaves a characterization suite someone must own, and it improved one path while the other eight paths through OrderRefundService remain exactly as unprotected as before (What Technical Debt Actually Is).

How to build it

Most important first.

  • Understand first, and write it down. Read the path, trace it in a staging environment, query production for the shapes of data it actually receives. Two hours here removes most of the risk in the remaining five steps.
  • Characterize before touching anything. The baseline must describe the code as it was found, including whatever it does that looks wrong (Characterization Tests).
  • Create the smallest seam that lets the net exist, and confirm production behaviour is unchanged by inspection, because nothing is protecting that edit yet (Seams).
  • Refactor in small steps, running the net after each one, until the behaviour change becomes a small and obvious edit. This is the step that makes the fix a one-liner *legitimately* (The Refactoring Loop).
  • Change the behaviour, in its own commit, with a test that asserts the new intended behaviour — this one asserts intent, unlike everything before it.
  • Verify against the baseline and review the deltas deliberately: every characterization test that went red is either the intended change or a regression, and someone has to say which, out loud, per test.
  • Leave the net behind. The loop's compounding return comes entirely from the fact that the next ticket starts at step four.

What the next change costs

The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.

Cost of the next change
  • This ticket: roughly four days instead of one, and that is the honest price. Two hours understanding, a day characterizing, an hour seaming, a day refactoring, an hour changing, half a day reviewing deltas.
  • The next ticket on the same path: about a day, because steps two and three are already done and the refactor from step four made the code legible.
  • The third: hours. This is the entire argument for the loop — the cost curve bends, whereas the one-line-fix curve is flat forever.
  • What stays expensive: a ticket on a *different* path through the same module, which starts again at step one. The loop makes one path cheap, not the module, and pretending otherwise is how the quarter-long version gets funded.
What the recommended approach costs
  • Four days for a one-line fix is a real cost paid now for a benefit that arrives on the second or third ticket. If there is no second ticket, the loop lost, and sometimes you can tell that in advance.
  • The discipline of separate commits slows down an engineer who is right, and some of that friction is genuinely wasted.
  • It biases toward preserving current behaviour, which means it will occasionally carry a defect forward that a bolder approach would have removed.

What can go wrong

Failure modes
  • Steps four and five are merged into one commit "because it is easier", and the review can no longer distinguish a move from a change. This is the single most common way the loop fails.
  • The loop is scoped to the whole module, runs out of time at 60% and is abandoned, leaving a half-seamed module that is now harder to understand than when it started.
  • The characterization suite is written after the seam and captures the seamed behaviour, so the riskiest edit in the sequence is the one thing with no baseline.
  • Verification is performed by running the suite and seeing green, when in fact three tests were deleted during the refactor because they were "in the way".
  • The mitigation's own failure: teams that internalise the loop start applying it to code that is already well tested, where steps one to three are pure overhead (When Design Does Not Pay).
Dependencies, and their direction
  • Step three depends on step two only in the sense that you want the net first — but the net often cannot be built until a seam exists. That circularity is real, and it is resolved by making the first seam small enough to verify by reading.
  • Step six depends on step two having captured the right surface. A baseline over the wrong boundary verifies nothing.
  • The loop depends on being able to run the code at all, which in some legacy systems is itself a week of work and should be estimated as such.
Misreads
  • "The loop is a process to complete." It is an ordering, and its value is in noticing which step you cannot do. Being unable to characterize the path is information — it usually means the seam has to come first, and that the seam edit needs a reviewer.
  • "Small steps means slow." Small steps are what allow the steps to be unverified-but-obvious rather than large-and-hoped-for. The wall-clock time is dominated by understanding, not by typing.
  • "We can skip characterization because we have monitoring." For a financial path, discovering the regression from production is discovering it from customers (Designing for Failure).
  • "Refactoring first is gold-plating." Step four is bounded by a specific target: make the behaviour change small and obvious. Refactoring beyond that point is a different activity and should be a different decision (Over-Design and Under-Design).

Testing it, and how it ages

What to test, and at which boundary
  • Two kinds of test, deliberately different: characterization tests that pin the old behaviour, and one intent test that asserts the new refund rule. Do not let them live in the same file, or their different authority gets forgotten.
  • Run the net after every refactoring step, not at the end of the day. The value of a net is proportional to how quickly it tells you which step broke something (The Refactoring Loop).
  • Add a regression test for the escalation itself — a partially shipped order refunds only unshipped lines — phrased in the domain's language so it survives future restructuring (Naming and Domain Language).
How this design ages
  • After three or four passes the module stops being legacy along the paths that get touched, and stays legacy everywhere else. That uneven outcome is correct and worth saying out loud, because it looks like inconsistency in a code review.
  • The characterization tests should be progressively replaced by intent tests as behaviour gets specified, and the suite should shrink.
  • Eventually the module has enough structure that the loop collapses into ordinary development: understand, change, verify. That is what finishing looks like.

Where this applies

This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.

  • GENERALThe ordering follows from what each step needs as input — you cannot detect an unintended change without a baseline, and cannot build a baseline without a way to run the code — so it holds regardless of language or stack.
  • LIFETIME-SPECIFICFor a system with a known decommission date inside a year, the compounding argument disappears and the one-line fix plus careful manual checking is often correct, because the second and third tickets that repay the loop will never arrive.
  • CONTESTEDThe strongest opposing view: for teams with strong production observability, fast rollback and small blast radius, shipping the small fix behind a flag and watching real traffic finds more real defects per hour than any offline characterization pass, because production has inputs your synthesised cases never will. That argument is strong for idempotent, reversible, non-financial paths and weak here — a wrong refund is money that has already left, and rollback does not unsend it (Feature Flags and What They Cost).

Where the depth lives

This domain teaches the codebase-level structure and hands the rest off.

Domains that do not exist yet
  • Testing & Reliability Engineering — how much confidence a given suite actually provides, and what to do when the honest answer is "not enough to ship this without a canary".