Modernising a Legacy System Without a Full Rewrite

In This Article

  1. Why the big-bang rewrite keeps failing
  2. Find the seams before you write any new code
  3. The strangler fig, concretely
  4. Contract tests: the rail that keeps two systems honest
  5. Data is the part that actually hurts
  6. Cutting over: parallel run, not a switch
  7. When a rewrite really is the right call

Key Takeaways

Why the big-bang rewrite keeps failing

The situation is familiar enough to be a genre. There is a system everyone agrees has to be replaced. A replacement project was funded. It is now somewhere between eighteen months and four years old, it is not in production, and the old system is still taking change requests — because the business did not agree to stop having new requirements while the rewrite caught up. Two budgets are running. Neither team can ship.

This is not a management failure. It is a structural property of the approach. Martin Fowler, describing the pattern he named after the strangler fig tree, is blunt about why straight replacement fails: "Replacements seem easy to specify, but often it's hard to figure out the details", much of the legacy behavior is stuff nobody actually wants so rebuilding it is waste, and users cannot simply wait for features while the rewrite runs. He notes he has watched simple-replacement projects "go down in flames most of the time."

The Thoughtworks team behind the Patterns of Legacy Displacement series — Ian Cartwright, Rob Horn, and James Lewis, first published July 2021 — put the same mechanism in terms of drift: "The longer each programme ran for, the more acute this gap between the programme plan vs. BAU and future needs." Their second observation is the one most modernization business cases ignore: "technology is at most only 50% of the legacy problem, ways of working, organization structure and leadership are just as important to success." A rewrite that changes only the code lands the new system into the same organization that produced the old one.

In government the arithmetic is public. The Government Accountability Office reported in GAO-19-471 (June 2019) that the federal government planned to spend more than $90 billion on IT in fiscal year 2019, roughly 80 percent of it on operating and maintaining what already exists. GAO examined 65 aging systems, identified the 10 most critical, and found them ranging from about 8 to 51 years old. Of the ten responsible agencies, only two — Defense and Interior — had modernization plans that met GAO's best-practice criteria; three had no documented plan at all. GAO's stated concern was that agencies without complete plans face "increased risk of cost overruns, schedule delays, and project failure."

So the question is not whether to modernize. It is how to modernize in a way that produces value before the last day.

Find the seams before you write any new code

Incremental replacement requires somewhere to cut. Michael Feathers, in Working Effectively with Legacy Code, gave that place a name: a seam is a place where you can alter behavior in your program without editing in that place. A seam might be an interface you can substitute, a configuration value that selects an implementation, a batch job's input and output files, a message queue, a database view, or an HTTP boundary. If there is no seam, the first engineering job is to create one — and that is a refactoring task, not a rewrite task.

Feathers' second contribution matters even more here. Before you change legacy code, you write characterization tests: tests written to document what the code actually does right now, and preserve it while you change the implementation. They are not correctness tests. They do not assert that the system is right. They assert that it still behaves the way it behaved yesterday, including the parts that are wrong, because somewhere downstream a process depends on the wrongness.

That distinction is where most teams lose a quarter. Someone reads the legacy rounding logic, decides it is a bug, and fixes it in the replacement. Six months later a reconciliation report shows a two-cent discrepancy across two million records, and nobody can say whether the new system is broken or the old one was. If you had pinned the old behavior first, you would know — and you would be having a policy conversation instead of a forensics one.

Practical ordering advice: do not start with the module that is easiest to read. Start where change traffic is highest. Rank candidate modules by how often they actually change, multiplied by how much damage they do when they break. The code nobody has touched in nine years is stable by revealed preference; leave it running.

The strangler fig, concretely

Fowler's strangler fig describes a gradual process of modernization in which small additions are built on top of, yet separate from, the legacy code base, and behavior is progressively moved across. The mechanism that makes it work is an interception point: something sitting in front of the legacy system that can decide, per request or per record, whether the old path or the new path handles it.

1 · Today Callers Legacy system every behavior Every change is a whole-system change. 2 · Insert a seam Callers Facade / router Legacy New slice One route moves. The rest is untouched. 3 · Strangle, then delete Callers Facade / router Remnant New system Legacy shrinks until it is small enough to delete.
The interception point is what makes incremental replacement possible. Without it, every increment is a coordinated release of both systems. Diagram by Precision AI Academy.

The Thoughtworks catalogue names the ways to create that point — Event Interception, Divert the Flow, Legacy Mimic, and Transitional Architecture among them. Two of those deserve emphasis. Legacy Mimic means the new component behaves toward the old system exactly as the old system's own components did, so the legacy side does not know anything changed. Transitional Architecture is scaffolding you build deliberately and intend to throw away — and the discipline is in the second half of that sentence.

When the change is inside the code rather than at a network boundary, the equivalent technique is Branch by Abstraction (Fowler, 2014; the term comes from Paul Hammant, who credits Stacy Curl with the idea). You introduce an abstraction over the thing you want to replace, migrate all callers onto the abstraction, build the new implementation behind the same abstraction, switch callers over progressively, then delete the old one. The system stays releasable the entire time — which is the property that distinguishes this from a long-lived rewrite branch.

What goes wrong: teams build the facade and then never delete anything. The transitional architecture becomes permanent, and the organization now operates three systems instead of one. The fix is unglamorous. Put the deletion on the plan as a dated deliverable with an owner, and treat "legacy lines removed" as a tracked number alongside "new features shipped." An increment that adds a new path without retiring an old one has not modernized anything; it has added surface area.

Contract tests: the rail that keeps two systems honest

The moment a seam exists, the two sides can be deployed independently — which is the benefit and also the new risk. During a strangler migration you have a legacy provider and a new provider that must be interchangeable from the caller's point of view, and "interchangeable" needs an executable definition.

That definition is a contract test. The idea traces to Ian Robinson's 2006 article on consumer-driven contracts, where the point is that "providers are subject to an obligation that originates from outside their boundaries" — the provider is no longer the sole authority on what its interface means. In practice this is usually implemented with a tool like Pact, where, per its documentation, "the contract is generated during the execution of the automated consumer tests" and the provider is then verified against it by checking "that all the calls to your test doubles return the same results as a call to the real application would."

Two consequences are worth internalizing. First, only the paths consumers actually use get pinned, so "any provider behaviour not used by current consumers is free to change without breaking tests" — which is exactly the freedom a replacement implementation needs. Second, a contract test is not an integration test. It verifies the shape and semantics of an interaction; it does not verify that a benefits determination is correct or that a payment reconciles. You still need characterization tests for behavior and reconciliation for data. Teams that substitute contract tests for both find out during parallel run.

Data is the part that actually hurts

Code translation gets the attention. Data is what stalls the program. The schema underneath a forty-year-old system encodes decisions nobody documented: fixed-width records, packed decimal fields, dates stored as six-digit integers, sentinel values where a high number means "never," flags that were repurposed twice, and referential integrity that lives in a batch job rather than in the database.

The pattern for changing an interface or a schema without a coordinated cutover is Parallel Change, also called expand / contract, described by Danilo Sato in 2014. Expand: add the new form alongside the old and keep both populated. Migrate: move readers and writers across one at a time — the longest phase, especially when consumers are outside your control. Contract: remove the old form once nothing reads it. Each phase is independently releasable, which is the whole point.

Alongside that, build a reconciliation job and treat it as a first-class deliverable rather than a test artifact. Run both implementations over the same inputs, compare outputs field by field, and classify every divergence into one of three buckets: new system is wrong, old system was wrong, or the two are equivalent under a rule you now need to write down. The count of unexplained divergences is the most honest progress metric a modernization program has. It is also the number that tells you when a slice is genuinely done, which is not the same day the code merges.

One warning specific to arithmetic. Legacy financial code typically uses fixed-point decimal; a naive port to binary floating point produces small, non-reproducible drift. Across millions of records that is not a rounding preference, it is a failed reconciliation and, in a regulated program, a finding.

Cutting over: parallel run, not a switch

The last mile is where the risk concentrates, and there is a well-documented way through it. When GitHub rewrote its permissions system — by their own description "one of the most critical systems in our application" — they built and open-sourced Scientist to run the old and new code paths side by side in production. The control path's result is what callers receive; the candidate runs in parallel and its result is discarded. Per their write-up, "the results of both the control and candidate are compared and, if there are any differences in that comparison, those are recorded," with execution order randomized to avoid ordering effects. The value is finding the real-world data quality problems that tests alone do not surface.

A workable sequence, in order: dark launch the new path so it executes but affects nothing; parallel run with reconciliation until the divergence rate is understood rather than merely low; canary a small fraction of live traffic; make the new path authoritative; hold a defined quiet period; then decommission. The Thoughtworks catalogue names the same instruments — canary release, dark launching, and the stop-the-world cutover you are trying to avoid.

The step teams skip is rehearsing the rollback. A rollback plan that has never been executed is a document, not a capability. Rehearse it against production-shaped data, on the actual runbook, with the actual on-call people, before the increment that needs it.

A note on AI-assisted translation, since it is now in every modernization business case: a coding model can accelerate the mechanical parts of a port, but it does not change any of the above. The characterization suite, the contract tests, the reconciliation job, and human review of every merged change are what make model-generated code safe to run — and they are the expensive part. Our field guide to AI coding agents covers where these tools currently hold up and where they do not.

When a rewrite really is the right call

Sometimes it is. If the runtime itself is unsupportable — no vendor, no compiler, no one who can build the artifact — incremental change has nowhere to stand. If the transaction volume and data footprint are genuinely small, a single cutover can be lower risk than a year of dual operation. And if the business domain has changed so much that feature parity with the old system is the wrong target, then strangling the old behavior forward is preserving the wrong thing; notably, the Thoughtworks series treats Feature Parity as a pattern to interrogate, not an automatic requirement.

Those are narrow conditions, and the burden of proof belongs on the rewrite rather than on the increment. The honest test is a question about the organization, not the code: can this program sustain the discipline of shipping small, verified changes for as long as it takes? If the answer is no, a rewrite will not fix it — because, as the Thoughtworks authors put it, technology is at most half of the legacy problem.

If you want help with this

The situation. You have a system everyone agrees must be replaced, a replacement that has been “about six months out” for a while, and no safe way to change the old system in the meantime.

What Precision Federal builds for it. Precision Federal is a federal software and AI firm whose legacy modernization practice is built around the sequence above. The work products are concrete: a program and data inventory (programs, copybooks, data stores, interfaces, batch schedules) assembled from static analysis plus operator interviews; a characterization test suite derived from real production batch logs; a risk-weighted roadmap that ranks modules by change frequency, criticality, and feasibility; facade APIs with OpenAPI specs; change-data-capture and replication pipelines to make parallel run possible; reconciliation dashboards; cutover plans with rehearsed rollback playbooks; and a decommissioning checklist, because deletion is the deliverable. Stacks covered include z/OS COBOL, CICS, IMS, DB2, JCL, IBM i RPG and CL, and legacy Windows estates in PowerBuilder, VB6, and Delphi, with target stacks chosen per program. Where LLM-assisted code translation is used, the firm's stated planning assumption is a 30–60% authoring speedup with characterization tests and mandatory human review gating every merge — not a hands-off translation.

What an engagement looks like. A scoped discovery and characterization assessment first — inventory, dependency graph, risk-weighted roadmap, and a working characterization suite — so that the modernization decision is made with evidence rather than estimates. Build work follows in bounded increments. Engagement models include SBIR Phase I feasibility work, fixed-price single-module increments, time-and-materials for long-horizon programs, Technology Modernization Fund business-case support, and specialist strangler engineering as a subcontractor under a prime-led effort.

Where this is not the right fit. The firm's default is incremental replacement; it does not take big-bang rewrite programs except in the narrow case where the legacy runtime is genuinely unsupportable. It will not commit to a legacy retirement date at kickoff, before the inventory exists — if that date is a contractual requirement, this is the wrong partner. It will not ship model-translated code that is not gated by characterization tests and human review. If the goal is lifting the existing system onto cloud VMs with no code change, that is a hosting project and other firms do it more cheaply. And if the program cannot make operators available for interviews or provide production logs and data samples, there is no basis for a characterization suite — which means there is no safe way to refactor, and the engagement should not start.

Legacy modernization at Precision Federal → Describe your system →

Sources: Martin Fowler — Strangler Fig Application; Ian Cartwright, Rob Horn & James Lewis — Patterns of Legacy Displacement; Martin Fowler — Branch By Abstraction; Danilo Sato — Parallel Change; Ian Robinson — Consumer-Driven Contracts; Pact documentation; GitHub Engineering — Scientist; GAO-19-471 — Agencies Need to Develop Modernization Plans for Critical Legacy Systems; Michael C. Feathers, Working Effectively with Legacy Code (Prentice Hall, 2004), for the definitions of seam and characterization test. Analysis and framing by Precision AI Academy.

Common questions

What is the strangler fig pattern? It is an incremental replacement strategy named by Martin Fowler after the strangler fig tree. Small additions are built on top of, yet separate from, the legacy code base, and behavior is gradually moved from the legacy system into the new one until the legacy system can be retired. Fowler later retitled his original post from "Strangler Application" to "Strangler Fig Application."

Do I need tests before I can safely change legacy code? Yes, but not the kind you write for new code. Michael Feathers describes characterization tests: tests written to document what the code actually does right now, rather than what it is supposed to do. They pin current behavior so you can detect any change you did not intend, including in the parts that are arguably wrong.

What is a contract test and how is it different from an integration test? A contract test checks that a provider still satisfies the expectations a specific consumer actually relies on. In consumer-driven contract testing, as implemented by Pact, the contract is generated during the consumer's own test run and the provider is verified against it. An integration test exercises a business scenario across deployed systems; a contract test verifies the interaction without deploying everything.

Is a full rewrite ever the right call? Occasionally. A rewrite can be justified when the runtime itself is unsupportable, when the data and transaction volume are small enough that a single cutover is genuinely low risk, or when the domain has changed so much that feature parity with the old system is the wrong goal. Those are narrow conditions, and the burden of proof belongs on the rewrite.

About Precision AI Academy

Precision AI Academy publishes practical AI news, plain-language analysis, and free courses for builders and working professionals. It is a sister site of Precision Federal, a federal software and AI firm. We verify the numbers, cite the primary sources, and skip the hype.