Case Study: Node Backend Refactor
Background
Ledgerly — B2B invoicing. Core API: Express + MySQL, ~45k LOC, five years of growth, three lead engineers over that span, currently owned by a team of four. The billing module (src/billing/, ~11k LOC) had become the place everyone feared: tax rules in three files, proration logic duplicated, a legacyInvoiceRebuild path still hit by 12% of traffic.
Goal: extract a cleaner billing domain, keep MySQL, stay on Express for now, stop the "touch billing and take a week of PTO" culture. They brought Claude Code in for the mechanical moves after two failed human-only attempts that stalled on fear.
Initial vague prompt
Refactor src/billing to be cleaner. Extract a BillingService, remove
duplication, modernize the tax and proration code, and add better tests.
Don't break production.The agent produced a 2.4k-line PR in one session: new BillingService, collapsed three tax helpers into one, "fixed" proration to always round half-up, deleted legacyInvoiceRebuild as dead code (it wasn't — the static analysis missed dynamic require from a cron entrypoint).
Why it failed
Staging soak found it in four hours:
| Symptom | Cause |
|---|---|
| 3% of renewal invoices off by $0.01–$0.03 | Agent "corrected" banker's rounding that finance had encoded as a feature for a specific enterprise contract |
| Nightly rebuild job 500s | legacyInvoiceRebuild deleted; cron still called it via string path |
| New unit tests all green | Tests asserted the new rounding behavior, not production behavior |
The vague prompt asked for improvement. The agent improved. Finance did not want improvement — they wanted identical invoices under a new structure. "Don't break production" is not a spec; it is a wish.
The improved spec
They stopped the PR, restored main, and spent three days on characterization before any redesign. Abridged from specs/billing-extract-phase1.md:
## Phase 1 — Extract without behavior change
### Invariants (must hold bit-for-bit on fixtures)
- Given fixtures/invoices/*.json (847 cases from prod anonymized),
`computeInvoice(input)` output must deep-equal recorded output
- Rounding: preserve current half-even on tax lines; DO NOT "fix"
- Keep legacyInvoiceRebuild; mark deprecated; add deprecation log
### Allowed changes
- Move functions into src/billing/domain/* with re-exports from old paths
- Add characterization suite under src/billing/__characterization__/
- No new features, no tax formula edits, no deletion of cron entrypoints
### Forbidden
- "Cleaning up" magic numbers without an ADR
- Changing error messages (clients parse substrings — yes, really)They generated fixtures by recording production inputs/outputs for one week into a side table, then anonymizing. The agent was only allowed to move code once the characterization suite was green and owned by humans.
Agent workflow
Phase 1 prompt pattern:
Read specs/billing-extract-phase1.md and CLAUDE.md.
Move computeTax and computeProration into src/billing/domain/ without
changing their bodies. Update imports. Run npm run test:characterization.
If any test fails, revert your changes and report which fixture failed —
do not fix the fixture or the code.Only after phase 1 merged did they open phase 2 specs for intentional tax cleanup — each change with an ADR and a fixture update reviewed by finance.
Human intervention points
- Fixture curation — engineers picked the 847 cases; the agent did not invent golden files.
- Diff review for silent logic edits — any changed constant or branch in a "move" PR was a reject, even if tests passed.
- Finance sign-off before phase 2 rounding changes.
- Cron inventory — human grepped for dynamic requires and string-based job names the agent had missed.
Mistakes made
- First PR trusted "better tests." Agent-authored tests encoded the rewrite. Cost: ~6 hours staging debug + trust damage with finance.
- Assuming static analysis finds all callers. Dynamic
require(jobName)injobs/runner.jshidlegacyInvoiceRebuild. Cost: would have been a Sev-2 at 02:00. - Letting the agent "document" magic numbers by renaming them.
TAX_FUDGE_0_004becameMUNICIPAL_SURCHARGE_RATEwith a wrong comment. The rename PR looked helpful; the comment was a hallucination. Caught in review because a senior had written the original fudge in 2021. - Characterization gaps on multi-currency. First fixture set was USD-only. EUR invoices drifted in phase 2. Added 120 EUR fixtures after a near-miss.
Final outcome
Phase 1 took 2.5 weeks calendar time (mostly fixture work). Net LOC in billing: still ~11k, but with a domain boundary and 847 characterization tests. Phase 2 (real tax cleanup) took one week with zero invoice mismatches in a two-week shadow deploy comparing old vs new compute on live traffic.
The team rule that stuck: no agent refactor PR without a characterization suite that fails when you deliberately mutate one line of business logic.
Lessons
- Pin current behavior — including bugs and ugly rounding — before an agent is allowed to improve anything. Improvement is a product decision, not a code-style decision.
- Agent-written tests of agent-written refactors are not safety; they are confirmation. Characterization fixtures must come from production or human-authored expectations.
- "Dead code" deletions require a runtime caller inventory, not just IDE find-references — especially in Node job runners.
- Split move-only phases from behavior-change phases; review them with different checklists.
- Magic numbers need ADRs, not confident new names from the model.
Related
- Playbook: Legacy Refactor — characterization-first process
- Testing AI Code — why agent tests of agent code fail open
- Architecture Erosion — cleanup that changes contracts
- Decision Records — for intentional behavior changes
- Maintenance Prompts — move-only and inventory prompts