When a ministry calls and asks us to replace a system that has been running for twenty-three years, we know we are about to inherit other people's decisions, other people's politics, and other people's institutional memory. Legacy government software is often described with derision in industry circles. We see it differently. That code has served citizens through three administrations, two currency reforms, and a pandemic. It deserves respect, even as we replace it.
The big-bang fallacy
Every modernization program we have rescued started the same way. A previous vendor promised to rewrite the entire system from scratch and cut over on a single weekend. The promise was, in every case, undeliverable. The replacement system either failed to ship, or shipped without critical features that turned out to be load-bearing for the agency's day-to-day operations. The political cost of these failures lingers for years and makes the next attempt harder.
The strangler fig pattern, named after the tropical tree that slowly envelops its host, is not new. It is, however, criminally underused in public sector contexts. The idea is simple. You wrap the legacy system in an API facade, route a small slice of traffic to a new implementation, and grow the new system one capability at a time. The legacy system continues to serve citizens for years while you replace it from the inside out.
Where to cut first
Not every capability is a good first target. We look for three properties. The capability should be self-contained enough that you can rewrite it without rewriting everything it depends on. It should be visible enough that a successful migration generates political capital for the rest of the program. And it should be safe enough that a failed migration does not cause irreversible harm. The intersection of those three is rarely the most exciting capability, and that is the point.
// Strangler facade — route by capability, fall back to legacy.
async function route(req: Request): Promise<Response> {
const capability = classify(req);
if (modernized.has(capability) && flags.enabled(capability, req.tenant)) {
try {
return await modern.handle(req);
} catch (err) {
metrics.fallback(capability, err);
return await legacy.handle(req); // Safety net.
}
}
return await legacy.handle(req);
}“The legacy system is not the enemy. The enemy is the assumption that we know what it does. We almost never do, until we read it.”
The human side
- Bring the people who maintain the legacy system into the modernization team. They are the only ones who know which behaviors are bugs and which are features.
- Document the legacy system as you go. The act of writing it down often surfaces undocumented assumptions that the rewrite would have inherited silently.
- Measure citizen-facing metrics, not engineering metrics. The minister does not care about deployment frequency. They care about wait times at the office.
- Plan for the political cycle. Multi-year programs survive administration changes only if they deliver visible wins every six months.
Our longest-running engagement of this kind is in its fourth year. The legacy system still serves about thirty percent of traffic. The modern system serves the rest, and the modernization has covered eight elections, two ministers, and one constitutional referendum without missing a service day. That is not a story of technical brilliance. It is a story of patience, respect for what came before, and ruthless prioritization of what comes next.
