Imagine the ticket says: "Move shipping quotes out of the monolith." You point an AI agent at a 15-year-old codebase. Four minutes later it hands you a tidy summary: one endpoint, one calculator class, two database tables, no writes. It reads like a senior engineer wrote it.

It is also missing something. A nightly batch job calls the same calculator directly, without going through the endpoint. Route the endpoint to a new service, and that job keeps running the old rules. Nobody notices until the numbers stop matching.

That's the honest picture of AI in legacy modernization today. Agents are very good at the heavy reading that used to take weeks. They are also confident in a way that has nothing to do with being right. The Strangler Fig pattern gives you a way to use the first property without being exposed to the second, as long as you build the migration around one rule:

AI agents can accelerate legacy modernization. But verification, not model confidence, must be the gate to production.

This post covers:

  1. Why big-bang rewrites fail and what the Strangler Fig pattern changes.
  2. A pipeline of specialized agents, with the input, output and permissions of each one.
  3. A worked (hypothetical) example: extracting one capability, building the facade, shifting traffic and retiring the old path, with objective criteria to accept or reject the migration.
  4. What should never be fully autonomous, and how to measure whether the agents are helping.

The rewrite trap

Every legacy system eventually triggers the same conversation: "It would be faster to rewrite it." It almost never is. Martin Fowler's Strangler Fig Application lists the reasons: a replacement takes a long time, the business can't freeze new features while it waits, and the existing behavior is hard to pin down, partly because much of it is no longer wanted.

That third reason is the one I'd underline. Having worked on modernization in banking and in product companies, my view is that the expensive part is rarely writing the new code. It's knowing what the old code actually does, including the things nobody wrote down.

The Strangler Fig pattern answers this by never doing the swap in one go. A facade sits in front of the legacy system and routes each request either to the old code or to the new service. You move one capability at a time, and the legacy system keeps serving everything else.

 ┌──────────────────────┐ clients ──▶ FACADE ──▶│ legacy monolith │ (everything not migrated yet) │ └──────────────────────┘ └──────▶ new service (one capability at a time) 

The Azure Architecture Center describes the end state: when no functionality and no dependency is left in the legacy system, you decommission it and eventually remove the facade. Until then you pay for transitional architecture, code that exists only so old and new can coexist. Fowler's argument is that the lower risk and earlier value are worth that cost.

Big-bang rewrite Strangler Fig
When value arrives At the end, if ever After the first capability
Blast radius of a mistake The whole system One capability, behind a router
Rollback Usually "go back to the old system" under pressure Change one route
Knowledge of old behavior Needed up front, for everything Needed per capability, when you migrate it

Notice the last row. The pattern doesn't remove the need to understand the legacy system. It spreads that work across the migration. That's exactly the work agents are now good at speeding up.

Where AI agents enter the picture

The strategy stays the same. Incremental migration, coexistence and controlled risk are the foundations of the pattern, and agents don't change them. What agents change is the cost of the steps around each migration: discovery, documentation, test generation, and the implementation of a well-specified slice.

There is public evidence for both the speed-up and its limits:

  • Thoughtworks built CodeConcise, which parses legacy code into abstract syntax trees, stores them in a knowledge graph and lets an LLM explain and query it. In a proof of concept on a COBOL/IDMS system, they estimated reverse engineering a 10,000-line module could drop from 6 weeks to 2. The same article insists that "the human is in full control" of the outputs and that subject-matter experts validate them.
  • Google published an experience report on LLM-driven code migrations. For one large migration, 80% of the code changes in the landed changelists were fully AI-authored, and engineers estimated the total time dropped by about 50%. They also wrote that code review and rollouts "still require a human operator", and in another migration the review speed was the bottleneck.

Both reports point at the same split. Agents are strong at producing artifacts. Humans and deterministic checks are still what make those artifacts trustworthy. So the design question is not "how autonomous can the agents be?". It's "what does each agent produce, and how do we check it before anything depends on it?".

Designing an agentic modernization pipeline

Instead of one agent that "migrates the module", I'd use a chain of narrow agents. Each one has a single job, a limited set of permissions and a verifiable artifact as output. If an artifact fails its check, the chain stops there, before the mistake spreads into code, tests and routing.

Agent Input Output artifact Can write to Verified by
1. Discovery Legacy repo, DB schema, sanitized traffic sample capability-map.yaml, every claim with evidence Nothing (read-only) Static call graph cross-check + engineer samples claims
2. Characterization Capability map, traffic sample Golden cases + test suite tests/characterization/ Suite passes 100% against legacy; mutation testing shows it fails when rules break
3. Contract Capability map, golden cases OpenAPI spec + consumer contract tests A pull request Capability owner approves the contract
4. Implementation Approved contract, golden cases (read-only) New service New service repo, not the tests CI: characterization + contract + security scans
5. Rollout Gate evidence, routing config Routing change + rollback runbook A config pull request Humans approve; the pipeline applies it

Two design choices do most of the safety work here.

The agent that writes the code can't touch the checks that judge it. The characterization suite is produced earlier, validated against the legacy system, and mounted read-only for the implementation agent. I argued the same in Dark Factory vs. Light Factory: without that boundary, a green suite proves very little.

Every claim carries evidence. A discovery summary in prose is impossible to check at scale. A claim with a file and line number, or a traffic sample, can be checked by a script or a human in seconds.

Migrating one capability at a time: a worked example

The example below is hypothetical, but the shape is realistic: a Java monolith with a ShippingCalculator and an endpoint that returns a shipping quote. It's a good first slice for a reason worth copying: it computes a value and writes nothing to the database, so old and new can run side by side without competing for data. That alone doesn't make it safe to run twice, as Step 1 shows.

Step 1: discovery with evidence

The discovery agent gets read access to the repository, the schema and a sanitized sample of requests. Its output is not an essay. It's a map:

# capability-map.yaml (produced by the discovery agent) capability: shipping-quote entry_points: - POST /api/shipping/quote -> ShippingController.quote # ShippingController.java:48 - batch: RecalculateOpenQuotesJob.run # RecalculateOpenQuotesJob.java:31 reads: [SHIPPING_RATES, CUSTOMER.TIER] writes: [] # no direct INSERT/UPDATE found in the call graph external_calls: - CarrierRatesClient.fetchSurcharge # HTTP, 3s timeout, side effects unknown business_rules: - id: R1 rule: "Orders of 50.00 EUR or more ship free, compared after tax" evidence: ShippingCalculator.java:212 status: unconfirmed open_questions: - "Line 240 adds 1.50 EUR for postcodes starting with 9 in December. Business rule or leftover?" - "Is fetchSurcharge billed or rate-limited per call? Does the carrier record quotes?" 

This is where the opening scene gets resolved. The second entry point, the batch job, is the dependency a prose summary tends to drop. A deterministic call graph tool can confirm that both callers exist, and an engineer can sample a handful of rules against the code. Each rule stays unconfirmed until someone with evidence says otherwise.

The open_questions list matters as much as the rules. A good discovery agent is useful precisely because it surfaces the strange things, like a December surcharge for one postcode range, instead of quietly deciding what they mean.

The second question is about safety, not business rules. No database writes is not the same as no side effects. If the carrier bills per call, rate-limits you or records every quote, running old and new in parallel doubles that effect. Shadowing is safe only once every external call in the path has been checked, and that check needs someone who knows the carrier contract, not just the code.

Step 2: characterization tests against the legacy system

Next, the characterization agent turns the map into golden cases: inputs that exercise each rule, plus the output the agent predicts from its reading of the code. Cases taken from the traffic sample add real inputs, with the outputs the legacy system actually returned.

{ "id": "free-shipping-threshold-is-inclusive-after-tax", "request": { "weightKg": 2.4, "destination": "PT", "orderTotalCents": 5000, "customerTier": "standard" }, "expected": { "status": 200, "body": { "amountCents": 0, "currency": "EUR", "rules": ["R1"] } }, "evidence": ["ShippingCalculator.java:212", "traffic-sample-2026-10-07"] } 

The same suite runs against both systems, switching only the target URL:

import { describe, it, expect } from "vitest"; import cases from "./golden/shipping-quote.cases.json"; const TARGET_URL = process.env.TARGET_URL ?? "http://localhost:8080"; describe("shipping quote characterization", () => { it.each(cases)("$id", async ({ request, expected }) => { const response = await fetch(`${TARGET_URL}/api/shipping/quote`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(request), }); expect(response.status).toBe(expected.status); expect(await response.json()).toEqual(expected.body); }); }); 

The rule for accepting this suite is strict: it must pass 100% against the unchanged legacy system. A failing predicted case means the agent misread the old behavior, and that's a bug in its understanding, not in the monolith. Fix the case and the capability map together, so the misreading doesn't survive in the documentation. Running a mutation testing tool (PIT for Java, Stryker for JavaScript or .NET) on the legacy calculator then tells you whether the suite actually catches broken rules or just passes.

This is also where a human makes the first real decision. The December surcharge is either a business rule or a forgotten hack. My recommendation is to migrate first and change behavior later: keep the new service bug-compatible during the migration, and fix the rule in a separate, visible change afterwards. If you mix the two, every difference between old and new becomes ambiguous.

Step 3: the facade and the routing modes

With the contract approved and the new service passing the suite in CI, the facade gets four modes per route. This is an illustrative sketch in TypeScript; in production the same logic often lives in an API gateway or a service mesh.

type RouteMode = "legacy" | "shadow" | "canary" | "new"; interface RouteConfig { mode: RouteMode; canaryPercent: number } interface ShadowJob { correlationId: string; body: string; legacyStatus: number; legacyBody: string } interface ShadowQueue { enqueue(job: ShadowJob, signal: AbortSignal): Promise<void> } const LEGACY_URL = process.env.LEGACY_URL ?? "http://legacy:8080"; export const NEW_URL = process.env.SHIPPING_SERVICE_URL ?? "http://shipping:8080"; // Above the carrier's 3s timeout plus processing, below the 5s the checkout allows for a quote const UPSTREAM_TIMEOUT_MS = 4_000; // The most latency the shadow path may ever add to a customer request const ENQUEUE_TIMEOUT_MS = 50; export async function routeShippingQuote(body: string, config: RouteConfig, shadowQueue: ShadowQueue): Promise<Response> { const correlationId = crypto.randomUUID(); if (config.mode === "new") return forward(NEW_URL, body, correlationId); if (config.mode === "canary" && Math.random() * 100 < config.canaryPercent) return forward(NEW_URL, body, correlationId); const legacyResponse = await forward(LEGACY_URL, body, correlationId); if (config.mode !== "shadow") return legacyResponse; const legacyBody = await legacyResponse.text(); const job = { correlationId, body, legacyStatus: legacyResponse.status, legacyBody }; // A slow or failing queue is logged and skipped: the shadow path must never affect the customer await enqueueWithin(shadowQueue, job, ENQUEUE_TIMEOUT_MS).catch(recordShadowError); return new Response(legacyBody, { status: legacyResponse.status, headers: { "content-type": "application/json" } }); } // The signal lets the queue client cancel the send; the race keeps the bound even if it ignores the signal async function enqueueWithin(queue: ShadowQueue, job: ShadowJob, timeoutMs: number): Promise<void> { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(new Error("shadow enqueue timed out")), timeoutMs); const aborted = new Promise<never>((_, reject) => controller.signal.addEventListener("abort", () => reject(controller.signal.reason))); try { await Promise.race([queue.enqueue(job, controller.signal), aborted]); } finally { clearTimeout(timer); } } export function forward(baseUrl: string, body: string, correlationId: string): Promise<Response> { return fetch(`${baseUrl}/api/shipping/quote`, { method: "POST", headers: { "content-type": "application/json", "x-correlation-id": correlationId }, body, signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS), }); } // recordShadowError omitted: it sends the error to your metrics pipeline. 

A worker consumes the queue outside the request path, calls the new service and records the comparison under the same correlation id:

export async function handleShadowJob(job: ShadowJob): Promise<void> { const candidate = await forward(NEW_URL, job.body, job.correlationId); const expected = { status: job.legacyStatus, body: JSON.parse(job.legacyBody) }; const actual = { status: candidate.status, body: await candidate.json() }; recordComparison(job.correlationId, classifyDifference(expected, actual)); } // classifyDifference applies the approved tolerances (see the gate below); // recordComparison stores the result with the correlation id for later inspection. 

What matters in it:

  • In shadow mode the customer always gets the legacy answer. The new service runs on the same input and only its differences are recorded. This is the same idea as GitHub's Scientist library, which always returns the old code path's result and publishes mismatches.
  • The comparison runs from a queue, not a fire-and-forget promise. A detached promise after the response is not guaranteed to finish, especially on serverless runtimes that freeze the instance once the response is sent. A queue (or a background-task API your runtime documents, such as waitUntil on Cloudflare Workers) makes the comparison survive, and lets you retry it.
  • The enqueue itself is still on the request path, so it gets a hard time limit: in the worst case shadowing adds 50 milliseconds, and a slow queue costs you a comparison, never a customer request. The abort signal asks the queue client to cancel the send too, so a timed-out enqueue doesn't keep working in the background; use a client that accepts a signal or has its own native timeout. If even that is too much, hand the job to something the infrastructure runs off the request path, such as that background-task API or a local agent that ships the records to the queue.
  • The correlation id travels in a header to both systems and into the comparison record. Without it, a mismatch in your metrics can't be traced back to the two log lines that explain it.
  • The timeout comes from the dependencies, not from a round number. The legacy path waits up to 3 seconds for the carrier, so a 2-second facade timeout would fail requests the legacy system was about to answer correctly. Size it from the slowest dependency and the latency budget of the caller.
  • Shadowing is safe here only after the external-call check from Step 1. Scientist's own README warns that it's only safe for code that doesn't change data. For capabilities with side effects, you need a sandbox for the new path, or you compare the intended effect (the command it would send) instead of executing it twice.
  • Canary sends a percentage of real traffic to the new service. Per-request random routing is fine for a stateless calculation; anything with sessions needs sticky routing.
  • The batch job from Step 1 never passes through this facade. It needs its own switch, or it keeps using the old calculator after the migration is "done".

Step 4: shift traffic in stages

The rollout agent prepares a plan, not a deployment. A typical sequence: shadow for a week of real traffic, then canary at 1%, 10%, 50% and 100%. Every stage has the same exit criteria, and the rollback is a single config change back to legacy that anyone on call can apply without an agent.

Step 5: done means no caller is left

100% of the endpoint's traffic on the new service is not the end of the migration. The batch job from Step 1 still calls the old calculator, and it won't show up in any facade metric. The capability is migrated when nothing depends on the old implementation anymore: no consumer, no job, no integration.

The completion check, before anyone deletes the legacy code path:

  • Every entry point in the capability map is switched: the endpoint through the facade, the batch job through its own change.
  • The legacy route receives zero requests over a full business cycle, long enough to include month-end and other periodic jobs.
  • Runtime evidence agrees with the map: logs or tracing show no calls into the old ShippingCalculator from anywhere.
  • Integrations outside the codebase are accounted for: reports, partner exports, database jobs that read the same tables.

The discovery agent can produce that list of callers again, against the current code and logs. A human signs off on deleting the old code path, because this is the one step with no cheap rollback.

Verification is the gate, not AI confidence

The gate between stages is a function of evidence. Here is the one I'd start with; the thresholds are illustrative starting points, so tune them to your traffic and risk.

interface ShadowMismatches { unclassified: number; // nobody has looked at them yet critical: number; // a real behavior difference outsideTolerance: number; // non-critical, but not covered by an approved tolerance } interface MigrationEvidence { characterizationPassRate: number; // new service, 0..1 contractTestsPassed: boolean; shadowRequests: number; mismatches: ShadowMismatches; p95LatencyRatio: number; // new / legacy rollbackRehearsed: boolean; approvals: string[]; } const MIN_SHADOW_REQUESTS = 50_000; const MAX_P95_LATENCY_RATIO = 1.1; const REQUIRED_APPROVALS = ["capability-owner", "on-call-engineer"]; export function evaluateStageGate(evidence: MigrationEvidence): { promote: boolean; reasons: string[] } { if (!hasValidEvidence(evidence)) return { promote: false, reasons: ["missing or invalid evidence"] }; const reasons: string[] = []; const { unclassified, critical, outsideTolerance } = evidence.mismatches; if (evidence.characterizationPassRate < 1) reasons.push("characterization suite is not fully green"); if (!evidence.contractTestsPassed) reasons.push("contract tests failed"); if (evidence.shadowRequests < MIN_SHADOW_REQUESTS) reasons.push("not enough shadow traffic yet"); if (unclassified > 0) reasons.push(`${unclassified} shadow mismatches not classified`); if (critical > 0) reasons.push(`${critical} critical shadow mismatches`); if (outsideTolerance > 0) reasons.push(`${outsideTolerance} differences outside approved tolerances`); if (evidence.p95LatencyRatio > MAX_P95_LATENCY_RATIO) reasons.push("p95 latency regressed"); if (!evidence.rollbackRehearsed) Source:https://dev.to/soaresdiogo/strangler-fig-ai-agents-can-we-automate-legacy-modernization-safely-1jfh

Related posts