ai newsMi., 28. Okt.
EilmeldungAnthropic's record IPO faces some big questions

Strangler Fig + AI Agents: Can We Automate Legacy Modernization Safely?

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 some

dev.toMittwoch 10:17 · 28.10.15 Min. Lesezeit
Bild: dev.to

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.

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) Enter fullscreen mode Exit fullscreen mode 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.

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.

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:

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?".

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.

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.

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.

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?" Enter fullscreen mode Exit fullscreen mode 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.

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" ] } Enter fullscreen mode Exit fullscreen mode The same suite runs against both systems, switching only the target URL:

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.

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. Enter fullscreen mode Exit fullscreen mode 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. Enter fullscreen mode Exit fullscreen mode What matters in it:

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.

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:

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.

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; Enter fullscreen mode Exit fullscreen mode 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 acceptance criteria, in plain terms:

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.

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.

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.

"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.

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.

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.

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.

Quelle: dev.to (dev.to). Original öffnen