The Strangler Fig Pattern: Incrementally Replace a Legacy Monolith Without The Big Rewrite
The big rewrite always fails. The Strangler Fig pattern lets you replace a legacy monolith piece by piece, routing traffic between old and new services, until nothing is left of the original. Here is the proxy layer, the migration workflow, and the data sync patterns that make it work.
Your legacy monolith is running in production right now. Deploying it takes 45 minutes. Adding a single field touches twelve files and requires two developers in a call to avoid merge conflicts. The test suite takes 22 minutes and nobody trusts it. Everyone on the team, including the person who wrote it, agrees it needs to be replaced.
The instinct is to rewrite it. A clean greenfield project. Modern stack. No baggage. Six months of focused work and you ship version 2.
That instinct has killed more projects than any bad framework decision. The big rewrite fails because it ships too late, the business requirements changed while you were building, and the legacy system accumulated years of edge cases that nobody documented. Martin Fowler described this pattern in 2004 after watching Netscape spend two years rewriting Navigator 4 into 5 and shipping a product that was slower and less stable than what they replaced.
The Strangler Fig pattern is the alternative. Named after the tropical vine that grows around a host tree, gradually envelops it, and leaves only a hollow shell of the original, this pattern lets you replace a production monolith one feature at a time. The old system stays running. The new system grows alongside it. Every migration is a small reversible step. The host tree is gone only when nothing is left to route to it.
How the Strangler Fig works
The pattern has three phases:
Phase 1 - Transform. Build a reverse proxy or routing layer that sits in front of the legacy monolith. All incoming requests go through this proxy. Initially the proxy forwards everything to the monolith. The proxy is the strangler vine’s first grip on the tree.
Phase 2 - Coexist. One by one, reimplement features as new services behind the proxy. For each migrated feature, add a routing rule: “if this request matches the migrated route, forward to the new service; everything else goes to the monolith.” Both systems run in parallel.
Phase 3 - Eliminate. When every route has migrated, delete the monolith. The proxy is now the only entry point, routing entirely to the new system.
The critical detail: at no point do you ship a big bang deployment. Every change is a small routing rule, a single feature behind a new endpoint, a reversible toggle. If the new service breaks, you flip the route back to the monolith and fix it without rolling back unrelated work.
Building the routing layer
The proxy is the heart of the pattern. It must inspect each request, decide where to send it, forward it, and return the response to the client. This can be a reverse proxy like Nginx, a service mesh sidecar, or a custom Node.js proxy. For teams that control their own infrastructure, a custom proxy gives the most flexibility for gradual migration.
Here is a minimal Express-based proxy that routes requests based on URL path:
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';
const app = express();
const LEGACY_HOST = process.env.LEGACY_HOST || 'http://monolith.internal:3000';
const NEW_API_HOST = process.env.NEW_API_HOST || 'http://api-v2.internal:4000';
// Routing table: maps migrated routes to new services
const ROUTING_TABLE = new Map([
['/api/users', NEW_API_HOST],
['/api/products', NEW_API_HOST],
['/api/orders', NEW_API_HOST],
]);
// Build a proxy for each target
const legacyProxy = createProxyMiddleware({
target: LEGACY_HOST,
changeOrigin: true,
});
const targetProxies = new Map<string, ReturnType<typeof createProxyMiddleware>>();
for (const [, target] of ROUTING_TABLE) {
if (!targetProxies.has(target)) {
targetProxies.set(target, createProxyMiddleware({
target,
changeOrigin: true,
onError: (err, req, res) => {
console.error('Proxy error for', req.path, err.message);
legacyProxy(req, res, () => {
res.status(502).json({ error: 'Service unavailable' });
});
},
}));
}
}
app.all('*', (req, res) => {
const matchedRoute = [...ROUTING_TABLE.entries()]
.find(([route]) => req.path.startsWith(route));
if (matchedRoute) {
const [, target] = matchedRoute;
targetProxies.get(target)!(req, res);
} else {
legacyProxy(req, res);
}
});
app.listen(8080);
This proxy checks every incoming request against the routing table. If the path starts with a migrated route, it forwards to the new service. Everything else goes to the monolith. The onError handler is the rollback mechanism: if the new service is down, it logs the failure and returns a 502 instead of silently dropping the request.
Route granularity matters
The routing table above matches by path prefix. That works when a whole endpoint family (/api/users/*) is migrated as one unit. But sometimes you need finer granularity: migrate the POST /api/users endpoint but leave GET /api/users on the monolith, because the read path has complex caching you are not ready to replicate.
Extend the routing table to support method-specific rules:
type RouteRule = {
methods?: string[];
target: string;
};
const ROUTING_TABLE = new Map<string, RouteRule>([
['/api/users', { methods: ['POST', 'PUT', 'DELETE'], target: NEW_API_HOST }],
['/api/users/profile', { target: NEW_API_HOST }],
['/api/products', { target: NEW_API_HOST }],
]);
app.all('*', (req, res) => {
const matchedRoute = [...ROUTING_TABLE.entries()]
.find(([route, rule]) => {
if (!req.path.startsWith(route)) return false;
if (rule.methods && !rule.methods.includes(req.method)) return false;
return true;
});
if (matchedRoute) {
const [, rule] = matchedRoute;
const proxy = targetProxies.get(rule.target);
if (proxy) {
proxy(req, res);
return;
}
}
legacyProxy(req, res);
});
Now you can migrate POST /api/users while GET /api/users still hits the monolith. This lets you move read paths and write paths independently, which is essential when one is much harder to migrate than the other.
Feature flags for request routing
Hardcoding routes in the proxy is fine for small migrations. For larger ones, add a feature-flag system that lets non-engineering teams decide when to flip:
type FlagConfig = {
route: string;
enabled: boolean;
percentage?: number; // gradual rollout: 0.0 to 1.0
};
const FLAG_CONFIG: FlagConfig[] = [
{ route: '/api/users', enabled: true },
{ route: '/api/products', enabled: false },
{ route: '/api/orders', enabled: true, percentage: 0.25 },
];
function simpleHash(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i++) {
hash = ((hash << 5) - hash) + input.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash) / 0x7fffffff;
}
function isRouteEnabled(path: string, userId?: string): boolean {
const flag = FLAG_CONFIG.find(f => path.startsWith(f.route));
if (!flag || !flag.enabled) return false;
if (flag.percentage !== undefined && userId) {
const hash = simpleHash(`${userId}:${flag.route}`);
return hash < flag.percentage;
}
return true;
}
app.all('*', (req, res) => {
const userId = req.headers['x-user-id'] as string | undefined;
const matchedRoute = [...ROUTING_TABLE.entries()]
.find(([route]) => req.path.startsWith(route) && isRouteEnabled(req.path, userId));
if (matchedRoute) {
const [, target] = matchedRoute;
targetProxies.get(target)!(req, res);
} else {
legacyProxy(req, res);
}
});
The percentage-based flag lets you run both systems side by side and compare behavior. Send 25% of real traffic to the new service, monitor error rates and latency, and ramp up only when the numbers are better. The deterministic hash ensures a given user always hits the same backend, avoiding confusing behavior where a user’s action goes to the new service and their next request goes to the old one.
Data migration: the hard part
The proxy handles request routing. Data is the problem the proxy cannot solve for you. When a request goes to the new service but the data it needs is still in the monolith’s database, you have a data gap.
Three strategies handle this, and you will likely use all three at different stages of the migration.
Strategy 1: Shared database
The new service reads and writes to the same database as the monolith. This is the fastest path to shipping but the messiest long term. The new service and the monolith share tables, which means schema changes require coordination, and the new service inherits all the legacy schema quirks you wanted to escape.
Use this for the first few migrations when speed matters more than purity. Promise yourself you will extract the schema later.
import pg from 'pg';
const pool = new pg.Pool({
connectionString: process.env.LEGACY_DB_URL,
});
app.get('/api/users/:id', async (req, res) => {
const result = await pool.query(
'SELECT id, email, name FROM users WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'not found' });
}
res.json(result.rows[0]);
});
Strategy 2: Dual-write with backfill
The new service writes to its own database and the monolith’s database simultaneously. Reads come from the new service’s database. Over time, backfill scripts copy historical data from the monolith’s database to the new one.
async function createUser(data: CreateUserInput) {
// Write to new service database
const newUser = await newDb.query(
'INSERT INTO users (email, name, created_at) VALUES ($1, $2, $3) RETURNING *',
[data.email, data.name, new Date()]
);
// Write to legacy database for backwards compatibility
try {
await legacyDb.query(
'INSERT INTO users (id, email, name, created_at) VALUES ($1, $2, $3, $4)',
[newUser.rows[0].id, data.email, data.name, newUser.rows[0].created_at]
);
} catch (err) {
console.error('Legacy write failed, queuing retry:', err);
await retryQueue.enqueue({
type: 'user-sync',
userId: newUser.rows[0].id,
});
}
return newUser.rows[0];
}
The fallback to a retry queue is critical. The legacy write can fail without the new service write failing. If you make the legacy write part of the same transaction as the new write, you couple the new system’s availability to the old system’s. Instead, let the new write succeed independently and reconcile the legacy database asynchronously.
Strategy 3: Change data capture (CDC)
A CDC pipeline streams every change from the monolith’s database into the new service’s database. Both systems stay in sync automatically. This is the cleanest long-term solution but requires infrastructure (Debezium, Postgres logical replication, or a custom WAL listener).
// Simplified CDC consumer using Postgres logical replication
import { createChangeListener } from 'pg-listen';
const changes = createChangeListener({
connectionString: process.env.LEGACY_DB_URL,
});
changes.notifications.on('user_changed', async (payload) => {
const change = JSON.parse(payload);
if (change.operation === 'INSERT' || change.operation === 'UPDATE') {
await newDb.query(
`INSERT INTO users (id, email, name, updated_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET email = $2, name = $3, updated_at = $4`,
[change.data.id, change.data.email, change.data.name, new Date()]
);
} else if (change.operation === 'DELETE') {
await newDb.query('DELETE FROM users WHERE id = $1', [change.data.id]);
}
});
CDC is the only strategy that keeps both systems in sync with zero application-level dual-write code. The tradeoff is operational complexity: you now run a CDC pipeline that can fall behind, stall, or consume too many resources on the primary database.
Migrating a feature step by step
Here is the exact workflow for migrating a single feature using the Strangler Fig pattern.
Step 1: Identify the feature boundary. Choose a feature that has a clear API contract. /api/users is good. “The user management system” is too vague. The feature should be independently deployable and testable.
Step 2: Build the replacement. Implement the new service with the same API contract. Write integration tests that run against both the old and new endpoints and compare responses. Accept minor differences only where the old behavior was a bug.
async function compareResponses(path: string, method = 'GET', body?: any) {
const oldRes = await fetch(`${LEGACY_HOST}${path}`, { method, body });
const newRes = await fetch(`${NEW_HOST}${path}`, { method, body });
expect(newRes.status).toBe(oldRes.status);
if (oldRes.status === 200) {
const oldData = await oldRes.json();
const newData = await newRes.json();
expect(newData).toMatchObject(omitTimestamps(oldData));
}
}
Step 3: Route the feature. Add the route to the proxy’s routing table. Monitor error rates, latency, and throughput for 24 to 48 hours. If metrics degrade, remove the route from the table. The monolith still handles everything that the proxy does not explicitly route.
Step 4: Ramp traffic gradually. Use the percentage-based flag to send a small fraction of traffic to the new service. Increase it in 10% steps, observing at each level. This catches edge cases that only appear under load.
Step 5: Remove the feature from the monolith. Once the new service handles 100% of traffic for the feature and has been stable for at least one week, remove the corresponding code from the monolith. The proxy still routes to the new service. The monolith is one feature lighter.
Step 6: Delete the routing rule. Now that the monolith no longer implements the feature, remove the route from the routing table. The proxy no longer needs to distinguish this feature. Everything still goes through the proxy, but the branching logic gets simpler with each migrated feature.
Common anti-patterns
The Strangler Fig pattern is simple to describe and hard to execute. Here are the traps that teams fall into most often.
The half-strangled monolith
You migrate two features to new services. Then the business priorities shift. Six months later, the team that built the new services has been reassigned, the monolith has three new features that duplicate logic from the services, and nobody knows whether the truth lives in the old database or the new one.
The fix: do not start the Strangler Fig unless you are committed to finishing it. Every incomplete migration adds complexity. The proxy is one more thing to maintain. The dual-write code is one more source of bugs. If you cannot dedicate a team to the migration for its full duration, do not start at all.
The shared database trap
The shared database strategy is seductive because it lets you ship quickly. The problem is that it never gets unshared. The new service keeps reading from the monolith’s database, which means the monolith’s schema constrains the new service’s domain model. You end up with a microservice that shares a database with the monolith, which is not a microservice at all.
Set a hard deadline for extracting the database. If you start with shared database access, schedule the extraction before you start the next feature migration. Otherwise the shared database becomes permanent.
Migrating the wrong boundary
Teams often decompose a monolith along technical layers instead of business capabilities. They extract “the database access layer” into a service, then “the business logic layer” into another service. This creates chatty services that cannot function independently, which defeats the purpose of the migration.
The correct boundary is a business capability: user management, order processing, inventory. Each service owns its data, its logic, and its API. A service should be independently deployable and independently useful.
No rollback plan
The proxy’s routing table is your rollback plan. If a new service fails, you delete its route from the table and traffic flows to the monolith again. But this only works if the monolith still has the feature’s code and the data it needs.
Do not delete the monolith’s code for a feature until the new service has been stable for at least a week and you have verified that no traffic is reaching the old code path. Keep the deletion as its own deploy, separate from the routing rule change. That way you can restore the monolith’s code without also reverting the routing change.
The takeaway
The Strangler Fig pattern is the only safe way to replace a production monolith. It works because it replaces risk with reversibility. Every change is small, isolated, and testable. If it breaks, you flip a routing rule and the old system takes over. No rollback scripts, no coordinated deploys, no 3 AM calls.
The proxy is your migration tool. The feature flags are your safety net. The data sync strategy is your long-term commitment. Spend the time to get each one right before you start migrating features.
A final rule of thumb: if you cannot migrate a single feature in two weeks, the boundary is wrong or the new service is too ambitious. Pick a smaller feature. The Strangler Fig grows one branch at a time.
A note from Yojji
The kind of architectural discipline that turns a tangled legacy monolith into a clean set of independently deployable services, designing the routing layer, the data migration strategy, and the gradual rollout plan, is the kind of delivery engineering Yojji’s teams practice every day.
Yojji is an international custom software development company founded in 2016, with teams across Europe, the US, and the UK. They specialize in the JavaScript ecosystem, cloud platforms, and the kind of incremental migration work that keeps production running through every phase of a system replacement.