Software Development
Refactoring legacy code: a safe method, and when to rewrite
27 October 2026

Refactoring legacy code means restructuring an existing system in small, behaviour-preserving steps so that it becomes easier to understand, test and change, and it can only be done safely once the code is protected by tests that describe what it does today. A rewrite replaces the system instead of reshaping it, and it is justified far less often than teams assume. This article sets out the sequence that keeps production stable: assessment, safety net, incremental transformation, and a clear decision rule for the cases in which refactoring is no longer the right tool.
TL;DR
- Legacy code is best defined operationally: code that cannot be modified with confidence because it lacks adequate tests, regardless of its age.
- Before any change, document scope, goals, risks and verification criteria, and prove that the system can be built and tested in a clean environment.
- Characterization tests capture current behaviour, quirks included, and form the safety net that makes every later step reversible.
- Refactor in small, named steps, and never mix behaviour-preserving work with feature work in the same commit.
- When a system must be replaced, an incremental approach such as the strangler fig pattern carries less risk than a big-bang rewrite.
Table of Contents
- What legacy code means, and why it resists change
- Assessing the codebase before touching it
- Building the safety net
- Refactoring in small, reversible steps
- Refactor or rewrite: a decision rule
- What AI assistance changes, and what it does not
- Our perspective
- How we can help
- FAQ
- Sources
What legacy code means, and why it resists change
In everyday usage, “legacy” suggests old technology. In engineering practice the definition is narrower and more useful. A university software-maintenance text treats legacy code as code without enough tests to modify it with confidence, and prescribes a sequence: identify the points where a change is needed, add characterization tests where they are missing, refactor to improve testability, and only then make the required change under regression protection.

This definition explains why the difficulty is rarely the age of the language or framework. A ten-year-old Java service with a thorough suite can be altered routinely, whereas a eighteen-month-old application written under deadline pressure, with no tests and with business rules embedded in conditionals, behaves as legacy from its first release. Michael Feathers, whose book on the subject remains the standard reference, describes such a codebase as a Pandora’s box for the people who maintain it, and presents tests as the means of nibbling away at uncertainty incrementally rather than confronting it all at once.
The practical consequence is that the first deliverable of a refactoring effort is not cleaner code. It is knowledge: what the system does, where it is fragile, and which behaviours the business depends on, including those that nobody documented.
Assessing the codebase before touching it
Teams that begin by editing the ugliest file usually discover, weeks later, that the ugliest file was not the one limiting delivery. An assessment phase prevents this, and it need not be long.
Define scope, goals and risks in writing
The University of Zurich’s software re-engineering course recommends documenting project scope, context, goals, verification criteria, opportunities and risks, including the likelihood and impact of each risk, before deciding whether to proceed at all. The decision to proceed is a genuine decision: the assessment may conclude that a module should be left alone, retired or replaced.
Goals deserve particular care. Refactoring, as Marco Tulio Valente explains in his open textbook, improves maintainability properties such as modularity, testability and readability while preserving the program’s behaviour. A well-formed goal therefore names one of those properties and leaves externally observable results unchanged: for example, “reduce the cyclomatic complexity of the pricing module so that it can be unit-tested”, rather than “clean up the pricing code”. A practical refactoring-plan template likewise advises that each goal be concrete and measurable, with scope and out-of-scope items recorded, and with validation and rollback criteria defined before work starts.
Prove that the system builds in a clean environment
The Zurich guidance also recommends a step that is frequently skipped: a mock installation in a clean environment, in which the team installs the system, compiles the code and runs whatever tests exist. The exercise exposes missing artefacts, undeclared dependency versions and broken build scripts. A project that can be built only on one engineer’s laptop carries a risk that no amount of code cleaning will remove, and discovering it after a plan has been agreed is expensive.
Locate hotspots with static analysis
Static analysis supplies evidence about where effort should go. A freeCodeCamp guide on complex codebases suggests using it to find high-risk hotspots such as long methods, large classes, duplicated code and complex conditionals, and then converting the findings into measurable targets: lower complexity, smaller units, higher coverage, removal of dead code. The most valuable cross-check is to combine the static findings with version-control history. A complex file that has not changed in three years is a low priority; a moderately complex file that is modified in every release is where refactoring repays itself.
Building the safety net
Without tests, a developer changing legacy code is, in the blunt phrase used in engineering curricula, editing and praying. The remedy is not to write a complete unit-test suite first, which is rarely feasible, but to create targeted tests around the places that will change.
Characterization tests
A characterization test does not assert what the code should do. It records what the code does now, including its quirks, so that any deviation is detected. GitLab’s engineering documentation describes the procedure: save detailed snapshots of representative input combinations before refactoring, rerun these pinning tests afterwards, and compare the results to detect behaviour changes. The inputs should be chosen from production reality, such as anonymised real requests and historical edge cases, since these reveal behaviours that specifications omit.
Two cautions apply. First, a characterization test that passes proves only that behaviour is preserved, not that it is correct; defects discovered along the way should be logged and fixed separately. Second, these tests are scaffolding. The university text cited earlier suggests progressively replacing characterization tests with unit tests that express intent as understanding improves.
Seams: testing code that resists testing
Legacy code often cannot be tested because it constructs its own database connections, reads the system clock or calls external services directly. The concept of a seam addresses this. As the University of Helsinki’s test-driven development course puts it, a seam is a location where behaviour can be altered without editing the core logic, which lets developers break dependencies for the purpose of testing. In Java, this typically means extracting an interface for a collaborator and injecting it through a constructor; in TypeScript, passing a function or a module boundary that tests can replace. Feathers has summarised the idea in an InfoQ interview as a place where things can be separated for testing without additional work, and as a good way to find where tests can be inserted into code that has none.
Refactoring in small, reversible steps
Once the area of change is covered, the transformation itself should be unremarkable. Guidance on refactoring and technical debt consistently asks for small, named, reversible steps, with the code working after each one. The same source states the two-hat rule: a developer either refactors, preserving behaviour, or adds features, changing behaviour, but never both in one commit. The discipline has a concrete benefit: when a regression appears, the offending commit is either a pure restructuring, which should not have altered anything, or a pure behaviour change, which was intended to. Bisecting becomes straightforward.
The following table summarises the techniques that the academic literature and practice converge on, together with the problem each addresses and the check that confirms it was applied safely.
| Technique | Problem addressed | Verification |
|---|---|---|
| Extract method or module | Long units mixing several responsibilities | Characterization tests unchanged and green |
| Rename identifiers | Names that conceal intent | Compiler and static analysis; no test change |
| Simplify conditionals | Deeply nested branching that hides business rules | Pinning tests across each branch |
| Remove duplication and hard-coded values | Divergent copies of the same logic; embedded constants | Snapshot comparison before and after |
| Introduce a seam or interface | Direct dependencies that block testing | New unit tests run in isolation |
| Separate responsibilities | Classes that read, compute, persist and format | Integration tests at the new boundary |
The university text cited above lists these same moves, together with static analysis, documentation review and diagrams, as the means of making a system understandable. Refactoring is also, in itself, a way of learning: restructuring a function forces the engineer to articulate what it was for, and that understanding is often the most durable output.
Working at the change point
A frequent mistake is to attempt a general clean-up of an entire repository. The more reliable pattern is opportunistic and bounded: when a feature or defect fix requires a change in a given area, cover that area with tests, improve its structure to the extent necessary, make the change, and leave the area measurably better than it was found. Over several quarters this produces a progressively more testable core, concentrated precisely where the business keeps requiring change. Large, dedicated refactoring projects remain appropriate for specific, well-justified goals, such as isolating a module before a framework upgrade, but they should still be delivered as a series of small merges rather than a long-lived branch.
Refactor or rewrite: a decision rule
The question of whether to rewrite is asked in almost every legacy engagement, and it is usually posed under frustration rather than analysis. A rewrite discards the accumulated, undocumented knowledge that the existing system embodies, and it delays the delivery of value until the replacement reaches parity. The cost is therefore front-loaded and the benefit uncertain.
That said, there are circumstances in which replacement is the sound choice: the platform is unsupported and cannot receive security updates; the architecture prevents a requirement that the business cannot forgo, such as multi-tenancy or horizontal scaling; or the codebase is so small in value, relative to the cost of understanding it, that rebuilding is cheaper. The assessment described earlier should make these conditions visible, because its risk register records likelihood and impact rather than impressions.
Even when replacement is warranted, it need not be a single event. The strangler fig pattern, coined by Martin Fowler, wraps the old system and redirects functionality to new code section by section, which is less risky and more incremental than replacing the whole application at once. In practice, a facade or API gateway routes one slice of functionality, for instance invoicing or authentication, to a new service, while everything else continues to reach the old system until it can be removed. The characterization tests written for refactoring serve equally well here: they define the behaviour that the new slice must reproduce.
| Situation | Preferred approach |
|---|---|
| Sound architecture, poor structure, no tests | Refactor, starting with characterization tests |
| Sound domain model, obsolete platform or framework | Incremental migration, often with the strangler fig pattern |
| Architecture blocks a mandatory requirement | Incremental replacement of the affected capability |
| Unsupported runtime that cannot be patched | Prioritised replacement, with the riskiest components first |
| Small, low-value component with no behaviour worth preserving | Rewrite directly |
What AI assistance changes, and what it does not
AI coding assistants can accelerate several parts of this work: summarising an unfamiliar module, proposing candidate test inputs, drafting mechanical transformations. They do not remove the need for the safeguards above, and in some respects they raise it, since a plausible but subtly wrong transformation is more difficult to notice than an obviously broken one.
The disciplined use of AI follows the same structure as any refactoring. The scope, risks, tests, validation and rollback strategy are defined first, and a plan template of the kind mentioned earlier can be drafted with AI assistance provided that these elements are settled before work starts. Generated changes then pass through the same gates as human ones: characterization tests, code review by an engineer who understands the domain, and small commits that can be reverted. An assistant that proposes changes across forty files in one step should be directed to work in increments the reviewers can actually verify.
Our perspective
We approach legacy systems from the position that the codebase is an asset with a history, not an embarrassment to be cleared. Most of the engagements in which a rewrite was requested at the outset were resolved, after assessment, by a narrower and cheaper plan: stabilise the build, cover the volatile modules with tests, and replace only the components that the analysis showed to be genuinely obstructive.

Our delivery model reflects this. The engineers who carry out the assessment are the engineers who speak to the client, without an account-management layer between them, so that findings about risk reach decision-makers undiluted. Every consulting engagement ends with written recommendations, and we are not tied to a particular vendor’s product, which allows us to recommend retention, refactoring, migration or replacement according to the evidence. We also limit the number of simultaneous projects, because careful work on a legacy system requires sustained attention to detail that cannot be divided indefinitely.
The stack we work in, which includes Java, Jakarta EE, Spring Boot, Quarkus, Angular, React and Next.js, is itself frequently the subject of modernisation: older enterprise applications moved step by step toward containerised, observable, cloud-native deployments, with the behaviour of the original protected throughout.
— Pepe F.
How we can help
For organisations that want an independent view before committing to a programme, our CTO advisory engagements begin with an initial assessment from €3,500, a one-off fee that results in a written evaluation of the codebase, its risks and the options available. Ongoing technical leadership is offered as CTO Advisory from €1,800 per month, Fractional CTO from €4,000 per month and CTO Partner from €7,500 per month. Code audit, architecture and technology-stack review, and custom development are described on our services page.
FAQ
How do you refactor legacy code without breaking it?
Begin by covering the area you intend to change with characterization tests that record current behaviour, then apply small, reversible transformations and rerun the tests after each one. Keep behaviour-preserving commits separate from feature commits, so that any regression can be traced to a single change.
Should we rewrite or refactor?
Refactor when the architecture is broadly sound and the difficulty lies in structure and missing tests. Consider replacement when the platform is unsupported or the architecture prevents a mandatory requirement, and even then prefer an incremental approach such as the strangler fig pattern to a single cut-over.
Is AI good at refactoring code?
It is useful for summarising modules, suggesting test cases and performing mechanical edits, but it does not guarantee that behaviour is preserved. The same safeguards apply as for human changes: tests written beforehand, review by an engineer who knows the domain, and small commits that can be rolled back.
How long does refactoring legacy code take?
There is no reliable general figure, since duration depends on the size of the system, the state of its tests and the goals set. A short assessment that documents scope, risks and a clean-environment build will give a defensible estimate, and incremental delivery means value arrives before the whole effort is finished.
Sources
- Software maintenance and legacy refactoring, CS169 textbook
- Working Effectively with Legacy Code, Michael Feathers
- Software Reengineering P2: Setting Direction, University of Zurich
- Chapter 9: Refactoring, Software Engineering: A Modern Approach
- How to Refactor Complex Codebases, freeCodeCamp
- Refactoring Plan Template, contextFirst
- Refactoring guide, GitLab Docs
- Chapter 4: Legacy code, Test-Driven Development MOOC
- Michael Feathers: Looking Back at Working Effectively with Legacy Code, InfoQ
- Refactoring and technical debt, Software Engineering Guide
- Strangler fig pattern, Wikipedia
This article was produced with AI assistance and reviewed for accuracy. It is provided for general information only and is not professional advice.