跳到正文
DEV Community· Diogo Soares·· 3 小時前AI 評分64

Strangler Fig 模式如何配合 AI 智能體安全地推進舊系統現代化?

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

AI 導讀

文章提出以 Strangler Fig 模式配合 AI 智能體,逐項遷移舊系統功能,並以可驗證證據而非模型信心作為進入生產環境的門檻。流程以有證據的功能探索和舊系統特徵測試起步,測試須先在未改動的舊系統上達到 100% 通過,再以 shadow、canary 分階段切換流量。

正文

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

來源:DEV Community · dev.to