Surgery or Amputation: How to Use LLM Prompts for Legacy-Code Refactoring Without Breaking Production
A 4,000-line OrderService that nobody has touched since 2021. Two engineers argue: one wants to rewrite it from scratch with an LLM in a weekend, the other wants to extract one responsibility at a time. Both are right — for different codebases. The decision is not taste, it is measurable, and this article gives you the prompts for both paths plus the test that tells you which one you are allowed to take.
Which path am I on? The two-question test
Before any prompt, answer two questions honestly.
- Do you have tests that fail when behaviour changes? Not "we have tests" — tests that go red when a discount calculation shifts by a cent.
- How many other modules import this one? Count the inbound edges, not the lines.
| Tests | Coupling | Path |
|---|---|---|
| Good coverage | Low | Rewrite from spec |
| Good coverage | High | Extract gradually |
| No coverage | Low | Write the safety net first, then rewrite |
| No coverage | High | Extract gradually, characterisation tests first |
The trap is the third row: people see "no tests, low coupling" and rewrite. Without a net you cannot prove the new module behaves the same. The prompt below builds that net before you touch anything.
How do I build a safety net for code nobody understands?
Characterisation tests: you do not assert what the code should do, you assert what it does. Run the legacy function with real inputs, capture the output, freeze it.
Prompt:
You are a test engineer working on legacy code with no test coverage.
Here is the function: [paste code].
Do NOT judge whether the behaviour is correct.
1. List every input the function reads (parameters, globals, env vars, DB calls, clock, randomness).
2. For each, give 3 concrete values that hit different branches, including one degenerate (null, empty, zero, negative).
3. Write characterisation tests in [pytest/JUnit] that assert the CURRENT output for those inputs, quirks included.
4. Flag any branch you cannot reach from outside — that is a hidden dependency I need to know about. More on the topic: “[Prompt Engineering: A Step-by-Step Learning Plan — From Zero-Shot to RAG, Working with GPT/Claude, and Token Optimization](/en/blog/prompt-inzhiniring-prompt-engineering-poshagovyy-plan-osvoeniya-ot-zero-shot-do-rag-raboty-s-gpt-claude-i-optimizatsii-tokenov)”.
Example: a calc_shipping function reads datetime.now() internally. The LLM flags it as unreachable-from-outside, you inject a clock parameter, and now the test is deterministic. That one flag saves you a flaky suite three months later. More on the topic: “The LLM Detective: DevTools Prompts That Reproduce Bugs, Read Stack Traces and Set Breakpoints Instead of Guessing”.
How do I ask the LLM to split a monolith into responsibilities?
Do not ask "refactor this." Ask for a map first, code second. More on the topic: “Stop Asking LLMs to Guess CVEs: A Pentester's Prompt Playbook for nmap, Burp Suite and OWASP Top 10”.
Prompt:
Read this module: [paste code].
Do not write code yet.
Group every function and method by the single responsibility it serves.
Output a table: responsibility | functions | why they belong together | what data they share.
Then mark which groups talk to each other and through which variables.
Finally: which group is the safest to extract first, and why? More on the topic: “[From Idiomatic Go to Data Race Hunting: 12 Prompts That Turn an LLM Into a Go Pair Programmer](/en/blog/go-bez-c-privychek-12-promtov-kotorye-zastavyat-llm-pisat-idiomatichnyy-kod-i-nakhodit-data-race)”.
The last question matters. The safest extraction is the one with the fewest shared variables and the most tests — usually a pure calculation, not the I/O-heavy part everyone wants to fix first.
How do I build a dependency map the LLM can actually read?
A prompt cannot see your repo. Feed it the imports and call sites, not the whole codebase.
Prompt:
Here is the list of files importing OrderService: [paste grep output].
Here is OrderService's public API: [paste method signatures].
Build a dependency map: which external module calls which method, and what it does with the result.
Flag any caller that reaches into internal state or relies on a side effect (writes to DB, sends email, mutates a passed object).
Those callers are the ones that will break when I extract a module.
Callers that depend on side effects are your real coupling. A caller that just reads a return value is cheap to migrate; a caller that assumes an email was sent is not.
How do I extract one responsibility without changing behaviour?
Now you write code — but only the move, not a rewrite.
Prompt:
Extract the [responsibility] group into a new class/module.
Constraints:
- Behaviour must be byte-identical for all inputs.
- No renaming of public methods, no signature changes.
- Keep the original function as a thin delegating wrapper.
- List every line you were tempted to "improve" but did not, and why.
That last line is the discipline. The LLM will want to fix the naming, the null checks, the loop. Every "improvement" is an untested behaviour change smuggled into a refactor. More on the topic: “CI/CD Without Surprises: 12 Prompts That Make the LLM Write Pipelines, Fix Broken Builds and Explain Terraform Plans in Plain English”.
How do I verify the refactor didn't change behaviour?
Run the characterisation suite. Then ask the model to diff intent, not text.
Prompt:
Here is the old function and the new extracted one: [paste both].
Ignore formatting. Compare behaviour branch by branch:
- Same return values for the same inputs?
- Same exceptions thrown in the same conditions?
- Same side effects in the same order?
- Any new dependency introduced?
Output a table of differences, or state explicitly that there are none.
If the table is empty and your tests are green, you have earned the right to delete the wrapper in a separate commit. More on the topic: “Green Tests, Broken Contracts: 12 LLM Prompts That Hunt Reentrancy, Oracle Holes, and Gas Leaks Before Your Solidity Audit”.
When is a full rewrite from spec actually the right call?
When coupling is low and you can describe the module's contract in a page. Write the spec from the tests and call sites, not from the old code — otherwise you copy the old design's mistakes. More on the topic: “AI Agents in Practice: Designing Multi-Agent Systems for Complex Tasks — and Who This Path Is Actually For”.
Prompt:
Here are the call sites and the characterisation tests for [module].
Do not read the old implementation.
Write a spec: inputs, outputs, error cases, invariants.
Then implement it fresh. Where the spec is ambiguous, list the ambiguity instead of guessing.
The ambiguities are your risk register. Each one is a place where the rewrite can silently diverge — resolve them before merging.
The one rule that keeps you out of the incident channel
LLM-assisted refactoring fails for a boring reason: the model is happy to change behaviour while calling it cleanup. Your prompts must forbid improvement and demand proof. Extract small, test first, delegate through wrappers, and delete only after the suite agrees. The monolith does not need to be brave — it needs to survive the week. More on the topic: “Go, LLM, and the Race Against Races: How to Prompt for Idiomatic Concurrency, Explicit Errors, and Context-Aware Code”.