Legacy: characterise, seam and strangle
Three techniques that let you safely change a system you did not write, do not understand, and cannot stop.
Michael Feathers' definition is five words: legacy code is code without tests.
Not old code. Code from 2011 with a good suite is easier to change than what you wrote yesterday with none.
That definition is good because it is actionable. "This code is bad" is a judgement, starts an argument and points at nothing. "This module has no safety net" is a verifiable fact and tells you what to do next.
And because it takes blame out of the conversation. Whoever wrote it was probably under an impossible deadline with half the information. The problem is not the person: it is the absence of fast feedback when somebody changes a line.
The vicious cycle
Without tests you cannot refactor safely. Without refactoring you cannot test, because the code has no way in.
Three techniques break that cycle, and they are used in that order.
Technique 1: characterisation tests
The trick is beautiful in its simplicity.
1. Write a test expecting any value. Guess. assert result == 0
2. It fails. And the failure message tells you today's real behaviour: "expected 0, got 1847.32".
3. Freeze that value in the test.
Done. You pinned the behaviour without needing to understand the rule.
Notice what happened: the test does not verify that the code is correct. It records what the code does today, so you notice if that changes tomorrow. Which is exactly what you need before refactoring.
And if the current behaviour is wrong? Then the test documents the bug, and fixing it becomes an explicit decision, with the test updated on purpose, instead of an accidental change nobody noticed.
One caution: make it clear in the name that the test describes the legacy, not the desired.
characterises_currentInterestCalculation rather than shouldCalculateInterestCorrectly.
The golden master
The industrial version of the same idea.
Take a thousand real inputs, anonymised. Run them through the current system. Save all the outputs to a file. Commit it.
In one afternoon, a module with no tests gains a dense safety net, without anybody needing to read the business rule.
After refactoring, every difference that appears is a question: "did I change that on purpose, or did I break it?"
That question asked during the refactoring, and not six months later in a support ticket, is what separates modernising a legacy system with method from poking at it and hoping.
The two cautions that avoid frustration:
Normalise before freezing. Today's date, a random id, unstable collection ordering and memory addresses will make the test fail for the wrong reason. Replace them with fixed values.
Prefer meaningful slices to a thousand line dump. A giant file nobody can read when it breaks is almost as bad as having no test.
In my experience it is the highest leverage technique there is in legacy code: very little code written, an enormous increase in safety.
Technique 2: seams
A seam is a point where you can change behaviour without editing in that place.
Legacy code usually has none. It creates the database connection, reads the clock and instantiates the HTTP client inside the method itself. To test it, you would have to stand up half the system.
The kinds of seam:
By parameter. The clock, the HTTP client and the random generator come in as arguments instead of being created in there.
By subclass. Extract the hard part into a protected method and override it in the test. It is ugly and works as an intermediate step.
By linkage. Swapping the implementation in configuration, at build time, or in the injector.
How to open the first seam safely: use only provably safe refactorings (extract method, rename, move parameter) and preferably through the editor's automatic refactoring, which makes fewer mistakes than your hands and does not miss a call site.
Only after opening the seam do you write the real test.
And the first seam I would open in any system: the clock.
Find every direct call to "now" and replace it with an injected dependency, defaulting to the real clock. Nothing breaks, and suddenly you can test due dates, expiries, interest and month boundaries without waiting for the calendar, and a good chunk of your flaky tests disappear.
It usually fits in a small PR, and it is the refactoring with the best effort to payoff ratio in a legacy base.
The signs that there is no seam, so you recognise them in review: a new HTTP client inside the
method; a static call to the clock, the environment or a singleton; an eight hundred line method that
only takes an id; deep inheritance where the parent constructor already opens a connection.
Technique 3: Strangler Fig
For replacing a whole system without stopping anything. The name comes from the strangler fig, which grows around its host tree until it replaces it.
1. Put an interceptor in place. All traffic passes through a point you control: a proxy, a gateway, a routing layer.
2. Recreate one slice. Pick the lowest risk one and reimplement only that.
3. Divert the traffic. That route now goes to the new system. The others stay on the old one.
4. Repeat. When the last route migrates, the old system starves.
Why it beats a rewrite from scratch:
It delivers value from the first slice, not in eighteen months. Each step is reversible: just point the route back. The risk is spread out. And it survives a change in priorities: if the project stops halfway, what already migrated stays standing.
A complete rewrite, on the other hand, chases a moving target: while you rewrite, the old system keeps gaining features. Joel Spolsky called it "the single worst strategic mistake" a software company can make, and that is not an exaggeration.
What goes wrong:
The shared database. Both systems writing to the same table. This is where most efforts stall. The usual paths: the new one reads from the old database for a while; the old one publishes events via CDC that the new one consumes; or you split by customer slice, migrating data alongside traffic.
Never reaching the end. The three hardest routes are left over, and you maintain two systems forever, at double the maintenance cost.
Against that: publish a visible number ("78% of traffic is already on the new system") on a dashboard, with an agreed shutdown date. Without it, the migration dies at the 60% slice, which is where the boring routes nobody wants to take live.
How to choose the first slice: do not start with the core, nor with the most irrelevant part. Pick something with few data dependencies, enough real traffic to prove the path, and easy rollback. Catalogue reads and reports are usually good candidates.
The goal of the first slice is not to deliver much value: it is to build the interceptor, the diversion, the observability and the rollback process. From the second one on, all of that already exists.
- 1CharacteriseFreeze today's behaviour without understanding the rule. A golden master does it in bulk, in one afternoon.
- 2SeamOpen a point where behaviour can be swapped without editing there. Start with the clock.
- 3StrangleInterceptor in front, one slice at a time, with a visible number and a shutdown date.
How to sell this to the business
The question that always comes.
Do not ask for "a refactoring sprint". That request loses to any feature, and rightly so.
Bake the cost into the estimate of the task that was already prioritised, and talk about delivery time, not elegance:
"Changing this today takes three days because there are no tests. With two days of preparation, the next five tasks in this area drop to half a day each."
Refactoring sold as an investment with a calculable return gets approved. Sold as personal taste, it never does.
Where to start tomorrow
Cross two lists you already have: which files change most often (git log) and which ones concentrate
the most bugs.
Whatever shows up on both is where the lack of tests hurts now. Start there, with a golden master.
It is the opposite of the natural instinct, which is to start with the ugliest module, and the ugliest module may have sat untouched for three years without bothering anybody.
Read this next
- Applied AIStep 22AI in production: RAG, evals and prompt injectionAnybody can build a demo that impresses. What separates the demo from the product is three disciplines, and most teams have none of them.Read article
- EngineeringStep 21Data and analytics: from OLTP to lakehouseTwo questions come up in every company: why did the report take down production, and why is the number on my dashboard different from yours. Both have the same root cause.Read article
- EngineeringStep 19Tests worth what they costTests do not exist to prove the code is right. They exist so you can change it tomorrow without fear. That change of goal reorganises everything.Read article