Stop rewriting legacy code. Wrap it.
Every few months a client sends me a Slack message that begins with the words “we think it might be time to rewrite.” I know exactly what has happened before I open the thread. A new senior engineer has joined. They have spent two weeks reading a codebase that was written by people who are no longer there, under constraints that are no longer documented, for a product that has since pivoted twice. They have concluded, reasonably, that the code is bad. They have concluded, unreasonably, that the solution is to throw it away and start again.
The instinct is understandable. I have had it myself. The urge to rewrite a legacy system is almost always an emotional reaction to the experience of debugging something you did not write. It feels like a technical judgement but it is really a feeling — the specific feeling of being trapped inside someone else’s mistakes. The rewrite is the fantasy of escape.
Why rewrites almost always fail
The honest version of a rewrite plan looks like this. You will take a system that currently works — badly, grudgingly, but works — and you will spend between nine and eighteen months replacing it with something that does not yet work at all, during which the business will continue to need the old system to keep running, which means you will be maintaining both, which means your team will produce roughly half as much as normal, and at the end of that period you will have a new system that has the exact same edge cases as the old one except you will have forgotten three of them and rediscovered them the hard way in production. This is not a slight against your team. It is what happens every time.
Legacy code is not bad code. It is code that has survived contact with reality, which is the highest compliment a codebase can receive.
The wrap-it strategy
There is a less glamorous alternative and it is almost always the correct one. You wrap the legacy system. You do not touch it. You draw a clean interface around it and you force all new code to talk to the old code through that interface only. Over time, you replace what is behind the interface — one endpoint, one module, one table at a time — while the interface itself stays stable.
// every new caller talks to this,
// not to the pile of PHP from 2016
interface BillingGateway {
getInvoice(id: string): Promise<Invoice>;
createCharge(input: ChargeInput): Promise<ChargeResult>;
}
class LegacyBillingGateway implements BillingGateway {
// thin adapter that calls the old system
}
That interface is the most important file in the codebase and the one you should argue about the most before writing. Everything downstream of the wrap is new code, nice code, the code your senior hire wanted to write on day one. Everything upstream of it is legacy that you can now replace in increments so small that nobody has to ask permission. The wrap gives you the psychological reward of “this part is clean now” without the eighteen-month march of death.
When to actually rewrite
There are two situations where a rewrite is the right call and I want to name them so I cannot be accused of dogma. The first is when the legacy system is built on a runtime that is genuinely end-of-life — not unfashionable, actually end-of-life, no security patches, no hires available. The second is when the business has pivoted so hard that the original problem the code solves is no longer a problem the company has. Both of these are rare. Most rewrites are not either of these. Most rewrites are a senior engineer wanting to feel less trapped.
If that is the real reason — and it often is, and it is a legitimate reason to feel — the fix is not to rewrite the system. The fix is to give that engineer the wrap, and let them build the nice part, and let the ugly part keep doing its job until there is nothing left of it.