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:
- Why big-bang rewrites fail and what the Strangler Fig pattern changes.
- A pipeline of specialized agents, with the input, output and permissions of each one.
- 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.
- 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
waitUntilon 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
ShippingCalculatorfrom 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) reasons.push("rollback not rehearsed");
const missing = REQUIRED_APPROVALS.filter((role) => !evidence.approvals.includes(role));
if (missing.length > 0) reasons.push(`missing approvals: ${missing.join(", ")}`);
return { promote: reasons.length === 0, reasons };
}
// Evidence arrives from metrics pipelines at runtime, so the types alone don't protect the gate
function hasValidEvidence(evidence: MigrationEvidence): boolean {
const mismatches = evidence.mismatches;
const counts = [evidence.shadowRequests, mismatches?.unclassified, mismatches?.critical, mismatches?.outsideTolerance];
return counts.every(isCount)
&& isRatio(evidence.characterizationPassRate)
&& isPositiveNumber(evidence.p95LatencyRatio)
&& typeof evidence.contractTestsPassed === "boolean"
&& typeof evidence.rollbackRehearsed === "boolean"
&& Array.isArray(evidence.approvals);
}
const isCount = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0;
const isRatio = (value: unknown) => typeof value === "number" && value >= 0 && value <= 1;
const isPositiveNumber = (value: unknown) => Number.isFinite(value) && (value as number) > 0;
Look at what is missing from MigrationEvidence: anything the agent says about itself. No confidence score, no "I verified the edge cases", no summary of its own work. Every field comes from a test run, a traffic comparison, a latency metric or a named human.
The first line of the gate matters as much as the rest. Missing evidence blocks promotion; it never counts as a pass. A broken metrics query returns NaN or nothing at all, and NaN > 1.1 is false in JavaScript, so a naive latency check would quietly let the stage through. The gate fails closed instead.
The acceptance criteria, in plain terms:
| Gate | Accept when | Reject when |
|---|---|---|
| Characterization | 100% green on legacy and on the new service | Any case fails on either side |
| Contract | Consumer contract tests pass; contract approved by its owner | Any consumer-visible change without approval |
| Shadow traffic | Enough real requests; zero unclassified or critical mismatches | Any critical mismatch, or a difference outside the approved tolerances |
| Performance | p95 latency within the agreed ratio | Regression, or no baseline to compare with |
| Rollback | Rehearsed, one config change, no agent needed | Rollback needs a data fix or a redeploy |
| Human sign-off | Capability owner and on-call engineer approved | Approval "by default" or by the agent |
| Completion | No consumer, job or integration still calls the old implementation | Any caller left, or no runtime evidence either way |
The mismatch policy is deliberately strict: zero critical mismatches, and no percentage of "acceptable" unexplained ones. What makes this workable is that tolerances are decided before the shadow run, not after. The capability owner approves a short list up front, for example "ignore the generatedAt timestamp" or "the new service rounds half-up, approved as a fix". classifyDifference applies that list automatically. Anything it can't match stays unclassified until a human looks at it, and an agent may propose a classification, but a person confirms it, because that's where business meaning lives.
What should never be fully autonomous?
In Dark Factory terms, most of this pipeline is a light factory: agents do the work, and humans sit at the points where a wrong decision is expensive or irreversible. Here is where I'd draw the line.
| Action | The agent may | A human must |
|---|---|---|
| Production data changes (backfills, schema, deletes) | Write the scripts and dry-run them against a copy | Approve, run, and own the backup and restore plan |
| Critical or public contracts | Propose the diff and generate contract tests | Approve every consumer-visible change |
| Architectural decisions (service boundaries, data ownership, sync strategy) | Draft options and trade-offs as an ADR | Decide |
| Deployments and traffic shifts | Prepare the config change and compute the gate evidence | Promote any stage beyond shadow |
| Bug-compatibility | Flag suspicious behavior with evidence | Decide whether to keep it, and when to fix it |
| Decommissioning legacy code | List the remaining callers | Turn it off |
The common thread is reversibility. Running a characterization suite twice costs nothing. Deleting a legacy table, changing a contract that partners depend on, or picking the wrong service boundary can cost months. Agents can prepare irreversible changes; they shouldn't execute them.
Measuring success
"The agents saved us time" is not a metric. If you want to know whether this pipeline helps, measure the agents and the migration separately.
| What | How to measure it | Why it matters |
|---|---|---|
| Discovery precision | Sample claims from each capability map; share confirmed by an engineer | Tells you how much to trust the next map |
| Discovery misses | Mismatches or incidents traced to behavior the map didn't list | The batch-job problem, counted |
| Effort | Lead time per capability (map to 100% traffic) and human review hours | Review is part of the cost; Google counted it in its estimate |
| Migration quality | Unexplained mismatch rate, rollbacks, incidents per capability | Speed without this is just faster risk |
| Operational cost | Tokens, plus running two systems and the facade | Transitional architecture isn't free |
| Progress | Share of traffic served by new services; legacy code deleted | The goal is decommissioning, not coexistence |
The last row is the one teams forget. A Strangler Fig migration that never turns off the legacy system is a permanent double running cost.
Trade-offs: when this approach is the wrong choice
- Small systems. If replacing the whole thing is simple, the facade and the pipeline are overhead. Azure's guidance says the same about the pattern itself.
- Requests you can't intercept. No facade, no strangler. The same goes for logic that only runs inside the database or in jobs you can't redirect.
- Capabilities with heavy side effects. Shadowing payments or emails needs a sandbox or intent comparison. That work is real; budget for it before choosing such a capability as an early slice.
- No observability. Without request samples, latency baselines and mismatch metrics, the gate has nothing to evaluate.
- A team that can't review the output. Agents can generate maps, tests and pull requests faster than people can check them. Throttle generation to review capacity, as Google did, rather than lowering the bar.
FAQ
Can't the agent just translate the legacy code into a new language?
It can produce a translation, but a translation reproduces structure, including the parts you wanted to leave behind. The Thoughtworks article notes that translated code still needs lengthy testing. Characterizing behavior first and implementing against it gives you the tests you need anyway.
Do I still need static analysis if I have agents?
Yes. Call graphs, dependency analyzers and dead-code tools are deterministic and cheap. Use them to check what the agent claims. Thoughtworks saw little GenAI value for dead-code detection, because existing tools already do it well.
Is this a dark factory for migrations?
Partly. Discovery and test generation can run with little supervision because their outputs are checked before anything depends on them. Contracts, data and traffic decisions stay lit.
Which capability should I migrate first?
One that is read-only, well bounded and valuable enough to matter. A calculation or a query is a better first slice than anything that writes money or sends messages.
Takeaway
- The Strangler Fig pattern is the strategy; agents speed up the steps. Incremental migration and coexistence don't change.
- Make every agent produce a verifiable artifact, with evidence for each claim, and stop the chain when a check fails.
- Characterize the legacy system before you replace it. The suite must pass 100% against the old code first.
- Keep the doer away from the checks. The implementation agent reads the tests; it can't edit them.
- Gate on evidence, never on model confidence: tests, shadow mismatches, latency, rollback and named human approvals.
- A capability is migrated when no caller is left, not when the endpoint hits 100% on the new service.
- Agents prepare irreversible changes; humans execute them.
Which part of your legacy system would you trust an agent to map first, and which part would you never let it touch? Tell me in the comments.
I'm Diogo Soares, a Staff AI Engineer working on AI agents and legacy modernization. If this was useful, follow me here on DEV.to and on Instagram at @diogosoares.dev, where this migration pipeline becomes a swipeable carousel you can save for your next modernization planning. This article also gets a video walkthrough on my YouTube channel, Diogo Soares Dev, so subscribe there for the next ones. Let's also connect on LinkedIn, or check out diogosoares.dev.
Top comments (0)