Changing legacy code with AI agents
Tuesday, 10:15. Atlantica wants consolidated invoices instead of 420 separate ones, and the only invoicing code is a 6,400-line file from 2017 with 11 tests. Claude Code offers to rewrite it "in about a day". On the 1st of the month it must still send 10,000 hotel invoices, right to the cent.
Old code nobody understands? Let the agent map it, photograph what it does today, then replace it piece by piece. Never all at once.
- Problem
StayHub for Chains needs consolidated invoices for Atlantica (one per legal entity and country), and the only invoicing code is src/billing/invoice_builder.js: 6,400 lines from 2017, 11 tests, its author long gone. It also produces 10,000 hotel commission invoices every month that must stay right to the cent.
- Why the simple system fails
An agent can rewrite 6,400 lines in a day, and its rewrite will look cleaner. But nobody wrote down what the old code does, so nobody can check that the new code does the same. The undocumented behaviour (Swiss 0.05 rounding, yen without decimals, a 2019 tax exception) disappears silently.
- Engineering concept
Understand before changing: map the code with the agent and verify its claims; pin today's behaviour with characterization tests built from real past outputs; find a seam; route new behaviour around the old code (strangler fig) in small steps that can each be undone.
- Technology
Claude Code in plan mode or read-only for mapping, sub-agents for research, file:line citations, traces and logs of a real run, golden-master tests on anonymized production samples, feature flags, shadow comparison, a per-directory CLAUDE.md that marks the legacy area.
- Trade-offs
Pinning and strangling cost weeks up front and keep two paths alive for a while. A rewrite feels faster and is cheaper to type with an agent, but the cost of a rewrite was never typing: it is discovering, by breaking it, what the old code quietly did.
The problem
2 min- You · Engineer at StayHubThe only invoicing code is
invoice_builder.js: 6,400 lines, 2017, 11 tests. The author left in 2020. - InèsIt sends about 10,000 hotel invoices on the 1st at 06:00. Please don't break it.
- You · Engineer at StayHubClaude Code says it can rewrite it as clean modules in about a day.
The big idea
4 minAgents make understanding old code cheap, not verifying it. Map, check the map, pin today's behaviour, then replace slice by slice.
A rewrite takes a day to type. Proving 10,000 invoices still come out identical takes exactly as long as before agents. That's where the order of the steps comes from.
How it works
5 minLegacy code is code you're afraid to change because you can't tell what will break. You don't knock down an old house with the family inside: you survey it, photograph every room, add the new kitchen through one doorway, close the old one last. The surveyor is fast but sometimes confidently wrong about which walls hold the roof.
- 1Map, read-only
A session that can't edit maps entry points, callers, what it writes and hidden rules (CHF rounded to 0.05, JPY without decimals, Canary Islands tax), each claim with file:line.
- 2Verify the map
Spot-check claims with search, a staging trace of one real invoice, and the owner. The map is fluent and sometimes wrong.
- 3Pin behaviour
A records what the code does today on real inputs and fails on any difference, bugs included. Here: 2,000 real past invoices as golden tests.
- 4Add a seam
A is one place to switch which code runs without editing the old code: a router in front of
buildInvoice, flag off. - 5Strangle, slice by slice
With the pattern, new code takes over slice by slice: chain accounts go to new code behind a flag; hotels stay on the old path.
Watch a decision being made
4 minYou add chain invoices without risking hotel invoices
Ship consolidated invoices while 10,000 hotel invoices go out every month unchanged.
- 1Map, then verify
14 cited claims and 3 unknowns. You spot-check 12; two are wrong:
roundLegacy()is called dynamically asthis['round' + mode](line 1399), and credit notes reusebuildInvoicewith negative lines.Why: The wrong claims are exactly the dangerous ones: deleting "dead" code, building credit notes separately.
- 2Pin today's behaviour
2,000 anonymized invoices covering every currency, country, tax rule and credit-note type. Deliberate break on a throwaway branch: change one rounding rule, 41 tests go red. Reverted.
Why: A test you've never seen fail proves nothing.
- 3A seam that changes nothing
Router in front of
buildInvoice, flagchain_invoicesoff. 3 CHF invoices off by 0.02. Claude offers to update the expected files. You refuse: the router passed a number instead of the currency object, skipping the 0.05 rounding. Fixed.Why: A step meant to change nothing must change nothing.
- 4Strangle, enforced
src/billing/chains/calls the old rounding and tax functions instead of copying them, behind the flag for the 20 pilot hotels.src/billing/CLAUDE.mdmarks the legacy file off-limits; a hook blocks agent edits.Why: Chain and hotel invoices share the exact same rules, and the rules agents follow are enforced.
In real life
3 minNetscape: rewrite from scratch
- Restarted the browser codebase
- About three years to the next major release
- Joel Spolsky's "Things You Should Never Do"
Years of hidden fixes thrown away.
GitHub: incremental Rails upgrade
- Ran the app on old and new versions side by side
- Fixed differences in small steps
- Feature work never stopped
Compared until the switch was boring.
What goes wrong
3 minCode can be called by a name built at runtime, a config or a scheduled job.
Runtime evidence over a full cycle before deleting anything.
The alarm goes quiet exactly when it rang.
A difference is a question for the owner.
The line that matters hides among 400 lines of formatting.
Separate diffs, separately reviewed.
Trade-offs
3 min| Approach | Fits when | You pay |
|---|---|---|
| Rewrite with an agent | Small modules, fully known and well tested | Hidden rules vanish; users find differences |
| Pin + seam + strangle | Money, tax, availability with undocumented rules | Weeks of pinning; two paths a while |
| Copy and build beside | New work sharing nothing, or a dated temporary step | Rules drift apart |
- Karim · CTOTwo weeks of tests on old code? Let Claude rewrite it. We'll fix whatever breaks.
- Omar · On-call SREOr copy the rounding and tax logic into a new service. Zero risk to hotel invoices.
- Karim · CTOAnd maintain two copies of tax rules forever?
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Rewrite, strangle or leave alone, decided by how well behaviour is pinned and how reversible mistakes are.
- Which real past outputs become characterization tests, and which cases they must cover.
- Any intended change to legacy behaviour: only with the owner, as its own change.
Don't edit any files. Map src/billing/invoice_builder.js: entry points, every caller in the repo (including dynamic calls and scheduled jobs), what it writes, and every rounding, tax or special-case rule. Cite file and line for each claim, mark how sure you are, list what you couldn't determine. Write it to docs/legacy/invoicing-map.md. Then propose how to select ~2,000 anonymized past invoices covering every case.
- Spot-check a sample of map claims yourself: search, a staging trace, the owner.
- Golden samples cover CHF, JPY, Canary Islands tax and credit notes, with no personal data.
- A seam step shows zero golden differences with the flag off.
- The agent "tidies" legacy code during a mapping session.
- It proposes updating expected outputs or deleting samples to get green.
- "This is dead code" based only on a text search.
Check yourself
2 minNext episode: week 6. The invoicing slice is safe, but Karim has just heard from Atlantica's CTO that the pilot might slip, and it wasn't from you.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Visual diagram, Analogy, Why it matters, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
Chain invoices without risking hotel invoices
Add Atlantica's consolidated invoices on top of src/billing/invoice_builder.js in six weeks, while 10,000 hotel invoices go out every month unchanged.
- 1Map, read-only
New session in plan mode: "Don't edit anything. Map invoice_builder.js: entry points, callers anywhere in the repo, what it writes (tables, files, emails), rounding and tax rules, anything that looks like a special case. Cite file:line for every claim. List what you're unsure about." Result: 14 claims and 3 unknowns in
docs/legacy/invoicing-map.md.Why: A session that can't edit can't "fix" things on the way. Citations turn a fluent essay into claims you can check.
- 2Verify the map
You spot-check 12 of the 14 claims: grep for callers, one real hotel invoice traced in staging, and 20 minutes with Inès on the tax rules. Two claims are wrong:
roundLegacy()is called dynamically asthis['round' + mode](line 1399), and credit notes reusebuildInvoicewith negative lines.Why: The two wrong claims are exactly the ones that would have caused damage: deleting "dead" code and building chain credit notes separately.
- 3Pin today's behaviour
Claude Code selects 2,000 anonymized invoices from the last 12 months, covering every currency, country, tax rule and credit-note type, and writes golden-master tests. You check coverage (CHF, JPY, Canary Islands present?) and ask for a deliberate break on a throwaway branch: change one rounding rule. 41 tests go red. Reverted.
Why: Representative samples catch the hidden rules; a test you've never seen fail proves nothing.
- 4Add a seam, change nothing
A router
invoice_router.tsin front ofbuildInvoice, flagchain_invoicesoff. Expected: zero golden differences. Actual: 3 CHF invoices off by 0.02. Claude offers to update the 3 expected files. You refuse: the router passed a plain number instead of the currency object, skipping the 0.05 rounding. Fixed; 2,000 green.Why: A step meant to change nothing must change nothing. The golden tests just caught the same kind of bug as the spring incident.
- 5Strangle, slice by slice
The new
src/billing/chains/module builds consolidated invoices per legal entity and country and calls the old rounding and tax functions instead of copying them. Behind the flag for Atlantica's 20 pilot hotels; shadow comparison of totals against the bookings ledger on every run.src/billing/CLAUDE.mdsays the legacy file is off-limits, and a hook blocks agent edits to it.Why: Hotels never touch the new path; chain invoices reuse the exact rules hotels' invoices use; the rules agents follow are enforced, not just requested.
| Step | Evidence it's done | What it caught |
|---|---|---|
| Map | 14 claims with citations; 3 unknowns with owners | The hidden rules: CHF, JPY, Canary Islands tax |
| Verify | 12 claims checked by grep, trace or Inès | Dynamic call to roundLegacy(); credit notes reuse buildInvoice |
| Pin | 2,000 golden tests; 41 red on a deliberate break | Proof the tests can fail |
| Seam | Router merged, flag off, 2,000 green | Router skipping CHF rounding |
| Strangle | the 20 pilot hotels; shadow totals match the ledger | Nothing yet: that's the point |
Visual diagram
Strangling the invoice builder: chain invoices go to new code through one seam, while the old path keeps serving hotels. Click a component, or follow the walkthrough.
Analogy
- Renovating a lived-in old houseSurveyor walks the houseChanging legacy codeAgent maps the code
- Renovating a lived-in old houseFlip each switch to checkChanging legacy codeTrace one real run
- Renovating a lived-in old housePhotograph every roomChanging legacy codeCharacterization tests
- Renovating a lived-in old houseOne doorway to the extensionChanging legacy codeA seam in the code
- Renovating a lived-in old houseClose old kitchen lastChanging legacy codeRetire old path last
Where it breaks: a surveyor rarely invents a wall. An agent can describe code that isn't there, or call a function unused when it isn't. Always check.
Why it matters
Rewrite it
- Clean code by tomorrow
- Hidden rules silently vanish
- Nothing to compare against
- One giant switch-over
Fast to type, slow to trust.
Pin, then replace
- Old path keeps working
- Every difference shows up
- Each step can be undone
Slower start, no surprises.
Old code holds forgotten decisions. StayHub's rounds Swiss totals to 5 centimes and drops decimals for yen. Nobody wrote that down, but hotel accountants rely on it.
And mistakes here are often permanent. A sent invoice can't be edited. Fixing the code doesn't fix the 1,800 wrong invoices already in hotels' inboxes.
Check your understanding
- 1.Retire the old path once nothing uses it
- 2.Pin today's behaviour with characterization tests
- 3.Add a seam that still routes everything to the old code
- 4.Map the code read-only, with citations
- 5.Route one slice to the new code behind a flag; compare
- 6.Verify the map's claims (grep, trace, ask a person)
Practice ladder
Which claims would you check?
Debugging Warm-upClaude Code mapped src/inventory/calendar.ts (2016, 4,100 lines, 30 tests; used by search and every booking) in a read-only session. Its map includes:
- "releaseHold() is only called by the expiry job" (cites :2210)
- "Overbooking allowance is read from hotel settings" (cites :880)
- "syncChannel() is dead code; nothing calls it" (no citation)
- "Availability results are cached for 60 seconds" (cites :120)
For each claim, trace what could make it wrong and what evidence you'd ask for before relying on it.
Complete the pinning plan
Design PracticeFill the blanks in the plan for pinning the invoice builder before the router is added.
- Samples: ___ anonymized invoices from the last ___ months, covering every ___, ___, ___ and credit-note type.
- Each test compares the ___ output (totals, every line, rounding, tax codes) with the saved ___ copy.
- Before trusting the tests: on a throwaway branch, ___ and confirm the tests go ___.
- Rule in src/billing/CLAUDE.md: never ___ to make a golden test pass; report the ___ instead.
- After adding the router with the flag ___, the expected number of golden differences is ___.
Chain availability through the old calendar
Design StretchAtlantica's central reservation system (CRS) will push availability (rooms left per night) through the new ARI API, and the batch applier must write it. Availability lives in src/inventory/calendar.ts (2016, 4,100 lines, 30 tests), used by search and every booking for ~1.3M guests a day. Today hotels update it by hand in the extranet.
Write the plan: the first instruction to Claude Code, how you verify its map, what you pin and from what data, where the seam is, how you roll out, and what makes each step reversible.
Rewrite this one?
Decision StretchA different module: format/currency.ts, 900 lines from 2019, formats prices for display (not for invoices). It has 140 tests covering every currency StayHub supports, and they run in 3 seconds. It's tangled and slows every change to the price display. Claude Code proposes rewriting it as a thin wrapper around the platform's built-in number formatting, about 120 lines.
Write your decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Karim says: "Claude can rewrite the invoice builder in a day. Why are we spending two weeks on tests for old code?" Answer him in a few sentences.