Zum Inhalt springen

Sicheres Refactoring von Legacy-Code, ohne alles kaputtzumachen

Systematische Refactoring-Strategien für Legacy-Code: Charakterisierungstests, Strangler Pattern und schrittweise Extraktion ohne Regressionen.

4 Min. Lesezeit
Vorher-Nachher-Diagramm, das zeigt, wie Legacy-Code schrittweise in saubere Module extrahiert wird, abgesichert durch ein Sicherheitsnetz aus Tests

Legacy-Code ist nicht einfach nur alter Code. Es ist Code ohne Tests, Code, den niemand versteht, oder Code, den keiner anzufassen wagt. Der erste Impuls ist meist, alles von Grund auf neu zu schreiben – aber solche Neuentwicklungen scheitern häufiger, als dass sie gelingen, weil dabei Jahre an eingebetteter Geschäftslogik verloren gehen: Bugfixes, Grenzfälle und undokumentierte Anforderungen, die tief in bedingten Verzweigungen stecken, an deren Entstehung sich niemand mehr erinnert.

Der sicherere Weg ist inkrementelles Refactoring: das Unbekannte in Tests einhüllen, die verständlichen Teile extrahieren und den Legacy-Anteil im Lauf der Zeit schrittweise verkleinern.

Charakterisierungstests: verstehen, was der Code wirklich tut

Bevor Sie Legacy-Code ändern, müssen Sie wissen, was er aktuell tatsächlich tut – nicht, was er tun sollte. Charakterisierungstests halten das bestehende Verhalten fest, einschließlich vorhandener Bugs.

tstypescript
// ❌ Writing tests based on what you think the code should do
test("calculateDiscount returns 10% for premium users", () => {
  expect(calculateDiscount("premium", 100)).toBe(90);
});
// This test might fail because the actual code has a bug
// that gives premium users 15%. The bug might be a feature
// that sales promised to customers.
tstypescript
// ✅ Characterization tests: capture what the code actually does
function characterize(
  fn: (...args: unknown[]) => unknown,
  inputs: unknown[][]
): void {
  for (const args of inputs) {
    const result = fn(...args);
    console.log(
      `${fn.name}(${args.map(a => JSON.stringify(a)).join(", ")}) => ${JSON.stringify(result)}`
    );
    // Copy these outputs into test assertions
  }
}
 
// Step 1: Run with various inputs and record actual outputs
characterize(calculateDiscount, [
  ["premium", 100],
  ["premium", 0],
  ["standard", 100],
  ["standard", 50],
  ["", 100],
  [null, 100],
  ["premium", -10],
]);
 
// Step 2: Turn recorded outputs into tests
describe("calculateDiscount (characterization)", () => {
  test("premium 100 → 85", () => {
    expect(calculateDiscount("premium", 100)).toBe(85);
  });
  test("premium 0 → 0", () => {
    expect(calculateDiscount("premium", 0)).toBe(0);
  });
  test("standard 100 → 95", () => {
    expect(calculateDiscount("standard", 100)).toBe(95);
  });
  test("null tier → 100 (no discount)", () => {
    expect(calculateDiscount(null, 100)).toBe(100);
  });
  test("negative amount → -10 (no guard)", () => {
    expect(calculateDiscount("premium", -10)).toBe(-10);
  });
});
// These tests document reality, not intent

Charakterisierungstests dienen als Sicherheitsnetz. Ändert ein Refactoring das bestehende Verhalten, schlägt ein Test fehl und macht Sie darauf aufmerksam, zu prüfen, ob die Verhaltensänderung beabsichtigt ist, bevor sie in Produktion geht.

Die Nahtstellen-Technik: sichere Ansatzpunkte fürs Refactoring finden

Eine Nahtstelle (engl. seam) ist eine Stelle, an der Sie das Verhalten ändern können, ohne den Code selbst zu bearbeiten. Michael Feathers prägte diesen Begriff in "Working Effectively with Legacy Code". Nahtstellen sind Ihre Einstiegspunkte, um Tests einzufügen und Logik zu extrahieren.

tstypescript
// Legacy function with embedded dependencies
function processOrder(orderId: string): void {
  // Direct database call — hard to test
  const order = db.query(
    `SELECT * FROM orders WHERE id = '${orderId}'`
  );
 
  // Business logic buried in the middle
  let total = 0;
  for (const item of order.items) {
    let price = item.price;
    if (item.category === "electronics" && order.memberTier === "gold") {
      price = price * 0.9;
    }
    if (item.quantity > 10) {
      price = price * 0.95;
    }
    total += price * item.quantity;
  }
 
  // Direct email service call
  emailService.send(order.email, `Your total is $${total}`);
 
  // Direct database update
  db.query(
    `UPDATE orders SET total = ${total} WHERE id = '${orderId}'`
  );
}
tstypescript
// Step 1: Extract parameters to create seams
function processOrder(
  order: Order,
  notify: (email: string, message: string) => void,
  save: (orderId: string, total: number) => void
): number {
  let total = 0;
  for (const item of order.items) {
    let price = item.price;
    if (item.category === "electronics" && order.memberTier === "gold") {
      price = price * 0.9;
    }
    if (item.quantity > 10) {
      price = price * 0.95;
    }
    total += price * item.quantity;
  }
 
  notify(order.email, `Your total is $${total}`);
  save(order.id, total);
  return total;
}
 
// Step 2: Now you can test the business logic
test("gold member gets 10% off electronics", () => {
  const order: Order = {
    id: "1",
    email: "test@example.com",
    memberTier: "gold",
    items: [
      { category: "electronics", price: 100, quantity: 1 },
    ],
  };
 
  const total = processOrder(
    order,
    () => {}, // stub notification
    () => {}  // stub persistence
  );
 
  expect(total).toBe(90);
});

Das Extract-Wrap-Delegate-Pattern

Extrahieren Sie bei großen Legacy-Funktionen die Geschäftslogik in ein neues, sauberes Modul, verpacken Sie den alten Code so, dass er an dieses neue Modul delegiert, und prüfen Sie, ob das Verhalten übereinstimmt.

tstypescript
// Step 1: Extract the pricing logic into a clean module
interface PricingRule {
  applies: (item: OrderItem, order: Order) => boolean;
  calculate: (price: number) => number;
}
 
const pricingRules: PricingRule[] = [
  {
    applies: (item, order) =>
      item.category === "electronics" && order.memberTier === "gold",
    calculate: (price) => price * 0.9,
  },
  {
    applies: (item) => item.quantity > 10,
    calculate: (price) => price * 0.95,
  },
];
 
function calculateOrderTotal(
  order: Order,
  rules: PricingRule[]
): number {
  let total = 0;
 
  for (const item of order.items) {
    let price = item.price;
 
    for (const rule of rules) {
      if (rule.applies(item, order)) {
        price = rule.calculate(price);
      }
    }
 
    total += price * item.quantity;
  }
 
  return total;
}
tstypescript
// Step 2: Verify new module matches old behavior
function verifyEquivalence(testCases: Order[]): void {
  for (const order of testCases) {
    const oldResult = legacyCalculateTotal(order);
    const newResult = calculateOrderTotal(order, pricingRules);
 
    if (oldResult !== newResult) {
      console.error(
        `Mismatch for order ${order.id}: ` +
        `legacy=${oldResult}, new=${newResult}`
      );
    }
  }
}
 
// Step 3: Deploy behind a feature flag
function getOrderTotal(order: Order): number {
  if (featureFlags.isEnabled("new-pricing-engine")) {
    return calculateOrderTotal(order, pricingRules);
  }
  return legacyCalculateTotal(order);
}

Legacy-Module schrittweise verdrängen

Bei größeren Refactoring-Vorhaben ersetzt das Strangler-Fig-Pattern Legacy-Module nach und nach: Neuer Traffic wird durch neuen Code geleitet, während der Legacy-Code weiterhin die bestehenden Pfade bedient.

tstypescript
interface MigrationTracker {
  module: string;
  totalEndpoints: number;
  migratedEndpoints: number;
  legacyEndpoints: string[];
  migratedOn: Map<string, Date>;
}
 
class StranglerRouter {
  private migrated: Set<string> = new Set();
  private tracker: MigrationTracker;
 
  constructor(module: string, totalEndpoints: number) {
    this.tracker = {
      module,
      totalEndpoints,
      migratedEndpoints: 0,
      legacyEndpoints: [],
      migratedOn: new Map(),
    };
  }
 
  markMigrated(endpoint: string): void {
    this.migrated.add(endpoint);
    this.tracker.migratedEndpoints++;
    this.tracker.migratedOn.set(endpoint, new Date());
  }
 
  route(
    endpoint: string,
    legacyHandler: () => unknown,
    newHandler: () => unknown
  ): unknown {
    if (this.migrated.has(endpoint)) {
      return newHandler();
    }
    return legacyHandler();
  }
 
  getProgress(): { percentage: number; remaining: string[] } {
    return {
      percentage:
        (this.tracker.migratedEndpoints / this.tracker.totalEndpoints) * 100,
      remaining: this.tracker.legacyEndpoints.filter(
        e => !this.migrated.has(e)
      ),
    };
  }
}

Checkliste für sicheres Refactoring

tstypescript
interface RefactoringStep {
  step: string;
  verification: string;
  rollbackPlan: string;
}
 
const safeRefactoringProcess: RefactoringStep[] = [
  {
    step: "Write characterization tests for existing behavior",
    verification: "All tests pass against current code",
    rollbackPlan: "N/A — no code changes yet",
  },
  {
    step: "Extract testable interfaces (seams)",
    verification: "Characterization tests still pass",
    rollbackPlan: "Revert extraction commit",
  },
  {
    step: "Write unit tests for extracted logic",
    verification: "Unit tests match characterization test behavior",
    rollbackPlan: "Delete new tests, keep old code",
  },
  {
    step: "Implement new module alongside legacy",
    verification: "Run both, compare outputs for N days",
    rollbackPlan: "Feature flag to legacy path",
  },
  {
    step: "Route traffic to new module",
    verification: "Monitor error rates, latencies, business metrics",
    rollbackPlan: "Feature flag back to legacy",
  },
  {
    step: "Remove legacy code",
    verification: "All tests pass, monitoring stable for 2 weeks",
    rollbackPlan: "Git revert — legacy code still in history",
  },
];

Die wichtigsten Erkenntnisse

Legacy-Code sicher zu refactoren bedeutet, durch Tests Vertrauen aufzubauen, bevor Sie Änderungen vornehmen. Beginnen Sie mit Charakterisierungstests, die das tatsächliche Verhalten dokumentieren, nicht das beabsichtigte. Suchen Sie Nahtstellen, an denen Sie Testdoubles einsetzen und Logik extrahieren können. Nutzen Sie das Extract-Wrap-Delegate-Pattern, um saubere Ersatzlösungen parallel zum Legacy-Code aufzubauen, und vergleichen Sie die Ergebnisse in Produktion, bevor Sie umschalten. Feature Flags geben Ihnen ein sofortiges Rollback, falls etwas nicht übereinstimmt. Das Ziel ist nicht, den Code perfekt zu machen – sondern ihn mit jeder Änderung ein Stück besser zu machen, ohne je die bestehende Funktionalität zu brechen. Teams, denen Refactoring erfolgreich gelingt, sind jene, die der Versuchung einer kompletten Neuentwicklung widerstehen und stattdessen die Legacy-Fläche stetig verkleinern, ein extrahiertes Modul nach dem anderen.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX