← All posts

The Art of Incremental Refactoring: Ship Features While Improving Code

The Art of Incremental Refactoring: Ship Features While Improving Code

Every engineer has been there: you open a file to add a simple feature and discover a tangled mess of legacy code. Your options seem binary — either hold your nose and add to the mess, or propose a massive refactor that will block feature work for weeks. But there's a third path that senior engineers master: incremental refactoring. This isn't about being cautious or slow. It's about being strategic. The best engineers I've worked with ship features faster precisely because they know how to improve code quality without stopping the train.

The Boy Scout Rule in Practice

The Boy Scout Rule — "leave the code better than you found it" — sounds great in theory but fails in practice without constraints. The key is defining better narrowly. When touching a file for a feature, I limit improvements to the immediate context: the function I'm modifying and its direct dependencies. Not the entire module. Not the architectural pattern. Just the code I'm already changing.

// Before: Adding a feature to this mess
function processOrder(order: any) {
  if (order.items && order.items.length > 0) {
    let total = 0;
    for (let i = 0; i < order.items.length; i++) {
      total += order.items[i].price * order.items[i].qty;
    }
    if (order.discount) {
      total = total - (total * order.discount);
    }
    order.total = total;
  }
  // Need to add tax calculation here...
}

// After: Refactor ONLY what you touch
interface OrderItem {
  price: number;
  quantity: number;
}

interface Order {
  items: OrderItem[];
  discount?: number;
  taxRate?: number;
}

function calculateSubtotal(items: OrderItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

function applyDiscount(subtotal: number, discount?: number): number {
  return discount ? subtotal * (1 - discount) : subtotal;
}

function calculateTax(amount: number, taxRate?: number): number {
  return taxRate ? amount * taxRate : 0;
}

function processOrder(order: Order): number {
  const subtotal = calculateSubtotal(order.items);
  const afterDiscount = applyDiscount(subtotal, order.discount);
  const tax = calculateTax(afterDiscount, order.taxRate);
  return afterDiscount + tax;
}

Notice what I didn't do: I didn't refactor the entire order processing system, migrate to a new architecture, or fix every any type in the codebase. I extracted functions for the calculations I needed to modify, added types for clarity, and made my new feature (tax calculation) trivial to implement. The PR is reviewable. The risk is contained. The code is measurably better.

The Strangler Fig Pattern for Architectural Changes

When you need to change something architectural — like replacing a legacy API client or migrating state management — the strangler fig pattern is your friend. Wrap the old system with a new interface, route new code through the wrapper, and gradually migrate old call sites. The key is that both systems run in parallel until the migration is complete.

// Legacy API client we want to replace
class LegacyAPIClient {
  async fetchUser(id: string) {
    // Old implementation with weird quirks
  }
}

// New, better implementation
class ModernAPIClient {
  async getUser(id: string): Promise<User> {
    // Clean implementation with proper types
  }
}

// Adapter that lets us migrate incrementally
class APIClient {
  private legacy = new LegacyAPIClient();
  private modern = new ModernAPIClient();
  private migratedEndpoints = new Set(['users', 'orders']);

  async fetchUser(id: string): Promise<User> {
    if (this.migratedEndpoints.has('users')) {
      return this.modern.getUser(id);
    }
    return this.legacy.fetchUser(id);
  }

  // Add feature flag support for gradual rollout
  async fetchUserWithFlag(id: string, useModern: boolean): Promise<User> {
    return useModern 
      ? this.modern.getUser(id)
      : this.legacy.fetchUser(id);
  }
}

// Now you can migrate call sites one at a time
// Old code keeps working, new code uses the better client
Production tip: Always add metrics when introducing an adapter. Track which code path is used and compare error rates. I've caught subtle bugs in "improved" implementations because I could compare them directly against the legacy behavior in production traffic.

Refactoring Debt vs. Technical Debt

Not all messy code needs refactoring. I distinguish between technical debt (code that slows down future work) and refactoring debt (code that offends my sensibilities but doesn't actually cause problems). That ugly 500-line function in a stable, rarely-changed module? Probably refactoring debt. The inconsistent error handling that makes debugging production issues take hours? Technical debt. Pay down the latter ruthlessly. Ignore the former unless you're already there.

  • Technical debt indicators: Bugs cluster in certain modules, features take longer than they should, new engineers get confused in specific areas, production debugging is painful
  • Refactoring debt indicators: Code doesn't follow current style guides, uses outdated patterns, isn't "elegant" but works fine
  • Decision rule: If fixing it would measurably improve velocity or reliability, it's technical debt worth addressing. If it just makes the code "nicer," deprioritize it
  • Track it: Keep a lightweight technical debt log in your team's docs. When someone hits a painful area, they add it. Review quarterly and tackle the highest-impact items

Making Refactoring Visible

The biggest career mistake I see engineers make with refactoring is doing it invisibly. They spend days improving code quality, then hide it in a feature PR or worse, don't mention it at all. Make your refactoring work visible. Not to brag, but because it's valuable work that should be recognized and because it teaches others the skill.

I structure refactoring PRs with a clear before/after narrative: "This function was doing X, Y, and Z. I extracted Y into a helper because we need it for the new feature. This makes the original function clearer and makes the new feature a 3-line change instead of 30." Show the value.

When to Stop and Do a Big Refactor

Sometimes incremental refactoring isn't enough. If you're hitting the same painful area repeatedly, or if the architecture is fundamentally wrong for current requirements, you need dedicated time. The key is getting buy-in by quantifying the cost of not refactoring. Track how much time the team spends working around the problem. Estimate how much faster you could ship features with the improved architecture. Present it as an investment with measurable returns, not as "we want to rewrite things because the code is ugly." I've successfully advocated for month-long refactors by showing they'd pay for themselves in saved engineering time within a quarter.

The craft of software engineering isn't about writing perfect code the first time — it's about knowing how to evolve imperfect code into better code while continuously delivering value. Master incremental refactoring, and you'll ship faster than the engineers who either ignore code quality or get stuck in endless refactoring cycles. That's the real 10x skill.