Wie man gewachsene Systeme schrittweise modernisiert, statt sie neu zu schreiben: Strangler Fig Pattern, Charakterisierungstests, Kapselung und kleine Refactorings.
Michael Feathers definiert Legacy Code in seinem Buch „Working Effectively with Legacy Code“ als Code ohne Tests. Die Definition ist knapp, trifft aber den Kern: Ohne Tests lässt sich nicht feststellen, ob eine Änderung etwas kaputt macht. In der Praxis kommt meist hinzu, dass die ursprünglichen Entwickler nicht mehr im Team sind. Wer den Code heute betreut, kennt seine Seiteneffekte nur teilweise und fasst manche Module lieber nicht an.
Warum ein Rewrite selten die Lösung ist
Der Vorschlag, das System komplett neu zu schreiben, kommt in solchen Projekten fast immer. Er scheitert oft aus denselben Gründen. Das Altsystem muss weiterlaufen, bis das neue fertig ist, und jede fachliche Änderung wird in dieser Zeit doppelt umgesetzt. Die Neuentwicklung dauert länger als geplant, weil das Altsystem Regeln enthält, die nirgends dokumentiert sind. Im schlechtesten Fall gibt es am Ende zwei Systeme, von denen keines vollständig ist.
Ein Rewrite lohnt sich nach meiner Einschätzung nur, wenn sich das Geschäftsmodell grundlegend ändert. In allen anderen Fällen ist schrittweises Modernisieren der sicherere Weg.
Strategie 1: Strangler Fig Pattern
Martin Fowler hat das Muster nach der Würgefeige benannt, die an einem Wirtsbaum hochwächst und ihn mit der Zeit ersetzt. Übertragen auf Software heißt das: Vor das Altsystem kommt eine neue Schicht, etwa ein API-Gateway, durch die alle Requests laufen. Dahinter wird eine Funktion nach der anderen neu implementiert und umgeleitet. Für die Nutzer ändert sich dabei nichts. Alte Komponenten, über die keine Requests mehr laufen, werden abgeschaltet.
[API Gateway]
|
/ | \
[Alt] [Neu] [Alt]
Beispiel
Ein über Jahre gewachsenes Bestellsystem in Python soll modernisiert werden. Ein neuer FastAPI-Service nimmt alle Bestellungen entgegen und entscheidet per Feature Flag, ob die neue Implementierung oder das Altsystem sie verarbeitet:
# Neuer FastAPI-Service
@app.post("/orders")
async def create_order(order: OrderRequest):
if feature_flag("new_order_processing"):
return await new_order_service.create(order)
return await legacy_order_proxy.create(order)
Über das Flag lässt sich die neue Implementierung einschalten und bei Problemen sofort wieder abschalten, ohne neu zu deployen.
Strategie 2: Charakterisierungstests
Bevor man Code ändert, muss man wissen, was er tatsächlich tut. Ein Charakterisierungstest hält das aktuelle Verhalten fest, auch wenn es fachlich fragwürdig ist:
from decimal import Decimal
def test_characterization_order_total():
"""
Charakterisierungstest: hält das aktuelle Verhalten fest.
Der Test beschreibt, was der Code tut, nicht was er tun sollte.
Änderungen an den erwarteten Werten nur nach Rücksprache mit der Fachseite.
"""
order = create_legacy_order(
items=[("SKU123", 2), ("SKU456", 1)],
discount_code="SUMMER10",
)
# Aktuelles Verhalten, gemessen am Altsystem
assert order.total == Decimal("47.23") # nicht 47.20
assert order.tax == Decimal("7.23")
# Ob 47.23 ein Fehler ist, ist noch offen.
Solche Tests sagen nichts darüber, ob das Verhalten richtig ist. Sie zeigen aber sofort, wenn ein Refactoring es unbeabsichtigt ändert. Ob ein auffälliges Ergebnis ein Fehler ist, klärt man danach mit der Fachseite.
Strategie 3: Bubble Context
Neuer Code soll nicht direkt mit den Datenstrukturen des Altsystems arbeiten. Stattdessen gibt es eine Stelle, die alle Legacy-Aufrufe kapselt und die Ergebnisse in saubere Modelle übersetzt. Eric Evans nennt eine solche abgegrenzte Zone Bubble Context.
# legacy_integration.py
# Einzige Stelle, die das Altsystem aufruft.
# Legacy-Funktionen nicht an anderer Stelle importieren.
from decimal import Decimal
from legacy import horrible_function
from models import Order
def get_user_orders(user_id: int) -> list[Order]:
"""Liefert die Bestellungen eines Users als Order-Objekte."""
legacy_result = horrible_function(user_id, None, "ORDERS", 1)
return [_convert_legacy_order(o) for o in legacy_result["data"]]
def _convert_legacy_order(legacy_order: dict) -> Order:
"""Übersetzt einen Legacy-Datensatz in das Order-Modell."""
return Order(
id=legacy_order["ORDER_ID"],
total=Decimal(str(legacy_order["TOTAL_AMOUNT"])),
# weitere Felder analog
)
Der restliche Code kennt nur get_user_orders und das Modell Order. Ändert sich etwas am Altsystem, ist nur diese Datei betroffen.
Strategie 4: Boy Scout Rule
Robert C. Martin hat die Regel von den Pfadfindern übernommen: Code nach einer Änderung etwas besser hinterlassen, als man ihn vorgefunden hat. Gemeint ist kein großes Refactoring, sondern kleine Verbesserungen im Umfeld einer ohnehin anstehenden Änderung, zum Beispiel sprechende Namen und Typannotationen:
# Vorher
def calc(a,b,c,d,e):
x=a+b
if c>0:
x=x*c
return x+d-e
# Nachher, im Zuge eines Bugfixes in der Nähe
def calculate_adjusted_total(
base_amount: float,
tax: float,
multiplier: float,
bonus: float,
discount: float,
) -> float:
"""
Berechnet den angepassten Gesamtbetrag.
Hinweis: Der Multiplikator wird nur angewendet, wenn er größer 0 ist.
Das ist übernommenes Verhalten, der Grund ist nicht bekannt.
"""
subtotal = base_amount + tax
if multiplier > 0:
subtotal *= multiplier
return subtotal + bonus - discount
calc = calculate_adjusted_total # alter Name für bestehende Aufrufer
Der alte Funktionsname bleibt als Alias erhalten, bestehende Aufrufer laufen unverändert weiter. Das auffällige Verhalten beim Multiplikator ist im Docstring festgehalten und nicht nebenbei geändert.
Strategie 5: Verhalten in Tests dokumentieren
Wo es keine Spezifikation gibt, können Tests diese Rolle übernehmen. Jeder Test beschreibt eine fachliche Regel, die man durch Analyse des Altsystems herausgefunden hat:
from decimal import Decimal
class TestLegacyOrderCalculation:
"""
Verhalten des Legacy-Bestellsystems, per Reverse Engineering ermittelt.
Die Tests beschreiben das vorgefundene Verhalten, keine Spezifikation.
"""
def test_discount_applied_before_tax(self):
"""Der Rabatt wird vor der Steuer abgezogen."""
order = create_order(
subtotal=Decimal("100"),
discount=Decimal("10"),
tax_rate=Decimal("0.19"),
)
# (100 - 10) * 1.19 = 107.10
assert order.total == Decimal("107.10")
def test_minimum_order_value_ignored_for_premium(self):
"""
Für Premium-Kunden gilt kein Mindestbestellwert.
Die Regel stammt von 2008, die Begründung ist nicht überliefert.
"""
order = create_order(subtotal=Decimal("5"), customer_type="premium")
assert order.is_valid is True
Wer später wissen will, warum Premium-Kunden keinen Mindestbestellwert haben, findet im Test zumindest den Hinweis, dass die Regel bekannt ist und bewusst übernommen wurde.
Warnsignale
Dringend wird eine Modernisierung, wenn eines dieser Merkmale zutrifft:
- Es gibt keine automatisierten Tests.
- Der Code liegt nicht in einer Versionskontrolle.
- Nur eine Person kennt das System.
- Bestimmte Module gelten im Team als unantastbar.
- Außer dem Code selbst gibt es keine Dokumentation.