For me, the strangler fig pattern is the most honest answer to the question “How do we get off our legacy system without stopping everything?”. After all, almost every team has a candidate like that: an application that has been running for years, that nobody likes to touch, and that still earns money every single day. However, the reflex is often a big rewrite. In my experience, that rewrite surprisingly often ends with two systems that both need maintenance.
So in this fifth part of the series, I show you the other route: you put a facade in front of the legacy system and move one feature after another to Lambda. Every step is small, and every step can be rolled back. The example is a made-up bike rental called “Radhafen”, built with the Serverless Framework and TypeScript.
In short
With the strangler fig pattern, every request passes through a facade, here an HTTP API in API Gateway. One route after another moves to Lambda, and everything else keeps flowing to the legacy system through the $default route. The biggest stumbling block is rarely the routes. It is the data that old and new both need.
What you will learn in this article
- How the strangler fig pattern works and when it pays off
- Why an HTTP API with a $default route makes a good facade for a legacy system
- Comparing a feature with Lambda safely using a parallel run
- Switching over and rolling back with a single parameter
- Which mistakes around data, host names and costs are typical
What is the strangler fig pattern?
The name comes from Martin Fowler. He saw strangler figs in the rain forests of Queensland: plants that sprout high up in a tree, send their roots down along the trunk and wrap around their host over the years. Eventually, the fig stands on its own and the old tree is gone. Applied to software, this means you build the new system around the old one and gradually take over its responsibilities until you can switch it off.
Fowler's reasoning is rather sober. A full replacement often fails because nobody can fully describe the existing behavior, because the business won't wait two years for new features, and because part of the old behavior isn't needed anymore anyway. He sums it up in one sentence: “the reduced risk and earlier value from the gradual approach outweigh its costs.” So the gradual route costs extra, but it pays off through lower risk and earlier benefits.
The three building blocks
Technically, you need three things. First, a facade: a proxy that every request passes through and that decides whether the old or the new system answers. Second, a seam: a spot in the legacy system where a feature can be cut out cleanly. Third, transitional architecture, meaning code you only need during the migration and delete afterwards. That includes, for example, a comparison mode or an adapter that keeps data in sync between old and new.
This transitional code feels like waste at first. I see it more like scaffolding: nobody would renovate a house without scaffolding just because it gets taken down at the end.
Four phases: facade, shadow comparison, live switch, retiring the legacy system.
When does the strangler fig pattern pay off?
Not every migration needs this pattern. For a small application with three endpoints, a rewrite over a weekend is often faster than any facade. The AWS guide to cloud design patterns agrees: the bigger and more tangled the legacy system, the more the gradual approach is worth.
In practice, the pattern fits well if at least two of these points apply: the legacy system is large and poorly documented. Also, the business keeps needing new features while you migrate. Outages or a “freeze” lasting several months are not an option. The external interface is HTTP, so you can put a proxy in front of it. It gets hard, on the other hand, when the legacy system mainly talks to other systems through a shared database. Then there is no door to put the facade in front of.
What about Refactor Spaces?
With Migration Hub Refactor Spaces, AWS had a service that built exactly this kind of facade for you. According to the AWS guide, Refactor Spaces has not been open to new customers since November 7, 2025. As an alternative, AWS points to AWS Transform there. The good news: the facade itself is so little infrastructure that you can build it yourself with a few lines in the Serverless Framework. That's exactly what we'll do now.
The example: a bike rental with a legacy system
Radhafen rents out city bikes, e-bikes and cargo bikes. Its application runs as a single Node application on one server. It can list stations, create reservations, write invoices and calculate prices. It is reachable at https://legacy.radhafen.example.
As the first seam, I pick price calculation, so POST /quotes. In fact, it is a textbook candidate: it doesn't read data from other modules, writes nothing, gets called often and is easy to test. Reservations, in contrast, depend on stations, payments and invoices. They come much later, once the facade has proven itself.
My rule of thumb for the order: first features without side effects and with their own data, then new features built directly in the new system, and core processes with shared data at the very end. That way you get to know the facade before a mistake gets expensive.
How does the HTTP API become a facade?
The facade is an HTTP API in API Gateway. For our purpose, two rules from the AWS documentation matter. First, the $default route catches all requests that don't match any other route and passes the full path on to the integration. Second, the most specific route always wins: an exact match like POST /quotes beats a route with {proxy+}, and that one beats $default.
In other words, that's the whole pattern. The $default route points to the legacy system through an HTTP proxy integration. In addition, every feature you move gets its own route to a Lambda function. Anything without its own route automatically ends up at the legacy system. If you delete a route again, traffic falls back just as automatically. That's your built-in emergency exit.
🎬 Strangler fig pattern: routing through the facade – this animated diagram only runs on my site: watch it on sascha-ms.de
In case you're wondering why I use an HTTP API here and not a REST API: for plain forwarding, HTTP APIs are cheaper and simpler. I covered the differences in detail in REST API vs HTTP API: Choosing API Gateway.
Step by step: implementing the strangler fig pattern with Lambda
Prerequisites
- Node.js 22 and an AWS account with permissions for Lambda, API Gateway, CloudFormation and CloudWatch Logs
- Serverless Framework v4 (
npm i -g serverless); if you are still on v3, Migrate to Serverless Framework v4 will help - A legacy system that is publicly reachable over HTTPS (for private systems, see the section on security)
- Basic TypeScript knowledge
1. Create the project
The project is deliberately small. It needs no runtime dependencies because Node.js 22 ships with fetch. The tests run on Node's built-in test runner.
mkdir -p radhafen-strangler/src radhafen-strangler/test cd radhafen-strangler # create package.json and tsconfig.json as shown below, then: npm install { "name": "strangler-fig-lambda", "version": "1.0.0", "private": true, "type": "module", "scripts": { "typecheck": "tsc --noEmit", "test": "node --experimental-strip-types --test test/*.test.ts", "deploy": "serverless deploy" }, "devDependencies": { "@types/aws-lambda": "^8.10.164", "@types/node": "^22.20.5", "typescript": "^7.0.2" } } { "compilerOptions": { "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "noEmit": true, "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "erasableSyntaxOnly": true, "skipLibCheck": true, "types": ["node"] }, "include": ["src", "test"] } 2. The facade in the Serverless Framework
For every function with an httpApi event, the Serverless Framework automatically creates an HTTP API. Its logical CloudFormation ID is HttpApi. The framework can't create an integration to an arbitrary URL directly; according to the docs, httpApi only offers the proxy integration to Lambda. That's why we add the integration and the $default route as plain CloudFormation resources. We pass the target URL and the mode as parameters, so you can switch later without changing code.
# serverless.yml service: radhafen-strangler frameworkVersion: '4' params: default: legacyBaseUrl: https://legacy.radhafen.example migrationMode: shadow provider: name: aws runtime: nodejs22.x architecture: arm64 region: eu-central-1 memorySize: 256 timeout: 10 logRetentionInDays: 14 environment: LEGACY_BASE_URL: ${param:legacyBaseUrl} MIGRATION_MODE: ${param:migrationMode} functions: quotes: handler: src/quote-handler.handler events: - httpApi: method: POST path: /quotes resources: Resources: # Everything without its own route goes to the legacy system unchanged. LegacyIntegration: Type: AWS::ApiGatewayV2::Integration Properties: ApiId: !Ref HttpApi IntegrationType: HTTP_PROXY IntegrationMethod: ANY IntegrationUri: ${param:legacyBaseUrl} PayloadFormatVersion: '1.0' TimeoutInMillis: 29000 LegacyDefaultRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref HttpApi RouteKey: $default Target: !Join ['/', ['integrations', !Ref LegacyIntegration]] Two details matter. First, HTTP proxy integrations use payload format 1.0, which is why it is set explicitly. Second, the integration timeout is 29 seconds, because an HTTP API doesn't allow more than 30 seconds anyway. If your legacy system has endpoints that take longer, you'll find out now at the latest. For those cases, take a look at the job pattern with 202 and SQS from part 1.
3. The new pricing logic
The new logic is a pure function without network access. That makes it easy to test and keeps it cleanly separated from the transitional code. I calculate amounts in cents to avoid floating-point rounding errors.
// src/tariff.ts // New pricing logic for the “Radhafen” bike rental – a pure function without I/O. export type BikeType = 'city' | 'ebike' | 'cargo'; export interface QuoteRequest { bikeType: BikeType; hours: number; member: boolean; } export interface Quote { bikeType: BikeType; hours: number; totalCents: number; currency: 'EUR'; } // Prices in cents to avoid floating-point rounding errors. const HOURLY_CENTS: Record<BikeType, number> = { city: 300, ebike: 600, cargo: 800 }; const DAY_CAP_CENTS: Record<BikeType, number> = { city: 1800, ebike: 3500, cargo: 4500 }; const MEMBER_DISCOUNT = 0.1; export function parseQuoteRequest(body: unknown): QuoteRequest | string { if (typeof body !== 'object' || body === null) return 'Body must be a JSON object'; const b = body as Record<string, unknown>; if (b.bikeType !== 'city' && b.bikeType !== 'ebike' && b.bikeType !== 'cargo') { return 'bikeType must be city, ebike or cargo'; } if (typeof b.hours !== 'number' || !Number.isInteger(b.hours) || b.hours < 1 || b.hours > 72) { return 'hours must be an integer from 1 to 72'; } return { bikeType: b.bikeType, hours: b.hours, member: b.member === true }; } export function computeQuote(req: QuoteRequest): Quote { const fullDays = Math.floor(req.hours / 24); const restHours = req.hours % 24; const restCents = Math.min(restHours * HOURLY_CENTS[req.bikeType], DAY_CAP_CENTS[req.bikeType]); let total = fullDays * DAY_CAP_CENTS[req.bikeType] + restCents; if (req.member) total = Math.round(total * (1 - MEMBER_DISCOUNT)); return { bikeType: req.bikeType, hours: req.hours, totalCents: total, currency: 'EUR' }; } 4. Parallel run: comparing old and new
Now comes the part I find most valuable in migrations. Before the new logic serves real answers, it runs along in shadow mode. A parallel run means: the Lambda function still asks the legacy system, also calculates the result itself and compares both. The customer gets the legacy answer, and you get a log entry with every difference.
In this way, you find the rules nobody ever wrote down. Maybe the legacy system rounds differently, maybe the daily cap only applies from the eighth hour. A parallel run shows you this with real traffic, without anyone seeing a wrong price. There is one limitation: it only works for requests without side effects. You must not create a reservation twice just to compare.
// src/compare.ts // Compares the legacy response with the new calculation (parallel run). export interface Difference { field: string; legacy: unknown; next: unknown; } export function diffResults(legacy: Record<string, unknown>, next: Record<string, unknown>): Difference[] { const fields = new Set([...Object.keys(legacy), ...Object.keys(next)]); const diffs: Difference[] = []; for (const field of fields) { if (JSON.stringify(legacy[field]) !== JSON.stringify(next[field])) { diffs.push({ field, legacy: legacy[field], next: next[field] }); } } return diffs; } // src/legacy-client.ts // Small client for the legacy system. The timeout is deliberately below the Lambda timeout. export interface LegacyResponse { status: number; body: unknown; } export async function callLegacy( baseUrl: string, path: string, payload: unknown, timeoutMs = 3000, ): Promise<LegacyResponse> { const res = await fetch(new URL(path, baseUrl), { method: 'POST', headers: { 'content-type': 'application/json', 'x-strangler-shadow': '1' }, body: JSON.stringify(payload), signal: AbortSignal.timeout(timeoutMs), }); const text = await res.text(); let body: unknown = text; try { body = JSON.parse(text); } catch { // Not JSON – pass the text through unchanged. } return { status: res.status, body }; } The client also sets the x-strangler-shadow header. As a result, the legacy system can recognize these calls in its own metrics. The three-second timeout is deliberately well below the Lambda timeout, so the function can respond cleanly instead of being cut off hard. If the legacy system hangs regularly, the circuit breaker from part 4 is the right addition.
5. The handler with shadow and live mode
// src/quote-handler.ts // POST /quotes – the first feature extracted from the legacy system. import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from 'aws-lambda'; import { computeQuote, parseQuoteRequest } from './tariff.ts'; import { diffResults } from './compare.ts'; import { callLegacy } from './legacy-client.ts'; type Mode = 'shadow' | 'live'; const json = (statusCode: number, body: unknown): APIGatewayProxyStructuredResultV2 => ({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); export async function handler(event: APIGatewayProxyEventV2): Promise<APIGatewayProxyStructuredResultV2> { const mode: Mode = process.env.MIGRATION_MODE === 'live' ? 'live' : 'shadow'; const legacyBaseUrl = process.env.LEGACY_BASE_URL ?? ''; let raw: unknown; try { raw = JSON.parse(event.body ?? ''); } catch { return json(400, { message: 'Invalid JSON' }); } const parsed = parseQuoteRequest(raw); if (typeof parsed === 'string') return json(400, { message: parsed }); const next = computeQuote(parsed); if (mode === 'live') { console.log(JSON.stringify({ msg: 'quote-served', source: 'lambda', mode })); return json(200, next); } // Shadow mode: the legacy system stays the source of truth, the new logic only runs for comparison. try { const legacy = await callLegacy(legacyBaseUrl, '/quotes', raw); if (legacy.status !== 200 || typeof legacy.body !== 'object' || legacy.body === null) { console.log(JSON.stringify({ msg: 'parallel-run', match: false, reason: 'legacy-status', status: legacy.status })); return json(legacy.status, legacy.body); } const diffs = diffResults(legacy.body as Record<string, unknown>, { ...next }); console.log(JSON.stringify({ msg: 'parallel-run', match: diffs.length === 0, diffs })); return json(200, legacy.body); } catch (err) { console.log(JSON.stringify({ msg: 'parallel-run', match: false, reason: 'legacy-error', error: StringSource:https://dev.to/sascha-ms/strangler-fig-pattern-migrate-to-lambda-step-by-step-dl5