Skip to content
Back to the archive

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.

  1. 1CharacteriseFreeze today's behaviour without understanding the rule. A golden master does it in bulk, in one afternoon.
  2. 2SeamOpen a point where behaviour can be swapped without editing there. Start with the clock.
  3. 3StrangleInterceptor in front, one slice at a time, with a visible number and a shutdown date.
In that order: the safety net first, the swap point next, the replacement last.

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

Talk to me

Questions about the article? Message me on WhatsApp

No form and no mailing list. If you disagree with something I wrote, or want to tell me how you solved it, the conversation goes straight to me.

Open the chat