Lesson 49 of 54

Refactoring Legacy Code

Incremental Improvement Strategies

A team I worked with inherited a payment processing system built in 2012. It worked, but it was a 40,000-line monolith with no tests. The CTO wanted a rewrite. "We'll build it right this time."

Eighteen months later, they had a new system that was 60% complete, the old system still running in production with patches, and two engineers who'd quit from the stress. The rewrite never shipped.

The alternative isn't "do nothing." It's incremental improvement—replacing or improving the system piece by piece while it keeps running.

Big-Bang Rewrite vs. Incremental Improvement

ApproachRiskTimelineBusiness impact
Big-bang rewriteHigh—everything at onceMonths to yearsFeatures freeze; two codebases to maintain
Incremental improvementLow—small, reversible stepsContinuousFeatures keep shipping; improvement compounds

The trade-off: incremental improvement requires more upfront design (how do we slice this?) and discipline (we don't "just fix it all"). But it delivers value continuously and doesn't bet the business on a single massive project.

The Strangler Fig Pattern

In nature, strangler figs grow around host trees. They start as seeds in the canopy, send roots down, and gradually envelop the host. Eventually the host tree dies and the fig stands alone.

The same pattern works for legacy systems: build the new system around the old one, gradually route traffic to the new code, then remove the old.

How It Works

Typescript
// Old monolithic router - we can't replace it all at once
async function handleRequest(req: Request): Promise<Response> {
  const path = req.url.pathname;
  
  // New: Route new features to new implementation
  if (path.startsWith('/api/v2/')) {
    return handleV2Request(req);
  }
  
  // Old: Everything else goes to legacy
  return legacyHandler(req);
}

You add a routing layer. New features go to the new implementation. Old features stay on the legacy path until you migrate them one by one.

Practical Example: Migrating a User Service

Typescript
// Before: All user logic in monolith
class UserService {
  async getUser(id: string) {
    return this.legacyDb.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

// Strangler approach: New implementation behind same interface
class UserService {
  private useNewImplementation = false; // Feature flag or config
  
  async getUser(id: string) {
    if (this.useNewImplementation) {
      return this.newUserRepository.findById(id);
    }
    return this.legacyDb.query('SELECT * FROM users WHERE id = ?', [id]);
  }
}

Migrate one method at a time. Test. Switch. Remove the old path when confident.

Branch by Abstraction

When you need to replace an implementation but can't do it in one deploy, introduce an abstraction, implement the new code behind it, then switch.

The Pattern

  1. Introduce abstraction—Create an interface or abstract type for the behavior
  2. Implement new—Build the new implementation
  3. Switch—Change the wiring to use the new implementation
  4. Remove old—Delete the legacy code
Typescript
// Step 1: Introduce abstraction
interface PaymentProcessor {
  charge(amount: number, currency: string, token: string): Promise<ChargeResult>;
}

// Step 2: Old implementation implements interface
class LegacyStripeProcessor implements PaymentProcessor {
  async charge(amount: number, currency: string, token: string) {
    // Old Stripe API v1 code
    return this.stripe.charges.create({ amount, currency, source: token });
  }
}

// Step 2: New implementation
class NewStripeProcessor implements PaymentProcessor {
  async charge(amount: number, currency: string, token: string) {
    // New Stripe API v2, with better error handling
    return this.stripe.paymentIntents.create({ amount, currency, payment_method: token });
  }
}

// Step 3: Switch via config or feature flag
const processor: PaymentProcessor = config.useNewStripe
  ? new NewStripeProcessor()
  : new LegacyStripeProcessor();

The rest of your code depends on PaymentProcessor, not the concrete implementation. You can switch without touching callers.

Parallel Implementations

Sometimes you need both implementations running simultaneously—for comparison, gradual rollout, or fallback.

Typescript
class PaymentService {
  constructor(
    private legacy: LegacyPaymentProcessor,
    private modern: ModernPaymentProcessor,
    private config: { useModern: boolean; compareResults?: boolean }
  ) {}

  async charge(amount: number, token: string): Promise<ChargeResult> {
    if (this.config.compareResults) {
      // Run both, compare, log discrepancies
      const [legacyResult, modernResult] = await Promise.all([
        this.legacy.charge(amount, token),
        this.modern.charge(amount, token)
      ]);
      this.compareAndLog(legacyResult, modernResult);
      return legacyResult; // Still return legacy until we're confident
    }

    return this.config.useModern
      ? this.modern.charge(amount, token)
      : this.legacy.charge(amount, token);
  }
}

Parallel implementations let you validate the new code against production traffic before fully switching. The cost: you're running two code paths. Use it for critical paths; don't do it everywhere.

Feature Flags for Refactoring

Feature flags let you ship refactored code without activating it. Deploy. Verify. Flip the flag. Roll back by flipping again.

Typescript
// Refactored checkout flow behind a flag
async function checkout(cart: Cart, user: User): Promise<CheckoutResult> {
  if (featureFlags.isEnabled('new-checkout-flow', user.id)) {
    return newCheckoutFlow(cart, user);
  }
  return legacyCheckoutFlow(cart, user);
}

Flag Strategies

StrategyUse when
User-basedRoll out to internal users, then beta, then everyone
Percentage1% → 10% → 50% → 100% of traffic
EnvironmentStaging only first, then production
Kill switchInstant rollback if metrics degrade

The trade-off: flags add complexity. You must have a process to remove them. A flag that's been "on" for 6 months with no rollback is technical debt—delete the old path and the flag.

Choosing the Right Strategy

SituationStrategy
Replacing a module with clear boundariesBranch by abstraction
Migrating a large system over timeStrangler fig
Critical path, need validationParallel implementations
Want gradual rollout with rollbackFeature flags
Small, isolated changeJust do it—no pattern needed

Don't over-engineer. A 50-line function doesn't need the strangler fig pattern. A payment system does.

Slicing the Work

The hard part of incremental improvement: how do you slice it?

Bad slice: "Refactor the user module." (Too big, no clear completion.)

Good slice: "Move getUserById from legacy DB to new repository." (One method, testable, shippable.)

Principles for slicing:

  • One deploy per slice—Each slice should be independently deployable
  • Behavior preserved—Users see no difference
  • Reversible—You can roll back if something breaks
  • Measurable—You know when the slice is done

The Discipline of Incremental

Incremental improvement requires saying no to "just rewrite it." It requires patience. It requires committing to the strategy even when the legacy code is frustrating.

The payoff: the system improves continuously. No feature freeze. No big-bang risk. No "we'll fix it in the next version" that never comes.


Key insight: Big-bang rewrites fail. Use the strangler fig to gradually replace systems, branch by abstraction to swap implementations, and feature flags to ship refactored code safely. Slice work into small, deployable, reversible pieces. Improvement compounds when it's continuous.