System Design ·

The Strangler Fig Pattern: Incrementally Replacing Legacy Systems Without Stopping Feature Development

A practical guide to migrating legacy systems using the strangler fig pattern: routing proxies, anti-corruption layers, dual-write data strategies, CDC with Debezium, and a TypeScript implementation of an incremental traffic-shifting proxy.

The Strangler Fig Pattern: Incrementally Replacing Legacy Systems Without Stopping Feature Development

In 2000, Joel Spolsky called it “the single worst strategic mistake that any software company can make”: throwing away your code and starting from scratch. He was writing about Netscape’s decision to rewrite Navigator from the ground up. It took three years, cost Netscape its market position, and the resulting browser was arguably worse than what it replaced.

Engineers still make this mistake. The big-bang rewrite is appealing: greenfield development, no legacy constraints, modern patterns throughout. The reality is messier. Business requirements keep changing during the rewrite. The old system accumulates bugs that nobody documented. Edge cases in the legacy code represent years of hard-won production knowledge, and the rewrite team discovers them one by one in production after launch.

The strangler fig pattern, named by Martin Fowler after a tropical plant that grows around a host tree until the tree dies and leaves behind only the fig’s structure, offers an alternative. You replace the legacy system incrementally, feature by feature, while the old system continues running in production.

Why Big-Bang Rewrites Fail

The fundamental problem with a big-bang rewrite is the assumption that you understand the system you are replacing. You rarely do.

Legacy systems accumulate behavior over years. Some of it is in code comments. Much of it is in the heads of engineers who left two jobs ago. Some of it exists only as production behavior that nobody remembers adding. When you rewrite from scratch, you are really building a new system based on a specification derived from reading old code plus whatever tribal knowledge survives. The gap between that specification and the actual system is where production incidents live.

There is also the timeline problem. Every big-bang rewrite estimate is wrong. The team finishes 80% of the work, discovers the remaining 20% is the hard part, and the project slips. Meanwhile, the business has frozen feature development on the old system to avoid divergence with the rewrite. You end up in a period where nothing ships to users, the team is demoralized, and stakeholders are asking questions that nobody has good answers for.

The strangler fig avoids both failure modes by keeping the old system running in production throughout the migration. Users are never exposed to a flag day. The team can ship incrementally.

The Pattern: Routing Layer, Feature Extraction, Anti-Corruption

The strangler fig has three structural components.

The routing layer sits in front of both the legacy and new system. Every incoming request passes through it. Initially, all traffic goes to the legacy system. As new implementations become ready, you shift traffic for specific routes or capabilities to the new system. The routing layer is the control plane for the migration.

Feature extraction is the migration unit. You identify a bounded piece of functionality in the legacy system, implement it independently in the new system, validate it, and then shift traffic for that feature via the routing layer. The legacy system handles everything else until its share of traffic reaches zero.

The anti-corruption layer (ACL) manages the boundary between old and new during coexistence. The legacy system has a domain model. The new system has a different domain model. Wherever they need to communicate, adapters translate between the two representations. Without this layer, the new system starts importing legacy concepts and the migration stalls as the new system gets contaminated by old patterns.

Implementing the Routing Proxy

Here is a TypeScript implementation of a routing proxy that gradually shifts traffic from a legacy service to a new service, with configurable per-route weights.

import { createServer, IncomingMessage, ServerResponse } from "http";
import { request as httpRequest } from "http";

interface RouteConfig {
  path: string;
  legacyWeight: number; // 0-100, percentage of traffic to legacy
  newWeight: number;    // 0-100, percentage of traffic to new service
}

interface ProxyConfig {
  routes: RouteConfig[];
  legacyHost: string;
  legacyPort: number;
  newHost: string;
  newPort: number;
  defaultTarget: "legacy" | "new";
}

function selectTarget(config: RouteConfig): "legacy" | "new" {
  const roll = Math.random() * 100;
  return roll < config.legacyWeight ? "legacy" : "new";
}

function findRouteConfig(
  path: string,
  routes: RouteConfig[]
): RouteConfig | undefined {
  return routes.find((r) => path.startsWith(r.path));
}

function proxyRequest(
  req: IncomingMessage,
  res: ServerResponse,
  targetHost: string,
  targetPort: number
): void {
  const options = {
    host: targetHost,
    port: targetPort,
    path: req.url,
    method: req.method,
    headers: {
      ...req.headers,
      "x-forwarded-by": "strangler-proxy",
    },
  };

  const proxyReq = httpRequest(options, (proxyRes) => {
    res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
    proxyRes.pipe(res);
  });

  proxyReq.on("error", (err) => {
    console.error("Proxy error:", err.message);
    res.writeHead(502);
    res.end("Bad Gateway");
  });

  req.pipe(proxyReq);
}

export function createStranglerProxy(config: ProxyConfig) {
  return createServer((req: IncomingMessage, res: ServerResponse) => {
    const path = req.url ?? "/";
    const routeConfig = findRouteConfig(path, config.routes);

    let target: "legacy" | "new";

    if (!routeConfig) {
      target = config.defaultTarget;
    } else {
      target = selectTarget(routeConfig);
    }

    const [host, port] =
      target === "legacy"
        ? [config.legacyHost, config.legacyPort]
        : [config.newHost, config.newPort];

    // Emit routing decision for observability
    console.log(
      JSON.stringify({
        ts: new Date().toISOString(),
        path,
        target,
        method: req.method,
      })
    );

    proxyRequest(req, res, host, port);
  });
}

// Example: shift /api/orders to new service at 10% to start
const proxy = createStranglerProxy({
  legacyHost: "localhost",
  legacyPort: 3000,
  newHost: "localhost",
  newPort: 4000,
  defaultTarget: "legacy",
  routes: [
    { path: "/api/orders", legacyWeight: 90, newWeight: 10 },
    { path: "/api/users", legacyWeight: 100, newWeight: 0 },
    { path: "/api/inventory", legacyWeight: 50, newWeight: 50 },
  ],
});

proxy.listen(8080, () => {
  console.log("Strangler proxy listening on :8080");
});

In production, the RouteConfig weights would be stored in a config store (Redis, a database, feature flag service) and reloaded without restarting the proxy. The routing decision log is the foundation for your comparison tooling: send both sets of requests and compare responses when legacyWeight > 0 && newWeight > 0 using shadow mode before committing to live traffic shifts.

The Anti-Corruption Layer

When the new service needs to read data from the legacy system, or when the legacy system needs to call back into the new system, you need translation. Without it, you import legacy data structures into the new codebase and the migration stalls.

Here is a concrete example. A legacy system represents a customer as a flat record with embedded address fields. The new system uses a structured domain model.

// Legacy domain model (reflects old database schema)
interface LegacyCustomer {
  cust_id: number;
  cust_name: string;
  addr_line1: string;
  addr_line2: string;
  addr_city: string;
  addr_state: string;
  addr_zip: string;
  acct_status: "A" | "I" | "S"; // Active, Inactive, Suspended
}

// New domain model
interface Address {
  line1: string;
  line2?: string;
  city: string;
  state: string;
  postalCode: string;
}

type AccountStatus = "active" | "inactive" | "suspended";

interface Customer {
  id: string;
  name: string;
  address: Address;
  status: AccountStatus;
}

// Anti-corruption layer: translates legacy -> new domain
export class LegacyCustomerAdapter {
  private static statusMap: Record<LegacyCustomer["acct_status"], AccountStatus> =
    {
      A: "active",
      I: "inactive",
      S: "suspended",
    };

  static fromLegacy(legacy: LegacyCustomer): Customer {
    return {
      id: String(legacy.cust_id),
      name: legacy.cust_name,
      address: {
        line1: legacy.addr_line1,
        line2: legacy.addr_line2 || undefined,
        city: legacy.addr_city,
        state: legacy.addr_state,
        postalCode: legacy.addr_zip,
      },
      status: this.statusMap[legacy.acct_status],
    };
  }

  static toLegacy(customer: Customer): LegacyCustomer {
    const reverseStatusMap: Record<AccountStatus, LegacyCustomer["acct_status"]> =
      {
        active: "A",
        inactive: "I",
        suspended: "S",
      };

    return {
      cust_id: parseInt(customer.id, 10),
      cust_name: customer.name,
      addr_line1: customer.address.line1,
      addr_line2: customer.address.line2 ?? "",
      addr_city: customer.address.city,
      addr_state: customer.address.state,
      addr_zip: customer.address.postalCode,
      acct_status: reverseStatusMap[customer.status],
    };
  }
}

The adapter lives at the boundary. The new service’s internal code never sees cust_id, addr_line1, or acct_status: "A". If the legacy schema changes, you update the adapter. If the new domain model evolves, you update the adapter. The rest of the codebase remains insulated.

Data Migration: The Hard Part

Moving compute is straightforward. Moving data without downtime and without data loss is where migrations go wrong.

Phase 1: Shared Database

In the early phase, both the legacy system and the new service read and write the same database. The new service uses the legacy schema directly, with the ACL handling translation. This is expedient but fragile: schema changes become blocked by the constraint that both services must agree. Run this phase for as short a time as possible.

Phase 2: Dual Write

When the new service needs its own schema, you enter the dual-write phase. Every write goes to both databases. Reads come from the legacy database until you verify the new database has caught up.

interface OrderWriteResult {
  legacyId: number;
  newId: string;
}

export class DualWriteOrderRepository {
  constructor(
    private readonly legacyDb: LegacyDatabase,
    private readonly newDb: NewDatabase,
    private readonly mismatchLog: MismatchLogger
  ) {}

  async createOrder(order: Order): Promise<OrderWriteResult> {
    // Write to legacy first: it is the source of truth during coexistence
    const legacyId = await this.legacyDb.insertOrder(
      LegacyOrderAdapter.toLegacy(order)
    );

    // Write to new system
    let newId: string;
    try {
      newId = await this.newDb.insertOrder({
        ...order,
        legacyRef: legacyId,
      });
    } catch (err) {
      // New system write failures are logged but not fatal during migration
      this.mismatchLog.logWriteFailure({ order, legacyId, error: String(err) });
      return { legacyId, newId: "" };
    }

    return { legacyId, newId };
  }

  async verifyConsistency(legacyId: number): Promise<boolean> {
    const [legacy, current] = await Promise.all([
      this.legacyDb.findOrderById(legacyId),
      this.newDb.findOrderByLegacyRef(legacyId),
    ]);

    const legacyNormalized = LegacyOrderAdapter.fromLegacy(legacy);
    const match = this.deepEqual(legacyNormalized, current);

    if (!match) {
      this.mismatchLog.logMismatch({ legacyId, legacy: legacyNormalized, current });
    }

    return match;
  }

  private deepEqual(a: unknown, b: unknown): boolean {
    return JSON.stringify(a) === JSON.stringify(b);
  }
}

Dual write introduces consistency risk: if the legacy write succeeds and the new write fails, you have divergence. During this phase, legacy remains authoritative. The new database is eventually consistent with legacy.

Phase 3: Change Data Capture with Debezium

Dual write at the application layer has gaps: batch jobs, stored procedures, and other systems writing directly to the legacy database bypass your application code. Change data capture (CDC) solves this by reading the database transaction log directly.

Debezium connects to the legacy database’s binary log (MySQL binlog, PostgreSQL WAL, SQL Server CDC) and emits every insert, update, and delete as a Kafka event. Your new service consumes these events and applies them to the new database.

// Kafka consumer processing Debezium CDC events
import { Kafka, EachMessagePayload } from "kafkajs";

interface DebeziumEvent<T> {
  op: "c" | "u" | "d" | "r"; // create, update, delete, read (snapshot)
  before: T | null;
  after: T | null;
  source: {
    db: string;
    table: string;
    ts_ms: number;
  };
}

export class LegacyOrderCDCConsumer {
  private kafka = new Kafka({ brokers: ["kafka:9092"] });
  private consumer = this.kafka.consumer({ groupId: "new-order-service-cdc" });

  constructor(private readonly newDb: NewDatabase) {}

  async start(): Promise<void> {
    await this.consumer.connect();
    await this.consumer.subscribe({
      topic: "legacy-db.orders",
      fromBeginning: false,
    });

    await this.consumer.run({
      eachMessage: async ({ message }: EachMessagePayload) => {
        if (!message.value) return;

        const event: DebeziumEvent<LegacyOrder> = JSON.parse(
          message.value.toString()
        );

        await this.applyEvent(event);
      },
    });
  }

  private async applyEvent(event: DebeziumEvent<LegacyOrder>): Promise<void> {
    switch (event.op) {
      case "c":
      case "r":
        if (event.after) {
          await this.newDb.upsertOrder(
            LegacyOrderAdapter.fromLegacy(event.after)
          );
        }
        break;

      case "u":
        if (event.after) {
          await this.newDb.upsertOrder(
            LegacyOrderAdapter.fromLegacy(event.after)
          );
        }
        break;

      case "d":
        if (event.before) {
          await this.newDb.deleteOrderByLegacyId(event.before.order_id);
        }
        break;
    }
  }
}

With CDC running, the new database stays synchronized with legacy even for writes that bypass your application. When you are ready to cut over reads to the new database, you verify lag is below an acceptable threshold, then promote the new database as authoritative and stop the CDC consumer.

Tradeoffs

ApproachRiskFeature ContinuityRollbackComplexityTime to Value
Big-bang rewriteVery high (unknown unknowns)None during rewriteHard or impossibleHighVery long
Strangler figMedium (coexistence bugs)Full throughoutPer-feature rollbackHighIncremental
Parallel runMedium (verification gaps)Full throughoutEasyVery highLong
Branch by abstractionLow-mediumFull throughoutPer-feature rollbackMediumIncremental

Big-bang rewrite has a brutal failure profile: you discover problems only when everything is supposed to be done. There is no partial win. Rollback means abandoning years of work.

Strangler fig gives you incremental delivery but requires operating two systems simultaneously. Every piece of infrastructure must handle both. Debugging is harder because a request might be handled by either system.

Parallel run runs both systems and compares responses, which catches behavioral differences before they hit users. The verification infrastructure is expensive to build and maintain. You need tooling to record, replay, and compare requests at scale.

Branch by abstraction introduces an abstraction layer within the existing codebase and swaps implementations behind it, without running a separate system. Simpler operationally, but the legacy code stays in the same codebase longer and teams must not bypass the abstraction.

For most teams, the strangler fig and branch by abstraction are complementary: use branch by abstraction for in-process extraction, and the strangler fig for service-level decomposition.

Production Considerations

Monitoring coexistence health. During the migration, you need metrics that span both systems. Request latency, error rates, and business metrics (order creation rate, user registration completions) should be tracked per target. If a metric degrades when you shift traffic to the new service, you catch it before it affects all users. Build dashboards that show legacy-vs-new breakdown as a first-class view.

Feature parity verification. Before shifting any traffic to a new implementation, run it in shadow mode. The proxy sends real requests to both systems but returns only the legacy response to users. You record both responses and compare them. Build the comparison tooling before you need it. Differences in response shape, status codes, and timing are all signals.

When to cut over the final routes. The last 10% is the hardest. It is the edge cases the legacy system handles that no test covers. Cut over incrementally to the last routes, not all at once. Run at 95% new for a week before going to 100%. Watch error logs obsessively in that window. Keep the legacy system running and routable for at least two weeks after the last route cuts over. Do not tear it down until you have confirmed no traffic is reaching it and no CDC consumers are still reading from its database.

When not to use the strangler fig. If the legacy system has deep shared state that cannot be incrementally extracted (a single massive database schema where every table joins to every other table), the strangler fig becomes extremely difficult. You spend most of your time on the database split problem rather than on the actual replacement. In those cases, branch by abstraction within the existing codebase often gives better results.

The strangler fig also requires organizational commitment. Two systems in production means two systems to operate, monitor, and debug. If the team is small and the migration timelines stretch long, the overhead compounds. Be honest about whether your team has the bandwidth to carry two systems for the duration.

The Real Measure of Success

A strangler fig migration is working when users cannot tell it is happening. Feature development continues. Production incidents do not spike. The legacy system’s traffic share declines week over week, not month over month.

The migration is finished when the routing proxy has nothing left to route to the old system. At that point, the strangler fig has done its job: the host tree is gone and only the new structure remains.

What looks incremental from the outside is actually the result of careful sequencing on the inside. Routing layer first. Then feature extraction in order from simplest to most complex. Data migration with verification at each phase. Parity testing before any traffic shift. Cutover only when you have confidence in the numbers.

The complexity is real. So is the payoff: you ship continuously, you learn continuously, and you never bet the product on a single deployment.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.