AI coding

Wrapping Old Code Instead of Rewriting It

Antonio Evans9 min read

A cross-section of a stone bridge: beneath the roadway, a new steel support structure is being bolted in place piece by piece while traffic continues to flow across the original deck above.
Key points

Key points

  1. 1Keep the old code live behind a stable interface and route a small share of calls to the new implementation, rather than swapping everything at once.
  2. 2Prove the old path still works with characterization tests that replay real inputs, written before the routing changes.
  3. 3Choose between a facade (traffic split outside the app) and branch by abstraction (an interface inside the app), based on whether the code has a clean seam.
  4. 4Make rollback a single switch that moves traffic back, and rehearse it before the first slice goes live.

Why wrapping beats rewriting

A full rewrite assumes you can specify the old system's behavior precisely. You usually cannot: the documentation is stale, the original authors are gone, and the code carries years of implicit contracts nobody wrote down. Martin Fowler's account of the strangler fig approach puts it directly: "Replacements seem easy to specify, but often it's hard to figure out the details of existing behavior," and much of that behavior is unwanted anyway, so rebuilding it is wasted work (Martin Fowler).

The alternative is gradual replacement: build new code alongside the old, move behavior across one piece at a time, and keep the system running throughout. Microsoft's architecture guidance describes the mechanics as a facade or proxy that intercepts requests to the legacy backend and routes them either to the old application or to new services, shifting more traffic to the new side as each iteration completes (Microsoft Learn). Wikipedia's entry on the pattern notes the same structure can run at method or class level, not just at service boundaries, so you do not need a network hop to start (Wikipedia).

The cost you accept is transitional architecture: adapter code, dual reads, comparison logging, all of which goes away when the migration finishes. Fowler argues the reduced risk and earlier value outweigh that cost, because each slice delivers working behavior instead of waiting for a big-bang cutover (Martin Fowler).

One more argument for wrapping: partial progress is still progress. In branch by abstraction, the main branch stays deployable at every commit, so a migration can be paused when a higher-priority feature lands and resumed later without a merge nightmare (Harness).

The legacy code dilemma, and how tests break it

Michael Feathers defines legacy code simply as code without tests. His point is not about age: it is that without automated tests you cannot verifiably change behavior, so fear of touching the code becomes rational (BSSw). He states the consequence plainly: with tests we can change behavior quickly and verifiably; without them "we really don't know if our code is getting better or worse" (BSSw).

That creates a paradox: you should have tests before you change code, but getting tests in place usually requires changing code. The way out is minimal, safe refactoring first: identify change points and seams, break dependencies, write the tests, make the change, then refactor (Understand Legacy Code). A seam is a place where you can alter behavior without editing the source, most commonly by substituting a subclass or an injected dependency in the test.

The test type that gets you started is the characterization test: a test that captures what the code actually does, not what you think it should do. If a method returns 12 for inputs 1 and 1, you assert 12 and move on. Once code is in the wild, users build processes around its quirks, so "right" and "wrong" stop mattering and "what is" becomes the specification (DaedTech).

Feathers himself says AI tools are genuinely useful for exactly this job, listing explaining code, identifying potential issues and generating tests among the productive uses (Tech Lead Journal). That is the narrow, high-value role: the agent proposes the test, you confirm the captured behavior is the one you want to keep.

Two architectures: facade and branch by abstraction

Both patterns put a stable interface in front of the old code. They differ in where the decision lives.

Facade (strangler fig at the edge). A proxy sits between clients and the backend. Clients keep calling the same address. The proxy decides per request whether to forward to the legacy system or the new service. This is the Microsoft model: the facade routes most requests to the legacy system initially, shifts them incrementally, and is removed once the legacy system is decommissioned (Microsoft Learn). AWS frames the same pattern as a way to migrate a monolith incrementally with reduced transformation risk and less business disruption, since coupled code cannot be tested in isolation and every change forces broad retesting (AWS Prescriptive Guidance).

Branch by abstraction (inside the code). You insert an interface over the component being replaced, refactor callers to use it, add the new implementation, and have the interface delegate to old or new as required. Then you delete the old implementation and, if you like, the interface (Continuous Delivery). Confluent's walkthrough of extracting notifications from a monolith shows the same three moves: adapter in front of the old code, new implementation built alongside, swap the adapter when ready (Confluent).

DimensionFacade / strangler figBranch by abstraction
Where the switch livesProxy or gateway outside the appInterface inside the codebase
Best whenComponent has a network boundaryComponent is called from many places in-process
RollbackChange routing ruleFlip a flag or config value
Shadow comparisonEasy: proxy can duplicate the requestPossible: adapter calls both and logs diffs
Main drawbackAnother hop to operate and monitorRequires refactoring every caller first
Removal stepDelete facade after decommissionDelete old class, then the interface

The routing rule is the same shape in both. A worked example: a slice that starts at 1 percent of traffic, moves to 5 percent, then 25, then 100, with automatic reversion if the error rate on the new path exceeds the old path by a set margin over a set window. The percentages and thresholds are yours to set; what matters is that each step is a config change, not a deploy.

Writing the harness that proves the old path still works

The harness has three layers, and you need all three before the first slice takes traffic.

1. Characterization tests on the legacy slice. Target only the code you are about to replace, not the whole codebase, and aim for near-total coverage of that slice, because without it you cannot tell whether behavior was preserved (BSSw). Generate these with an agent, review each assertion by hand, and commit them before any routing changes. Feathers notes some vendors now offer to write all your tests for you, which alarms a test-driven developer but works well for characterization testing specifically (Tech Lead Journal).

2. Golden-input replay. Record real request and response pairs from production for the slice, then replay them against the new implementation offline and diff the outputs. This catches the long tail that hand-written tests miss. Agent-driven modernization work commonly lands in the 60 to 80 percent automation range precisely because automated refactoring still needs this kind of verification and human issue resolution (InterSystems).

3. Contract tests at the interface. These assert the shape of the response (fields, types, error codes, ordering) independently of either implementation, so they keep passing when the old code is deleted.

A small comparison wrapper makes shadow mode practical:

def handle(request):
    old_result = legacy.handle(request)
    if not in_shadow(request):
        return old_result
    new_result = modern.handle(request)
    if not equivalent(old_result, new_result):
        log_divergence(request, old_result, new_result)
    return old_result  # old system stays authoritative during shadow

Shadow mode means calling both implementations and comparing, while still returning the old result. Confluent describes this exact use: run both, log the differences, and monitor how close you are to completion before you swap which one returns the answer (Confluent). The comparison function needs judgement: exact equality on IDs and amounts, tolerance on timestamps and float rounding, and an explicit ignore list for fields you know will differ, such as a generated request ID.

Keeping AI agents inside the slice

Coding agents help most when the task is bounded and the feedback loop is tight. The failure mode is the opposite: agents make changes based on incomplete understanding of a large system and add new debt while removing old debt. One team's 12-week modernization of a 10 million line Java ERP stalled for more than nine months because agents were productive but operating without context infrastructure, going down rabbit holes and introducing inconsistencies the humans then had to clean up (Driver).

The practices that keep agents useful on legacy code, from a practitioner walkthrough of the workflow, are worth treating as rules (Google Cloud Tech):

  • Document the local build, linter and test commands in a README so the agent can self-correct against real feedback instead of guessing.
  • Map the blast radius first: ask what files, APIs and tables break before a line is touched.
  • Lead with the failing test, written by a stronger model, then let a cheaper, faster model write the implementation until it goes green.
  • Break large tasks down and keep a running markdown checklist to prevent context drift.

A practitioner thread on scaling LLMs to large codebases adds two specific hazards: agents assume your language is C or Python wearing a costume, and they ignore surrounding code that could be reused, producing copy-paste sprawl (Hacker News). Both are mitigated by naming the seam explicitly in the prompt and asking the agent to cite which existing functions it reused.

If your team is running several agents across slices and wants a shared, tested capability instead of re-prompting from scratch, Quanta Skills (https://quantaintelligence.ai/skills) covers that workflow at the tool level.

Review gates for a partial migration

A partial migration is in production for weeks. The review process has to treat "half old, half new" as a normal state, not a temporary mess to be tolerated.

GateQuestionEvidence required
Slice definitionIs the boundary a real business capability?Named owner, named interface, list of callers
Test coverageDoes the legacy slice have characterization tests?Coverage report for the slice, not the repo
Shadow agreementDo old and new agree on real inputs?Divergence rate from replay, with an explained ignore list
Routing changeIs the first step reversible in one edit?Flag or route rule reviewed by a second engineer
Data writesCan both paths write safely?Idempotency confirmed or one path made read-only
RemovalIs anything still calling the old code?Call-count metric at zero over a full business cycle

The data point that bites most teams is the fifth. Microsoft's guidance flags shared data stores explicitly: when both systems use the same store, you must decide how to keep access compatible and how to handle updates that race between them (Microsoft Learn). The safe default for a first slice is to route reads to the new path and keep writes on the old path until the new path has proven idempotent under retry.

For the sixth gate, Wikipedia notes a quieter use of the pattern: adding logging to old code to see how often it runs, which is exactly the metric that tells you when a slice is truly dead and safe to delete (Wikipedia). Treat the call count, not the calendar, as the removal signal.

Rollback that you have actually rehearsed

Rollback is not a document. It is a switch, and its behavior under load is something you test before you need it.

Feature flags give per-feature rollback without redeployment, while rolling back a canary typically means rolling back the whole deployment, which is why the two are complements rather than substitutes (Harness). For a slice migration, the flag should have four properties:

  1. One name, one owner. A single flag per slice, named after the slice, with a named person responsible for flipping it.
  2. Fail-closed to the old path. If the flag service is unreachable, traffic goes to the legacy implementation. The old code is the safe default until the very last step.
  3. No deploy required. The flip happens through configuration, in seconds.
  4. Rehearsed. Flip to new and back in staging under production-like load, and time it. If the reverse flip takes twenty minutes, you do not have a rollback.

Set the trigger before you start, in numbers. A workable default: automatic reversion when the new path's error rate exceeds the old path's by more than a fixed absolute margin over a fifteen-minute window, or when p95 latency on the new path exceeds the old by more than a set percentage. Write the thresholds into the slice's checklist so that nobody negotiates them mid-incident.

Two operational details finish the job. First, keep the comparison logging running after you flip to 100 percent, for at least a full business cycle, because some divergences only appear on month-end or quarter-end paths. Second, before deleting the old implementation, remove the flag and the interface in the same change window, and confirm from the call-count metric that nothing else reached in.

When the migration ends, the transitional code becomes debt of its own. Schedule its removal as the final slice ticket with the same rigor as the first, because adapter code left in place quietly becomes the next generation's legacy.

FAQ

FAQ

Is wrapping always better than rewriting?

No. Wrapping wins when the existing behavior is poorly understood and the system must keep running, which describes most production legacy systems. A rewrite can be reasonable for a genuinely small, well-specified component with tests around it. The deciding question is whether you can state the old behavior precisely; if you cannot, gradual replacement with characterization tests is the lower-risk path.

What if the legacy code has no seam at all?

You create one. Introducing an interface in front of a hard-to-test component is itself a small, safe refactoring, and it is the first step of both facade and branch by abstraction. Keep that change as small as possible, because it lands before the tests that would otherwise protect it, then write characterization tests immediately afterward.

How long should a slice stay in shadow mode?

Until the divergence rate on real inputs is stable and every remaining difference is explained by your ignore list. There is no fixed duration that fits every system; a slice on a low-traffic path needs multi-week calendar coverage to see enough inputs, while a high-traffic slice may reach stability in days. Judge by evidence, not by the clock.

Who owns the decision to flip traffic to the new path?

The engineer who owns the slice, with a second reviewer on the routing change and on the rollback thresholds. Ownership should not sit with a central platform team, because the slice owner has the most context on the new implementation's failure modes. What the platform team owns is the mechanism: the flag service, the metrics and the alerting.

Can the new implementation call the old one during the transition?

Yes, and it is often the pragmatic first step: the new path handles a narrow slice and delegates the rest to the legacy code through the same interface. This keeps each cut small and lets you migrate outward incrementally. The trap is that dependencies on the old code multiply if you are not tracking them, so record every delegation and treat each as a slice still to be done.

If your next slice needs several model types behind one key for the comparison and classification work in the harness, Quanta Inference (https://quantaintelligence.ai/inference) exposes text, image, video, audio and embedding models through one OpenAI-compatible API.

Sources

Sources

  1. Strangler Fig - Martin Fowler
  2. Strangler Fig Pattern - Azure Architecture Center | Microsoft Learn
  3. Strangler fig pattern - Wikipedia
  4. How to Branch by Abstraction with Feature Flags
  5. Working Effectively with Legacy Code - Better Scientific Software (BSSw)
  6. The key points of Working Effectively with Legacy Code
  7. Characterization Tests - DaedTech
  8. Working Effectively with Legacy Code and AI Coding Assistant - YouTube
  9. Strangler fig pattern - AWS Prescriptive Guidance
  10. Make Large Scale Changes Incrementally with Branch By ...
  11. Branch by Abstraction | Designing Event-Driven Microservices
  12. Proven AI, Modernizing Legacy Systems With AI Coding Agents
  13. AI Coding Agents Should Make Migrations Easier. Without Context ...
  14. How to modernize legacy codebases using AI coding agents ... - YouTube
  15. Scaling LLMs to Larger Codebases - Hacker News
  16. Canary Releases and Feature Flags Explained
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles