AI-assisted refactoring of legacy codebases works when the model is treated as an editor inside a harness you built, not as an architect who understands the system. The harness is characterization tests that freeze current behavior, seams that isolate one unit at a time, and a verification loop that compares old and new output on real traffic. With those three in place, a model can convert an untyped Express route into a typed service in a reviewable pull request. Without them, the same model will confidently rewrite a payment path, drop a null check nobody documented, and ship it behind a green build.
The distinction matters because most failures blamed on AI refactoring are process failures. Teams hand a 900-line file to a model with the instruction “modernize this”, get a plausible diff, approve it, and find out three weeks later that a rounding rule changed. The model did what it was asked. The harness did not exist.
This is the execution-level playbook: what to build before the first prompt, how to slice work so the model cannot wander, when a deterministic codemod beats a language model, and how to prove a refactor changed structure without changing behavior. For the wider survey of where LLMs fit in modernization, our earlier guide to refactoring legacy code with AI covers the broader landscape. This piece is the part you run on Monday.
How do you safely refactor legacy codebases with AI coding assistants?
Michael Feathers defined legacy code as code without tests, and that definition survives every framework cycle because tests are what let you change structure safely. A four-year-old service with no test coverage is legacy. A twelve-year-old service with a characterization suite is maintainable.
Concretely, a legacy codebase has four properties that block AI-assisted change:
- No observable contract. Behavior lives in database side effects, error logs, and customer habits rather than assertions.
- No seams. Business logic is fused to framework code, so you cannot execute one rule without booting the whole application.
- Implicit types. Values are strings shaped by convention, so a model reading a function signature cannot know what it receives.
- Unknown callers. Nobody knows which of the 40 places that import a helper actually rely on the bug in it.
A language model cannot fix any of those by reading faster. It can only change code that sits inside a seam you built.
Why rewriting is the expensive option
The instinct when a codebase is painful is to start clean. The evidence against it is old and consistent. Joel Spolsky called a rewrite “the single worst strategic mistake that any software company can make” after watching Netscape do exactly that. Martin Fowler’s catalog in Refactoring: Improving the Design of Existing Code exists because incremental, behavior-preserving change is how working systems stay working.
The economics are simple. A rewrite reimplements every feature, including the undocumented ones customers depend on, and it does so while the old system still needs maintenance. Incremental refactoring pays that cost in small installments with the old system running and earning. The reason teams choose rewrites anyway is that incremental work feels slow and invisible, while a rewrite has a launch date and a demo. The launch date is the part that lies.
AI does not change this calculus. It changes the cost of the incremental path, which is the opposite of an argument for a rewrite.
The order of operations that decides the outcome
Five steps, in this order. Skipping any one of them is how a project ends up with a modern-looking codebase and a new class of production bug.
- Instrument. Build characterization tests around the units you intend to touch, using real inputs and recorded current outputs.
- Cut seams. Introduce interfaces and dependency injection at the boundaries of those units so they can run outside the framework.
- Slice. Choose one seam per change, sized for a single reviewable pull request.
- Transform. Apply a codemod where the change is mechanical, an LLM where the change needs judgement about intent, and a human for design.
- Verify. Run differential tests, then shadow traffic, then cut over, with a rollback path that has been exercised.
What are characterization tests, and why do they decide the outcome?
A characterization test asserts current behavior, including behavior that looks wrong. That is the point. Its job is not to describe the ideal system, it is to pin the one you have, so that when a refactor changes output you find out immediately instead of at month end.
How to write them without spending six months:
- Record real inputs. Pull a sample of production payloads, or the fixtures in the integration tests nobody trusts, and replay them.
- Capture golden outputs. For pure functions and formatters, snapshot the output of the current implementation and commit it. Approval testing tools or plain
toMatchSnapshotin Vitest do this without ceremony. - Assert on contracts, not internals. Test the shape and values crossing a boundary, not the private helper calls underneath it.
- Cover the paths that touch money, identity, and scheduling first. Those are where a silent change is expensive. A working target is 70 to 85 percent coverage on the slice, not 100 percent across the application.
- Keep the suite fast. A characterization suite that takes 25 minutes will be skipped locally, then skipped in review, then deleted. Under five minutes changes the behavior of the team.
A useful side effect: writing these tests exposes the implicit rules. The first week of characterization work usually produces a list of “we cannot refactor this part because nobody knows what it does”, which is the real backlog.

How do you break large legacy files into manageable slices?
The most common practical mistake is asking a model to modernize a whole file because the file is large. Context windows are not the constraint; blast radius is. A 2,000-line utility file with two exports can be a safe single change. A 120-line function that writes to three tables and calls a payment API cannot.
Slice along the dependency graph instead:
- One exported symbol per change. If the function is imported in 30 places, change the implementation behind the same signature first, then migrate call sites separately.
- Introduce the boundary before the body. Add the interface, adapter, or typed wrapper, then let the model fill in the body against that shape.
- Feed the model the dependencies, not the neighbors. Give it the target, the interfaces it calls, the tests, and the type definitions. Do not paste the entire module tree; that invites edits outside the slice.
- Size for review. If the diff cannot be read in 20 minutes by someone who knows the system, it is too big to trust, regardless of how correct it is.
Typed boundaries are what make the model’s job narrow enough to verify. If you are moving untyped JavaScript toward TypeScript, the narrowing and type guards documentation is the reference for the part models most often get subtly wrong: runtime checks that look like types but do not actually validate input. Pair that with schema validation at the edge, which our Zod and structured outputs guide covers for API boundaries.

Codemods versus LLM edits: a decision rule
Not every change needs a model. The deterministic option is faster, cheaper, and repeatable in CI.
| Change type | Tool | Why |
|---|---|---|
| Rename a symbol across the repo | ts-morph, IDE rename, or ESLint fix |
Deterministic, reviewable, zero hallucination risk |
| Framework API migration (React class to hooks, jQuery to fetch) | OpenRewrite recipes or Codemod | Repeatable across hundreds of files, auditable diff |
| Language upgrade (JS to TS, CommonJS to ESM) | TypeScript compiler in stages, ts-migrate for initial annotations |
Compiler verifies the result |
| Extract a function, name variables, simplify conditionals | LLM | Needs judgement about intent and naming |
| Redesign a module boundary | Human, with LLM as a drafting tool | Design decisions carry consequences tests cannot catch |
| Write characterization tests for an undocumented path | LLM drafts, human validates against production behavior | Model suggests cases, engineer confirms reality |
The pattern: mechanical transformations belong to deterministic tools, judgement calls belong to the model, and design belongs to people. A pipeline that inverts this ordering produces impressive diffs and unpredictable regressions.

The strangler fig at function level
The strangler fig pattern is usually described at the service level. It works at the function level too, and that is where AI-assisted refactoring benefits most.
- Declare the target interface. For example,
InvoiceCalculator.calculate(input): InvoiceResult. - Wrap the legacy implementation behind an adapter that implements the interface and delegates to the old function.
- Add a differential test that calls both implementations with the same inputs and compares outputs.
- Reimplement inside the adapter with the model’s help, in small commits.
- Cut over when the differential test passes on recorded production inputs, then delete the old path.
Because the old implementation stays reachable until cutover, every step is reversible. The model works against a fixed interface, which limits what it can change, and the differential test grades it in the same run.

Verification that actually catches behavior change
Unit tests prove the units you thought of. A refactor changes the things you did not think of, so verification has to run on real inputs.
- Differential testing calls old and new implementations with the same input and compares results, including errors. This is the cheapest high-signal check and it belongs in CI for the duration of the migration.
- Golden masters on real payloads catch the edge cases in production data that fixtures miss: the empty array, the null customer, the 400-character name.
- Shadow traffic runs the new path on a mirror of live requests without returning its output to customers. Compare results offline. Route the diff rate into a dashboard before you trust the cutover.
- Error budget for the migration sets the threshold at which you stop and roll back, agreed before the work starts rather than argued about during an incident.
- Mutation testing on the new code checks whether the tests actually fail when behavior changes. A refactor covered by tests that assert nothing is not covered.
Google’s code review guidance is worth rereading for this phase, and our automated test strategy guide covers how to keep that suite trustworthy as it grows: small changes, one purpose per change, and a reviewer who can hold the whole diff in their head. AI-assisted refactoring fails review culture long before it fails technically.
A worked example
A PHP monolith at 180,000 lines, twelve years old, no test suite, four engineers. The business wanted a typed service layer without a rewrite. The sequence that shipped in eleven weeks:
- Weeks 1 to 3: Characterization harness on the three highest-risk paths (invoicing, subscription changes, tax calculation). 1,400 recorded payloads, 380 assertions, suite runs in 90 seconds.
- Weeks 4 to 6: Seams introduced at 14 boundaries. Each got an interface and an adapter delegating to the legacy implementation.
- Weeks 7 to 10: Module-by-module conversion with the model editing inside one seam per pull request. Codemods handled 2,100 mechanical renames and the CommonJS to ESM move; the model handled 61 refactors that required judgement.
- Week 11: Shadow traffic on the migrated paths, 0.3 percent diff rate traced to two rounding rules that the characterization tests had pinned correctly and the new implementation had changed. Both fixed before cutover.
The measurable outcome was not speed of code generation. It was change risk: after the migration, the team shipped a pricing rule change in a day, a change that previously took three weeks of manual verification. That is the return worth funding, and it comes from the harness, not from the model.
Failure modes that repeat
- The model invents an API. It calls a method that does not exist because the name looks plausible. Type checking catches this if you run it; a diff review often does not.
- Test theater. Tests are generated from the new implementation instead of recorded from the old one, so they assert whatever the model wrote. Regenerate from the legacy path or the suite proves nothing.
- Big-bang merge. A week of model-generated changes in one branch cannot be reviewed, so it gets approved. Split by seam and merge daily.
- Untyped boundaries. The logic is typed, the boundary is
any, and the bug moves rather than disappearing. - Stale context. The model is fed an interface that changed three commits ago and produces code against the old shape. Regenerate the brief from the current tree every session.
- No ownership. A migration without a named engineer who owns the harness becomes an orphaned branch within a month.
The checklist to run before the next pull request
- Characterization tests exist for the slice you are about to touch, recorded from current behavior.
- The seam exists and is exercised outside the framework.
- One exported symbol per change, one purpose per pull request.
- Mechanical edits go through a codemod, not a prompt.
- Type checks and the differential test run in CI on that slice.
- A rollback path exists and someone has executed it in staging.
- The migration has an owner, a stop threshold, and a dashboard.
Conclusion
AI-assisted refactoring of legacy codebases is a harness problem before it is a prompting problem. Build the characterization tests, cut the seams, slice along dependencies, use codemods for mechanical change and the model for judgement, then verify on real traffic before cutover. Do that and a legacy codebase becomes a sequence of small, reversible changes that a model can help with. Skip it and you get a faster way to ship unknown behavior changes.
If your team is planning a legacy modernization and wants the harness reviewed before the first migration branch, our software development practice and AI consulting both do this work, usually starting with the characterization suite.


