What is the Strangler Fig Pattern?
The Strangler Fig Pattern, named by Martin Fowler after a tropical tree that grows around and eventually replaces its host tree, is a migration strategy for incrementally replacing a legacy system. Instead of a risky big-bang rewrite, you gradually build new functionality alongside the old system, redirecting traffic piece by piece until the legacy system can be decommissioned.
Three Phases of the Strangler Fig
- Transform: Build the new component that will replace a specific part of the legacy system
- Coexist: Run both old and new implementations side by side, gradually routing traffic to the new one
- Eliminate: Once the new component handles all traffic and is proven stable, remove the old one
Implementation with a Routing Facade
// Strangler facade that routes between old and new systems
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";
const app = express();
// Configuration: which routes go to which system
interface RouteConfig {
path: string;
target: "legacy" | "new";
methods?: string[];
featureFlag?: string;
}
const routeConfigs: RouteConfig[] = [
// Already migrated — all traffic to new system
{ path: "/api/products", target: "new" },
{ path: "/api/products/*", target: "new" },
// In progress — feature flag controlled
{ path: "/api/orders", target: "new", methods: ["GET"], featureFlag: "new-order-reads" },
{ path: "/api/orders", target: "legacy", methods: ["POST"] },
// Not yet migrated — all traffic to legacy
{ path: "/api/users", target: "legacy" },
{ path: "/api/reports", target: "legacy" },
];
const targets = {
legacy: "http://monolith:8080",
new: "http://new-services-gateway:3000",
};
// Feature flag service
class FeatureFlagService {
private flags = new Map();
async isEnabled(flag: string): Promise {
// In production: check LaunchDarkly, Split.io, etc.
return this.flags.get(flag) ?? false;
}
}
const featureFlags = new FeatureFlagService();
// Route each request to the appropriate target
app.use(async (req, res, next) => {
const matchingRoute = routeConfigs.find(config => {
const pathMatch = req.path.startsWith(config.path.replace("/*", ""));
const methodMatch = !config.methods || config.methods.includes(req.method);
return pathMatch && methodMatch;
});
if (!matchingRoute) {
// Default to legacy for unmigrated routes
return proxy(req, res, "legacy");
}
// Check feature flag if applicable
if (matchingRoute.featureFlag) {
const enabled = await featureFlags.isEnabled(matchingRoute.featureFlag);
if (!enabled) {
return proxy(req, res, "legacy");
}
}
return proxy(req, res, matchingRoute.target);
});
function proxy(req: express.Request, res: express.Response, target: string): void {
createProxyMiddleware({
target: targets[target as keyof typeof targets],
changeOrigin: true,
})(req, res, () => {});
}
Migration Steps in Detail
// Step 1: Identify a bounded context to extract
// Start with a module that has clear boundaries and few dependencies
// Step 2: Build the new service
// New Product Service with its own database
class ProductService {
constructor(private readonly productRepo: ProductRepository) {}
async getProduct(id: string): Promise {
return this.productRepo.findById(id);
}
async createProduct(data: CreateProductDTO): Promise {
const product = Product.create(data);
await this.productRepo.save(product);
return product;
}
}
// Step 3: Data migration — sync data from legacy to new
class DataMigrationService {
constructor(
private readonly legacyDb: LegacyDatabase,
private readonly newDb: NewDatabase
) {}
async migrateProducts(): Promise {
const legacyProducts = await this.legacyDb.query("SELECT * FROM products");
let migrated = 0;
let failed = 0;
for (const row of legacyProducts) {
try {
await this.newDb.query(
"INSERT INTO products (id, name, price, category, created_at) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (id) DO UPDATE SET name = $2, price = $3",
[row.id, row.name, row.price, row.category_name, row.created_at]
);
migrated++;
} catch (error) {
failed++;
console.error(`Failed to migrate product ${row.id}:`, error);
}
}
return { total: legacyProducts.length, migrated, failed };
}
// Keep data in sync during coexistence period
async setupChangeDataCapture(): Promise {
// Use Debezium or similar CDC tool to stream changes
// from legacy DB to new service's event bus
}
}
// Step 4: Shadow testing — send traffic to both, compare results
class ShadowTestMiddleware {
async handle(req: Request, res: Response, next: NextFunction): Promise {
// Send to legacy (primary)
const legacyResult = await this.callLegacy(req);
// Send to new service (shadow — result is discarded)
this.callNewService(req).then(newResult => {
// Compare results asynchronously
if (JSON.stringify(legacyResult) !== JSON.stringify(newResult)) {
console.warn("Shadow test mismatch:", {
path: req.path,
legacy: legacyResult,
new: newResult,
});
}
}).catch(err => {
console.error("Shadow call failed:", err);
});
// Always return legacy result during shadow testing
res.json(legacyResult);
}
}
// Step 5: Gradual traffic shift
// 0% -> 1% -> 5% -> 25% -> 50% -> 100% new service
class TrafficSplitter {
private newServicePercentage = 0;
setPercentage(pct: number): void {
this.newServicePercentage = Math.min(100, Math.max(0, pct));
}
shouldRouteToNew(): boolean {
return Math.random() * 100 < this.newServicePercentage;
}
}
Migration Checklist
- Data synchronization: Ensure data stays in sync during the coexistence period — use CDC or dual-write patterns
- Rollback plan: Always be able to route 100% traffic back to the legacy system if issues arise
- Monitoring: Compare error rates, latency, and correctness between old and new systems
- Team alignment: The team working on the new service needs deep knowledge of the legacy behavior
- Feature parity: The new service must handle all edge cases the legacy system handles before full cutover