Back to skills
extension
Category: Development & EngineeringNo API key required

Strangler Fig Pattern

Safely replace legacy system piece by piece by building new implementation around it, routing traffic gradually, like a strangler fig tree

personAuthor: jakexiaohubgithub

Strangler Fig Pattern

Overview

The strangler fig tree grows around a host tree, gradually replacing it until the original tree is no longer needed. This pattern applies the same concept to legacy code: build the new system around the old, gradually route traffic to the new, eventually remove the old.

Core principle: Never rewrite big bang. Replace incrementally with production traffic validating each step.

Named after: Strangler fig trees that germinate in tree canopy, send roots down, eventually replace host tree.

When to Use

Use strangler fig when:

  • Legacy system is too large for big-bang rewrite
  • System must stay operational during migration
  • Risk of breaking changes is high
  • You can't afford months without deploying
  • Want to validate new implementation with real traffic
  • Need ability to rollback at any step

Don't use when:

  • System is small (just rewrite it)
  • Can afford downtime
  • Legacy and new can't coexist (architectural impossibility)

The Iron Law

NEVER BIG-BANG REWRITE

Rewrites fail. Incremental replacement succeeds. Always choose strangler fig over rewrite.

The Six-Step Process

Step 1: Identify a Seam

Find natural boundary in legacy code.

Good seams:

  • Module boundaries (payment processing, user management)
  • API endpoints (/api/users, /api/orders)
  • Feature boundaries (search, recommendations)
  • Data boundaries (user service talks to user DB)

Bad seams:

  • Middle of a function
  • Arbitrary line in code
  • "Everything that touches X"

How to find seams:

# Look for boundaries in architecture
- Separate modules/packages
- API boundaries
- Database boundaries
- Clear input/output contracts

Example: E-commerce checkout

Good seam: The entire checkout flow
- Input: Cart + user
- Output: Order confirmation
- Clear boundary

Bad seam: "The part that calculates tax"
- Too granular
- Too coupled with pricing, shipping

Step 2: Add Characterization Tests

Before touching anything: safety net.

See skills/refactoring/characterization-testing/SKILL.md

// Characterize current behavior
test('legacy checkout creates order', async () => {
  const cart = { items: [{ id: '123', quantity: 2 }] };
  const user = { id: 'user-456' };

  const order = await legacyCheckout(cart, user);

  expect(order.status).toBe('pending');
  expect(order.total).toBe(29.98);
  expect(order.items).toHaveLength(1);
});

All existing behavior must have tests. No refactoring without safety net.

Step 3: Create Abstraction Layer

Add facade that can route to old OR new implementation.

Pattern:

// Before: Direct call to legacy
const order = await legacyCheckout(cart, user);

// After: Call through abstraction
const order = await checkoutService.process(cart, user);

Implementation:

class CheckoutService {
  constructor(
    private legacyCheckout: LegacyCheckout,
    private newCheckout: NewCheckout | null,
    private featureFlags: FeatureFlags
  ) {}

  async process(cart: Cart, user: User): Promise<Order> {
    // Route to new or old based on feature flag
    if (this.newCheckout && this.featureFlags.isEnabled('new-checkout', user)) {
      return this.newCheckout.process(cart, user);
    }

    return this.legacyCheckout.process(cart, user);
  }
}

Deploy abstraction layer. At this point, 100% traffic still goes to legacy. Nothing changed functionally.

Step 4: Implement New Version

Build new implementation behind abstraction.

class NewCheckout {
  async process(cart: Cart, user: User): Promise<Order> {
    // New, clean implementation
    const validation = await this.validateCart(cart);
    const pricing = await this.calculatePricing(cart, user);
    const order = await this.createOrder(cart, user, pricing);
    await this.sendConfirmation(order, user);
    return order;
  }
}

Key points:

  • New implementation starts disabled (feature flag off)
  • Test new implementation thoroughly in isolation
  • New implementation must satisfy same contract as legacy
  • Don't touch legacy code during this step

Step 5: Gradually Route Traffic

The strangling part - slowly shift traffic to new implementation.

Phase 1: Dark launch (0% user traffic)

if (this.featureFlags.isEnabled('new-checkout-dark', user)) {
  // Call both, return legacy result, compare
  const legacyResult = await this.legacyCheckout.process(cart, user);
  const newResult = await this.newCheckout.process(cart, user);

  this.compareAndLog(legacyResult, newResult); // Log differences
  return legacyResult; // Always return legacy
}

Purpose: Validate new implementation with real data, zero user impact.

Phase 2: Canary (1% user traffic)

if (this.featureFlags.isEnabled('new-checkout', user)) {
  return this.newCheckout.process(cart, user); // Real users!
}
return this.legacyCheckout.process(cart, user);

Enable for 1% of users. Monitor errors, performance, business metrics.

Phase 3: Ramp up (10%, 25%, 50%, 100%)

Week 1: 1% → Monitor
Week 2: 10% → Monitor
Week 3: 25% → Monitor
Week 4: 50% → Monitor
Week 5: 100% → Victory!

At any stage: rollback is just flipping feature flag off.

Step 6: Remove Legacy Code

Once 100% traffic on new implementation for extended period:

  1. Verify legacy is unused

    # Check logs: zero calls to legacy in past week
    grep "legacyCheckout" logs/* | wc -l  # Should be 0
    
  2. Remove legacy code

    class CheckoutService {
      async process(cart: Cart, user: User): Promise<Order> {
        // Legacy code removed
        return this.newCheckout.process(cart, user);
      }
    }
    
  3. Clean up abstraction (optional) If abstraction was only for migration, remove it too:

    // Direct call to new implementation
    const order = await newCheckout.process(cart, user);
    

Feature Flag Patterns

Simple Boolean Flag

// config/features.ts
export const features = {
  'new-checkout': process.env.NEW_CHECKOUT_ENABLED === 'true'
};

Pros: Simple Cons: Requires deploy to change, all-or-nothing

User-Based Rollout

class FeatureFlags {
  isEnabled(flag: string, user: User): boolean {
    if (flag === 'new-checkout') {
      // Gradually roll out based on user ID
      const rolloutPercent = this.getRolloutPercent('new-checkout'); // 0-100
      const userHash = hashCode(user.id) % 100;
      return userHash < rolloutPercent;
    }
    return false;
  }
}

Pros: Gradual rollout, specific user targeting, no deploy needed Cons: More complex

A/B Test Pattern

// Compare old vs new
const variant = this.abTest.getVariant('checkout-test', user);

if (variant === 'new') {
  result = await this.newCheckout.process(cart, user);
  this.metrics.track('checkout.new', result);
} else {
  result = await this.legacyCheckout.process(cart, user);
  this.metrics.track('checkout.legacy', result);
}

Measures: Which implementation performs better (conversion, speed, errors)

Monitoring During Migration

Critical metrics to track:

// Error rates
this.metrics.increment('checkout.new.error');
this.metrics.increment('checkout.legacy.error');

// Performance
this.metrics.timing('checkout.new.duration', duration);
this.metrics.timing('checkout.legacy.duration', duration);

// Business metrics
this.metrics.increment('checkout.new.success');
this.metrics.increment('checkout.new.abandoned');

Red flags requiring rollback:

  • Error rate >2x legacy
  • P95 latency >1.5x legacy
  • Conversion rate drop >5%
  • Revenue drop

Checklist

  • [ ] Identified clear seam with well-defined input/output
  • [ ] Added characterization tests for legacy behavior
  • [ ] Created abstraction layer (facade/router)
  • [ ] Deployed abstraction layer (100% traffic to legacy still)
  • [ ] Implemented new version with tests
  • [ ] Dark launched (0% user traffic, compare results)
  • [ ] Canary release (1% users)
  • [ ] Monitored metrics (errors, performance, business)
  • [ ] Gradually ramped up (10%, 25%, 50%, 100%)
  • [ ] Verified legacy is unused
  • [ ] Removed legacy code
  • [ ] Cleaned up migration scaffolding

Real-World Example

Context

Legacy payment processing (2000 lines, 5 years old, no tests, handles $1M/day)

Step 1: Identify Seam

Seam: processPayment(order, card) → PaymentResult
Clear input/output, isolated from rest of system

Step 2: Characterization Tests

test('legacy payment: successful charge', async () => {
  const order = { total: 29.99 };
  const card = { number: '4111111111111111', cvv: '123' };

  const result = await legacyPaymentProcessor.process(order, card);

  expect(result.status).toBe('success');
  expect(result.transactionId).toBeDefined();
  expect(result.amount).toBe(29.99);
});

// 20+ more tests for edge cases, errors, etc.

Step 3: Abstraction Layer

class PaymentService {
  async process(order: Order, card: Card): Promise<PaymentResult> {
    if (this.flags.isEnabled('new-payment-processor')) {
      return this.newProcessor.process(order, card);
    }
    return this.legacyProcessor.process(order, card);
  }
}

Step 4: New Implementation

class NewPaymentProcessor {
  async process(order: Order, card: Card): Promise<PaymentResult> {
    const token = await this.stripe.createToken(card);
    const charge = await this.stripe.charge({
      amount: order.total,
      token: token.id
    });
    return {
      status: 'success',
      transactionId: charge.id,
      amount: charge.amount
    };
  }
}

Step 5: Gradual Rollout

Week 1: Dark launch - 0% users, compare results
Week 2: 1% canary - monitor error rates
Week 3: 10% - all metrics green
Week 4: 50% - confidence high
Week 5: 100% - complete!

Step 6: Remove Legacy

// 2000 lines of legacy code → deleted
// Saved ongoing maintenance cost
// New implementation: 100 lines, tested, modern API

Anti-Patterns

❌ Big-Bang Switchover

Bad: Build new system for months, switch all traffic at once Good: Gradual rollout with ability to rollback

Big-bang = all eggs in one basket. Gradual = validate each step.

❌ No Abstraction Layer

Bad: If statements scattered everywhere checking feature flags Good: Single routing point in abstraction layer

Abstraction contains the mess, makes rollback simple.

❌ Touching Legacy During Migration

Bad: "While I'm here, let me fix this bug in legacy..." Good: Don't touch legacy. All effort into new implementation.

Touching legacy = risk. New implementation will replace it anyway.

Common Mistakes

| Mistake | Reality | |---------|---------| | "I'll do it all at once" | Big-bang rewrites fail. Strangler fig succeeds. | | "I don't need feature flags" | Feature flags enable rollback. Rollback saves production. | | "Legacy works, so new implementation must match exactly" | Legacy has bugs. New can fix them. Match contract, not bugs. | | "I'll migrate when new is perfect" | Perfect = never. Ship at "good enough", iterate. | | "100% traffic → delete legacy immediately" | Wait 2-4 weeks at 100% before deleting. Verify first. |

Integration with Other Skills

  • skills/refactoring/characterization-testing - Safety net before strangling
  • skills/refactoring/seam-finding - How to find good boundaries
  • skills/safety/feature-flags-for-legacy - Routing mechanism
  • skills/refactoring/parallel-change - Similar incremental pattern

Remember

  • Never big-bang rewrite
  • Abstraction layer is key
  • Gradual rollout with monitoring
  • Feature flags enable rollback
  • Wait before deleting legacy (verify unused)
  • New implementation can fix legacy bugs
  • Strangler fig = proven pattern for legacy replacement