← Zurück

Kapitelkompass

Erst Verhalten sichern, dann verbessern

Characterization Tests beschreiben das tatsächliche Verhalten eines Legacy-Systems, auch wenn es fachlich unbequem ist.

BeobachtenAusgangspunktTestenEinordnenSeamAbsichernRefactorErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Beobachtbares Verhalten sichern
  • Seams finden
  • Golden Master begrenzen
  • Risiken priorisieren

Praxisfall

Ein alter Order-Prozessor mischt Preis, Steuer, Zahlung und Seiteneffekte in einer Methode.

Entscheidung

Zuerst kritische Pfade und Seiteneffekte charakterisieren, nicht jede Zeile konservieren.

!

Typisches Risiko

Ein Test auf interne Aufrufreihenfolge friert Struktur statt fachliches Verhalten ein.

11. Legacy-Monster-Code und Characterization Tests

Legacy-Monster-Code und Characterization Tests

1. Ziel von Kapitel: Legacy verstehen, nicht sofort reparieren

Legacy Monster Map

Kapitel startet den Legacy-Refactoring-Deep-Dive. Der wichtigste Gedanke ist unbequem: Ein großes Legacy-Stück wird nicht besser, nur weil man sofort Klassen extrahiert. Ohne Sicherheitsnetz produziert Refactoring oft neue Fehler, weil man unbeabsichtigt altes Verhalten verändert. Deshalb behandelt dieser Kapitel die Monster-Methode zuerst wie ein Produktionssystem: Verhalten beobachten, Eingaben kontrollieren, externe Effekte erfassen und erst danach schneiden.

In vielen Enterprise-Systemen ist die problematische Methode nicht zufällig entstanden. Sie ist über Jahre gewachsen: Rabatte wurden ergänzt, Steuerregeln geändert, Payment-Anbieter gewechselt, Rechnungslogik erweitert, Events angebaut und Auditpflichten nachträglich eingebaut. Die Methode wirkt schlecht, aber sie enthält oft viel implizites Fachwissen. Wenn man dieses Wissen nicht sichtbar macht, verliert man es beim Refactoring.

Der zentrale Lernpunkt: Characterization Tests beschreiben, was der Code heute tut. Sie sagen noch nicht, ob das Verhalten fachlich schön ist. Sie verhindern zuerst, dass man versehentlich bestehendes Verhalten bricht. Später kann man dokumentiert entscheiden, welche alten Bugs bewusst geändert werden.

Schlechte typische Vorgehensweise

  1. Monster-Methode öffnen.
  2. Sich über schlechte Struktur ärgern.
  3. Sofort extract method und extract class anwenden.
  4. Tests fehlen oder prüfen nur Happy Path.
  5. Produktion findet die Sonderfälle.

Bessere Vorgehensweise

  1. fachliche Szenarien sammeln.
  2. externe Abhängigkeiten über Seams kontrollieren.
  3. deterministische Daten aufbauen.
  4. Golden-Master-Snapshots erzeugen.
  5. Verhalten konservieren.
  6. erst dann kleine Refactoring-Schritte durchführen.
TEXT
Legacy-Code -> Characterization Tests -> kleine Refactoring-Schritte -> gleiche Tests -> Architektur verbessern

2. Die Monster-Methode als reales Lernobjekt

Die Klasse LegacyOrderProcessor ist absichtlich nicht schön. Sie mischt Validierung, Preisberechnung, Rabattlogik, Steuerlogik, Lagerreservierung, Payment, Rechnungsanlage, Eventversand und Audit. Genau so sehen viele echte Legacy-Methoden aus: nicht als Spielzeugbeispiel, sondern als gewachsene Transaktionsskripte.

Auszug aus dem echten Code-Lab:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.DayOfWeek;
import java.time.ZoneOffset;
import java.util.*;
// Pattern: Legacy Facade (absichtlich) - diese Klasse zeigt eine Monster-Methode als Lernobjekt.
// Sie ist NICHT das Zielbild. Kapitel nutzt sie, um Verhalten abzusichern, bevor Refactoring beginnt.
public final class LegacyOrderProcessor {
    private final LegacyIntegrationGateway gateway;
    public LegacyOrderProcessor(LegacyIntegrationGateway gateway) {
        this.gateway = Objects.requireNonNull(gateway);
    }
// Anti-Pattern: Long Method + Mixed Responsibilities + Hidden Transaction Script.
// Zweck in diesem Kapitel: vorhandenes Verhalten sichtbar und testbar machen.
public LegacyInvoiceResult process(LegacyOrderRequest request) {
    List<String> localEvents = new ArrayList<>();
    List<String> localAudit = new ArrayList<>();
    String orderId = request == null ? "<null>" : request.orderId();
    try {
        localAudit.add("start process order=" + orderId);
        if (request == null) {
            localAudit.add("request is null");
            return LegacyInvoiceResult.failed("<null>", "INVALID_REQUEST", "request must not be null", localEvents, localAudit);
        }
    if (blank(request.orderId())) {
        localAudit.add("orderId missing");
        return LegacyInvoiceResult.failed("<missing>", "INVALID_ORDER_ID", "orderId is required", localEvents, localAudit);
    }
if (blank(request.customerId())) {
    localAudit.add("customerId missing");
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_CUSTOMER", "customerId is required", localEvents, localAudit);
}
if (request.lines() == null || request.lines().isEmpty()) {
    localAudit.add("no lines");
    return LegacyInvoiceResult.failed(request.orderId(), "EMPTY_ORDER", "at least one line is required", localEvents, localAudit);
}
if (request.riskyAccount()) {
    localAudit.add("risky account blocked customer=" + request.customerId());
    localEvents.add("OrderRejected:risky-account");
    gateway.audit("risky account rejected " + request.customerId());
    return LegacyInvoiceResult.failed(request.orderId(), "CUSTOMER_RISK_BLOCK", "customer is blocked by risk checks", localEvents, merge(localAudit, gateway));
}
BigDecimal subtotal = BigDecimal.ZERO;
BigDecimal digitalSubtotal = BigDecimal.ZERO;
BigDecimal physicalSubtotal = BigDecimal.ZERO;
int physicalQuantity = 0;
int totalQuantity = 0;
Set<String> skusSeen = new LinkedHashSet<>();
boolean containsTaxReduced = false;
boolean containsHazardous = false;
boolean containsGiftEligible = false;
for (int i = 0; i < request.lines().size(); i++) {
    OrderLineRequest line = request.lines().get(i);
    if (line == null) {
        return LegacyInvoiceResult.failed(request.orderId(), "INVALID_LINE", "line " + i + " is null", localEvents, localAudit);
    }
if (blank(line.sku())) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_SKU", "line " + i + " has no sku", localEvents, localAudit);
}
if (line.quantity() <= 0) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_QUANTITY", "quantity must be positive for " + line.sku(), localEvents, localAudit);
}
if (line.unitPrice() == null || line.unitPrice().signum() < 0) {
    return LegacyInvoiceResult.failed(request.orderId(), "INVALID_PRICE", "price invalid for " + line.sku(), localEvents, localAudit);
}
if (!skusSeen.add(line.sku())) {
    localAudit.add("duplicate sku merged logically=" + line.sku());
}
BigDecimal lineTotal = line.unitPrice().multiply(BigDecimal.valueOf(line.quantity())).setScale(2, RoundingMode.HALF_UP);
subtotal = subtotal.add(lineTotal);
totalQuantity += line.quantity();
if (line.digital()) {
    digitalSubtotal = digitalSubtotal.add(lineTotal);
}
else {
    physicalSubtotal = physicalSubtotal.add(lineTotal);
    physicalQuantity += line.quantity();
    int available = gateway.availableStock(line.sku());
    localAudit.add("stock check sku=" + line.sku() + " requested=" + line.quantity() + " available=" + available);
    if (available < line.quantity()) {
        localEvents.add("OrderRejected:stock-unavailable:" + line.sku());

Diese Methode zeigt mehrere gefährliche Eigenschaften:

Problem Warum es weh tut
viele Verantwortungen Änderungen an Steuerlogik können Payment-Verhalten beeinflussen
externe Effekte mitten in der Methode Tests brauchen Payment, Lager, Eventbroker und Zeitkontrolle
lokale Sonderregeln Fachwissen ist nicht dokumentiert, sondern im Code versteckt
Fehlercodes als Strings API, UI und Batchjobs hängen oft implizit daran
Reihenfolge wichtig Lagerreservierung vor Payment kann bei Payment-Fehlern problematisch sein

Die Methode enthält außerdem Legacy-Verhalten, das man nicht blind korrigieren darf. Beispiel: gemischte Steuerkategorien werden mit einem vereinfachten Legacy-Satz behandelt. Vielleicht ist das falsch, vielleicht ist es historisch gewollt. Kapitel ändert es nicht, sondern macht es sichtbar.

3. Characterization Tests: Verhalten einfrieren, ohne Architektur zu feiern

Characterization Loop

Ein Characterization Test ist kein idealer fachlicher Test. Er ist eine Sicherheitsleine. Er sagt: Für diese Eingabe produziert der alte Code diese beobachtbare Ausgabe. Das ist besonders wertvoll, wenn niemand sicher sagen kann, welche Sonderfälle absichtlich sind.

Der Test muss dabei nicht jedes Logdetail festnageln. Ein zu genauer Golden Master wird brüchig. Ein zu grober Golden Master übersieht Fehler. Die Kunst liegt in der Auswahl stabiler Signale:

JAVA
package com.example.legacy.Kapitel;
import java.util.stream.Collectors;
// Pattern: Golden Master - vergleicht beobachtbares Verhalten, nicht interne Struktur.
public final class GoldenMasterSnapshot {
    private GoldenMasterSnapshot() {
    }
public static String from(LegacyInvoiceResult result) {
    String events = result.domainEvents().stream().collect(Collectors.joining(","));
    String auditSignal = result.auditTrail().stream()
    .filter(a -> a.contains("stock check") || a.contains("reserved") || a.contains("authorize payment") || a.contains("invoice saved"))
    .collect(Collectors.joining(" | "));
    return "success=" + result.success()
    + ";order=" + result.orderId()
    + ";invoice=" + result.invoiceId()
    + ";subtotal=" + result.subtotal().toPlainString()
    + ";discount=" + result.discount().toPlainString()
    + ";shipping=" + result.shipping().toPlainString()
    + ";tax=" + result.tax().toPlainString()
    + ";total=" + result.total().toPlainString()
    + ";failure=" + result.failureCode()
    + ";events=" + events
    + ";auditSignal=" + auditSignal;
}
}

Der Snapshot filtert bewusst technische Details. Er nimmt nur relevante Audit-Signale, nicht jede Debug-Zeile. So schützt er Verhalten, ohne Refactoring unnötig zu blockieren.

4. Seams: externe Abhängigkeiten kontrollierbar machen

Seam Map

Legacy-Code ist schwer testbar, wenn er direkt auf Uhrzeit, Datenbank, Payment, Messaging oder Dateisystem zugreift. Eine Seam ist eine Stelle, an der man Verhalten austauschen kann, ohne den fachlichen Ablauf sofort umzubauen.

In Kapitel kapselt LegacyIntegrationGateway die externen Effekte:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.time.Instant;
// Pattern: Seam - kapselt externe Zeit-, Lager-, Payment-, Invoice- und Event-Abhängigkeiten für Characterization Tests.
public interface LegacyIntegrationGateway {
    Instant now();
    int availableStock(String sku);
    boolean reserveStock(String orderId, String sku, int quantity);
    PaymentDecision authorizePayment(String orderId, String customerId, String paymentMethod, BigDecimal total);
    String nextInvoiceId();
    void saveInvoice(String invoiceId, BigDecimal total);
    void publishEvent(String eventType, String payload);
    void audit(String message);
}

Die Fake-Implementierung macht das System deterministisch:

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.*;
// Pattern: Fake - deterministische Test-Implementierung ersetzt externe Systeme ohne Mock-Rauschen.
public final class DeterministicLegacyGateway implements LegacyIntegrationGateway {
    private final Instant fixedNow;
    private final Map<String, Integer> stock = new LinkedHashMap<>();
    private final List<String> events = new ArrayList<>();
    private final List<String> audit = new ArrayList<>();
    private final Set<String> declinedPaymentMethods = new HashSet<>();
    private int invoiceSequence = 1000;
    public DeterministicLegacyGateway(Instant fixedNow) {
        this.fixedNow = fixedNow;
    }
public DeterministicLegacyGateway withStock(String sku, int quantity) {
    stock.put(sku, quantity);
    return this;
}
public DeterministicLegacyGateway declinePaymentMethod(String paymentMethod) {
    declinedPaymentMethods.add(paymentMethod);
    return this;
}
@Override public Instant now() {
    return fixedNow;
}
@Override public int availableStock(String sku) {
    return stock.getOrDefault(sku, 0);
}
@Override public boolean reserveStock(String orderId, String sku, int quantity) {
    int current = stock.getOrDefault(sku, 0);
    if (current < quantity) return false;
    stock.put(sku, current - quantity);
    audit("reserved " + quantity + " of " + sku + " for " + orderId);
    return true;
}
@Override public PaymentDecision authorizePayment(String orderId, String customerId, String paymentMethod, BigDecimal total) {
    audit("authorize payment " + paymentMethod + " total=" + total.toPlainString());
    if (declinedPaymentMethods.contains(paymentMethod)) {
        return PaymentDecision.declined("PAYMENT_PROVIDER_DECLINED");
    }
if (total.compareTo(new BigDecimal("7500.00")) > 0) {
    return PaymentDecision.declined("LIMIT_EXCEEDED");
}
return PaymentDecision.approved("TX-" + orderId + "-" + fixedNow.toString().replace(":", ""));
}
@Override public String nextInvoiceId() {
    return "INV-" + (++invoiceSequence);
}
@Override public void saveInvoice(String invoiceId, BigDecimal total) {
    audit("invoice saved " + invoiceId + " total=" + total.toPlainString());
}
@Override public void publishEvent(String eventType, String payload) {
    events.add(eventType + "|" + payload);
    audit("event published " + eventType);
}
@Override public void audit(String message) {
    audit.add(fixedNow + " " + message);
}
public List<String> events() {
    return List.copyOf(events);
}
public List<String> auditTrail() {
    return List.copyOf(audit);
}
}

Produktionshinweis: Eine Seam ist nicht automatisch schöne Architektur. Sie ist oft nur der erste sichere Schnitt. Später wird daraus vielleicht ein sauberer Port, ein Adapter, ein Repository oder ein Domain Service. In Kapitel zählt zuerst: Tests müssen ohne echte Infrastruktur laufen.

5. Szenario-Katalog: Fachsprache statt technische Testnamen

Gute Legacy-Tests heißen nicht test1, testHappyPath oder processWorks. Sie heißen nach fachlichen Situationen. Das hilft beim Lesen und später beim Refactoring.

JAVA
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
import java.util.List;
// Pattern: Test Data Builder / Object Mother - zentrale, lesbare fachliche Szenarien statt kopierter Testdaten.
public final class ScenarioCatalog {
    private ScenarioCatalog() {
    }
public static LegacyOrderRequest platinumHappyPath() {
    return new LegacyOrderRequest("ORD-1001", "C-77", "PLATINUM", "DE", "VIP20",
    true, true, false, "CARD_OK", List.of(
    new OrderLineRequest("BOOK-1", 2, new BigDecimal("29.90"), false, true, "REDUCED"),
    new OrderLineRequest("CHAIR-9", 1, new BigDecimal("149.00"), false, true, "STANDARD")));
}
public static LegacyOrderRequest insufficientStock() {
    return new LegacyOrderRequest("ORD-1002", "C-88", "STANDARD", "DE", null,
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("RARE-1", 5, new BigDecimal("12.00"), false, false, "STANDARD")));
}
public static LegacyOrderRequest invalidCoupon() {
    return new LegacyOrderRequest("ORD-1003", "C-99", "SILVER", "DE", "NOPE",
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("BOOK-1", 1, new BigDecimal("20.00"), false, true, "REDUCED")));
}
public static LegacyOrderRequest digitalOnlyEuCustomer() {
    return new LegacyOrderRequest("ORD-1004", "C-11", "GOLD", "AT", "WELCOME10",
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("EBOOK-7", 3, new BigDecimal("19.99"), true, false, "DIGITAL")));
}
public static LegacyOrderRequest paymentDeclined() {
    return new LegacyOrderRequest("ORD-1005", "C-55", "STANDARD", "DE", null,
    false, false, false, "CARD_DECLINE", List.of(
    new OrderLineRequest("CHAIR-9", 1, new BigDecimal("149.00"), false, true, "STANDARD")));
}
public static LegacyOrderRequest hazardousCrossBorder() {
    return new LegacyOrderRequest("ORD-1006", "C-12", "STANDARD", "AT", null,
    false, false, false, "CARD_OK", List.of(
    new OrderLineRequest("HZ-CLEANER", 1, new BigDecimal("9.99"), false, false, "STANDARD")));
}
}

Der Szenario-Katalog ist bewusst breit:

Diese Szenarien prüfen nicht nur Rückgabewerte. Sie prüfen auch Reihenfolge und Nebenwirkungen. Genau dort entstehen viele Legacy-Fehler.

6. Golden-Master-Strategie: Was vergleichen, was ignorieren?

Golden Master Scope

Ein schlechter Golden Master vergleicht alles: komplette Logdateien, zufällige IDs, echte Uhrzeiten, JSON-Feldreihenfolge, Stacktraces und Providertexte. Dadurch wird jeder Refactoring-Schritt zur Qual.

Ein guter Golden Master vergleicht stabile fachliche Signale. In Kapitel enthält die Snapshot-Zeile bewusst:

TEXT
success, order, invoice, subtotal, discount, shipping, tax, total, failure, events, auditSignal

Warum nicht mehr? Weil Details wie vollständige Audittexte später anders entstehen dürfen. Warum nicht weniger? Weil Beträge, Fehlercodes und Events genau die vertraglichen Punkte sind, an denen andere Systeme hängen.

Die Tests selbst sind bewusst ohne JUnit geschrieben, damit sie in dieser Umgebung JDK-only kompilieren und laufen:

JAVA
package com.example.legacy.Kapitel;
import java.util.LinkedHashMap;
import java.util.Map;
public final class LegacyCharacterizationTestRunner {
    public static void main(String[] args) {
        Map<String, String> expected = new LinkedHashMap<>();
        expected.put("platinumHappyPath", "success=true;order=ORD-1001;invoice=INV-1001;subtotal=208.80;discount=62.64;shipping=15.50;tax=17.54;total=179.20;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1001;auditSignal=stock check sku=BOOK-1 requested=2 available=20 | stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 2 of BOOK-1 for ORD-1001 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1001 | 2026-02-02T10:15:30Z authorize payment CARD_OK total=179.20 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=179.20");
        expected.put("insufficientStock", "success=false;order=ORD-1002;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=STOCK_UNAVAILABLE;events=OrderRejected:stock-unavailable:RARE-1;auditSignal=stock check sku=RARE-1 requested=5 available=1");
        expected.put("invalidCoupon", "success=false;order=ORD-1003;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=INVALID_COUPON;events=OrderRejected:invalid-coupon:NOPE;auditSignal=stock check sku=BOOK-1 requested=1 available=20");
        expected.put("digitalOnlyEuCustomer", "success=true;order=ORD-1004;invoice=INV-1001;subtotal=59.97;discount=14.20;shipping=0.00;tax=0.00;total=45.77;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1004;auditSignal=2026-02-02T10:15:30Z authorize payment CARD_OK total=45.77 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=45.77");
        expected.put("paymentDeclined", "success=false;order=ORD-1005;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=PAYMENT_DECLINED;events=OrderRejected:payment:PAYMENT_PROVIDER_DECLINED;auditSignal=stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1005 | 2026-02-02T10:15:30Z authorize payment CARD_DECLINE total=177.31");
        expected.put("hazardousCrossBorder", "success=false;order=ORD-1006;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=HAZARDOUS_CROSS_BORDER;events=OrderRejected:hazardous-cross-border;auditSignal=stock check sku=HZ-CLEANER requested=1 available=8");
        assertSnapshot("platinumHappyPath", expected, ScenarioCatalog.platinumHappyPath());
        assertSnapshot("insufficientStock", expected, ScenarioCatalog.insufficientStock());
        assertSnapshot("invalidCoupon", expected, ScenarioCatalog.invalidCoupon());
        assertSnapshot("digitalOnlyEuCustomer", expected, ScenarioCatalog.digitalOnlyEuCustomer());
        assertSnapshot("paymentDeclined", expected, ScenarioCatalog.paymentDeclined());
        assertSnapshot("hazardousCrossBorder", expected, ScenarioCatalog.hazardousCrossBorder());
        System.out.println("Kapitel");
    }
private static void assertSnapshot(String name, Map<String, String> expected, LegacyOrderRequest request) {
    DeterministicLegacyGateway gateway = LegacyTestFixture.gateway();
    LegacyOrderProcessor processor = LegacyTestFixture.processor(gateway);
    String actual = GoldenMasterSnapshot.from(processor.process(request));
    String wanted = expected.get(name);
    if (!wanted.equals(actual)) {
        throw new AssertionError("Snapshot mismatch for " + name + "\nExpected:\n" + wanted + "\nActual:\n" + actual);
    }
}
}

7. Schmerzhafte Beobachtung: Tests konservieren auch Bugs

Characterization Tests frieren Verhalten ein. Das ist Absicht, aber es hat eine Nebenwirkung: Sie konservieren auch historisches Fehlverhalten. Beispiel in der Monster-Methode:

JAVA
if (containsTaxReduced && "DE".equalsIgnoreCase(request.country())) {
    taxRate = new BigDecimal("0.12");
    // Legacy bug/behavior: mixed basket gets blended simplified rate.
    localAudit.add("legacy blended reduced tax rate 12%");
}

Ist das fachlich korrekt? Vielleicht nicht. Aber wenn Rechnungen seit Jahren so erzeugt werden, darf ein Refactoring diese Regel nicht heimlich ändern. Stattdessen braucht man eine Entscheidung:

Fall Vorgehen
Verhalten ist korrekt Snapshot bleibt
Verhalten ist Bug, aber vorerst kompatibel nötig Snapshot bleibt, Bug dokumentieren
Verhalten soll geändert werden neuer expliziter fachlicher Test, Migrationsnotiz, Release-Kommunikation

Das ist der Unterschied zwischen Refactoring und fachlicher Änderung. Refactoring soll Struktur ändern, nicht Verhalten. Fachliche Änderung muss sichtbar entschieden werden.

8. Typische Fehler bei Legacy-Characterization

Fehler 1: Nur Happy Path testen

Der Happy Path ist oft der am wenigsten riskante Teil. Die gefährlichen Stellen sind Abbruchpfade: ungültiger Coupon, Payment abgelehnt, Lagerbestand zu knapp, externe Systeme nicht erreichbar.

Fehler 2: Echte Infrastruktur im Test verwenden

Wenn der Test echten Payment, echte Uhrzeit oder echte Datenbank braucht, ist er langsam und instabil. Eine Seam oder Fake ist kein Luxus, sondern Voraussetzung.

Fehler 3: Snapshot zu breit machen

Ein Snapshot, der jeden internen Logeintrag prüft, verhindert Refactoring. Man sollte fachliche Signale prüfen, nicht Implementierungsrauschen.

Fehler 4: Alte Bugs versehentlich korrigieren

Das klingt gut, ist aber gefährlich. Wer beim Refactoring fachliche Bugs ändert, braucht Migrationsentscheidung, Datenkorrektur und Kommunikation. Sonst ist es kein Refactoring mehr.

Fehler 5: Testdaten kopieren

Wenn jedes Szenario 40 Zeilen Testdaten kopiert, versteht niemand mehr den Unterschied. Ein Szenario-Katalog hält die Fachsprache sichtbar.

9. Code-Walkthrough: Warum diese Methode schwer zu refactoren ist

Die Methode ist schwer zu refactoren, weil die Reihenfolge fachlich relevant ist:

TEXT
Validierung -> Stock Check -> Rabatt -> Versand -> Steuer -> Reservierung -> Payment -> Invoice -> Events

Eine naive Extraktion könnte zum Beispiel Payment vor Reservierung ziehen. Das sieht sauberer aus, ändert aber Verhalten. Oder sie könnte Steuerberechnung isolieren und dabei die Legacy-Sonderregel für reduzierte Steuer verlieren.

Weitere kritische Stelle:

JAVA
shipping = new BigDecimal("4.90");
if (physicalSubtotal.compareTo(new BigDecimal("100.00")) > 0) shipping = BigDecimal.ZERO;
if (request.expressShipping()) shipping = shipping.add(new BigDecimal("12.00"));
if ("FREESHIP".equalsIgnoreCase(String.valueOf(request.couponCode()))) shipping = BigDecimal.ZERO;
if (!"DE".equalsIgnoreCase(request.country())) shipping = shipping.add(new BigDecimal("9.00"));
}
if (request.giftWrap()) {
    if (!containsGiftEligible) {
        return LegacyInvoiceResult.failed(request.orderId(), "GIFT_WRAP_NOT_ALLOWED", "no gift eligible line", localEvents, localAudit);
    }
shipping = shipping.add(new BigDecimal("3.50"));
}
shipping = shipping.setScale(2, RoundingMode.HALF_UP);
BigDecimal taxableBase = subtotal.subtract(discount).max(BigDecimal.ZERO);
BigDecimal taxRate = "DE".equalsIgnoreCase(request.country()) ? new BigDecimal("0.19") : new BigDecimal("0.00");
if (containsTaxReduced && "DE".equalsIgnoreCase(request.country())) {
    taxRate = new BigDecimal("0.12");
    // Legacy bug/behavior: mixed basket gets blended simplified rate.
    localAudit.add("legacy blended reduced tax rate 12%");
}
BigDecimal tax = taxableBase.multiply(taxRate).setScale(2, RoundingMode.HALF_UP);
BigDecimal total = taxableBase.add(shipping).add(tax).setScale(2, RoundingMode.HALF_UP);
DayOfWeek dow = gateway.now().atZone(ZoneOffset.UTC).getDayOfWeek();
if (request.expressShipping() && (dow == DayOfWeek.SATURDAY || dow == DayOfWeek.SUNDAY)) {
    localAudit.add("express weekend handling fee");
    total = total.add(new BigDecimal("5.00")).setScale(2, RoundingMode.HALF_UP);
}
for (OrderLineRequest line : request.lines()) {
    if (!line.digital()) {
        boolean reserved = gateway.reserveStock(request.orderId(), line.sku(), line.quantity());
        if (!reserved) {
            localEvents.add("OrderRejected:race-stock-unavailable:" + line.sku());
            return LegacyInvoiceResult.failed(request.orderId(), "STOCK_RACE_LOST", "stock changed for " + line.sku(), localEvents, merge(localAudit, gateway));
        }
}
}
PaymentDecision payment = gateway.authorizePayment(request.orderId(), request.customerId(), request.paymentMethod(), total);
if (!payment.approved()) {
    localEvents.add("OrderRejected:payment:" + payment.reason());
    return LegacyInvoiceResult.failed(request.orderId(), "PAYMENT_DECLINED", payment.reason(), localEvents, merge(localAudit, gateway));
}
String invoiceId = gateway.nextInvoiceId();
gateway.saveInvoice(invoiceId, total);
String invoicePayload = request.orderId() + ":" + invoiceId + ":" + total.toPlainString();
gateway.publishEvent("InvoiceCreated", invoicePayload);
gateway.publishEvent("OrderAccepted", request.orderId() + ":" + payment.transactionId());
localEvents.add("InvoiceCreated:" + invoiceId);
localEvents.add("OrderAccepted:" + request.orderId());
localAudit.add("finished success invoice=" + invoiceId + " total=" + total.toPlainString());
return new LegacyInvoiceResult(true, request.orderId(), invoiceId, subtotal.setScale(2, RoundingMode.HALF_UP), discount,
shipping, tax, total, "-", "-", List.copyOf(localEvents), merge(localAudit, gateway));
}
catch (RuntimeException ex) {
    localAudit.add("technical exception " + ex.getClass().getSimpleName() + ":" + ex.getMessage());
    return LegacyInvoiceResult.failed(orderId, "TECHNICAL_FAILURE", ex.getMessage(), localEvents, merge(localAudit, gateway));
}
}
private static boolean blank(String text) {
    return text == null || text.trim().isEmpty();
}
private static List<String> merge(List<String> local, LegacyIntegrationGateway gateway) {
    List<String> merged = new ArrayList<>(local);
    if (gateway instanceof DeterministicLegacyGateway d) merged.addAll(d.auditTrail());
    return List.copyOf(merged);
}
}

Hier hängen Rabatt, Versand und Steuer zusammen. Man kann diese Logik später trennen, aber erst wenn Tests zeigen, welche Kombinationen wichtig sind. Vorher wäre jede Extraktion Spekulation.

10. Kapitel als Vorbereitung für Kapitel

Kapitel ist nicht das Ende, sondern das Sicherheitsnetz für die nächsten Vertiefungen. Kapitel kann danach die ersten Verantwortungen extrahieren:

  1. Validation Service
  2. Pricing Policy
  3. Shipping Calculator
  4. Tax Calculator
  5. Inventory Port
  6. Payment Port
  7. Invoice Service
  8. Outbox/Audit Service

Aber jede Extraktion muss die bestehenden Snapshots weiter bestehen. Sobald ein Snapshot bricht, muss man entscheiden:

Diese Disziplin macht aus chaotischem Legacy-Code eine kontrollierte Modernisierung.

11. Produktionscheckliste für Legacy-Refactoring

Vor dem ersten produktionsnahen Refactoring sollten diese Punkte erfüllt sein:

TEXT
[ ] wichtigste fachliche Szenarien benannt
[ ] externe Abhängigkeiten kontrollierbar
[ ] Zeit deterministisch
[ ] Payment und Lager fakebar
[ ] Golden-Master-Snapshots genehmigt
[ ] bekannte Bugs dokumentiert
[ ] Logik mit Nebenwirkungen markiert
[ ] Codepfade mit Payment, Invoice, Event und Audit getestet
[ ] Refactoring-Schritte klein genug
[ ] Rollback-Plan vorhanden

Die wichtigste Regel lautet: Kein großes Refactoring ohne schnelle lokale Tests. Wenn Tests mehrere Minuten dauern oder Infrastruktur brauchen, werden Entwickler sie nicht nach jedem kleinen Schritt ausführen.

12. Nichtdeterminismus: der unsichtbare Feind von Legacy-Tests

Nichtdeterminismus Matrix

Legacy-Code ist selten nur wegen langer Methoden schwer zu testen. Oft ist er schwer zu testen, weil sein Ergebnis von unsichtbaren Dingen abhängt: aktueller Uhrzeit, zufälligen IDs, externer Payment-Antwort, Datenbankzustand, Message-Broker-Verfügbarkeit oder sogar Reihenfolge von Logeinträgen.

Ein Characterization Test muss diese Quellen kontrollieren. Sonst schlägt der Test nicht fehl, weil Verhalten falsch ist, sondern weil die Umgebung anders war. Kapitel kontrolliert deshalb Zeit, Lager, Payment, Invoice-Sequenz, Events und Audit über DeterministicLegacyGateway.

Typische Nichtdeterminismus-Quellen

Quelle Beispiel Gegenmaßnahme
Uhrzeit Wochenende erzeugt Zusatzgebühr feste Clock / Gateway
Sequenz Rechnungsnummer ändert sich deterministische Sequenz
Payment Provider lehnt zufällig ab Fake mit festen Regeln
Lager anderer Test verändert Bestand frisches Fixture pro Szenario
Events Broker sendet asynchron lokale Eventliste im Fake
Logging Reihenfolge oder Threadnamen ändern sich nur fachliche Audit-Signale vergleichen

Wichtig ist: Man faked nicht, um sich die Welt schönzureden. Man faked, um das beobachtbare Verhalten reproduzierbar zu machen.

13. Nebenwirkungen sichtbar machen: warum Rückgabewerte nicht reichen

Side Effect Timeline

Viele einfache Tests prüfen nur den Rückgabewert. Bei Enterprise-Legacy reicht das nicht. Eine Bestellung kann scheinbar korrekt fehlschlagen und trotzdem vorher Lager reserviert haben. Oder eine Rechnung kann gespeichert sein, aber das Event fehlt. Genau deshalb prüft Kapitel ausgewählte Nebenwirkungen.

In paymentDeclined ist das Verhalten bewusst unangenehm: Lager wird reserviert, danach lehnt Payment ab. Ob das fachlich gut ist, ist eine spätere Entscheidung. Für Kapitel ist wichtig, dass dieses Verhalten sichtbar wird.

JAVA
expected.put("insufficientStock", "success=false;order=ORD-1002;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=STOCK_UNAVAILABLE;events=OrderRejected:stock-unavailable:RARE-1;auditSignal=stock check sku=RARE-1 requested=5 available=1");
expected.put("invalidCoupon", "success=false;order=ORD-1003;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=INVALID_COUPON;events=OrderRejected:invalid-coupon:NOPE;auditSignal=stock check sku=BOOK-1 requested=1 available=20");
expected.put("digitalOnlyEuCustomer", "success=true;order=ORD-1004;invoice=INV-1001;subtotal=59.97;discount=14.20;shipping=0.00;tax=0.00;total=45.77;failure=-;events=InvoiceCreated:INV-1001,OrderAccepted:ORD-1004;auditSignal=2026-02-02T10:15:30Z authorize payment CARD_OK total=45.77 | 2026-02-02T10:15:30Z invoice saved INV-1001 total=45.77");
expected.put("paymentDeclined", "success=false;order=ORD-1005;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=PAYMENT_DECLINED;events=OrderRejected:payment:PAYMENT_PROVIDER_DECLINED;auditSignal=stock check sku=CHAIR-9 requested=1 available=4 | 2026-02-02T10:15:30Z reserved 1 of CHAIR-9 for ORD-1005 | 2026-02-02T10:15:30Z authorize payment CARD_DECLINE total=177.31");
expected.put("hazardousCrossBorder", "success=false;order=ORD-1006;invoice=-;subtotal=0;discount=0;shipping=0;tax=0;total=0;failure=HAZARDOUS_CROSS_BORDER;events=OrderRejected:hazardous-cross-border;auditSignal=stock check sku=HZ-CLEANER requested=1 available=8");
assertSnapshot("platinumHappyPath", expected, ScenarioCatalog.platinumHappyPath());
assertSnapshot("insufficientStock", expected, ScenarioCatalog.insufficientStock());
assertSnapshot("invalidCoupon", expected, ScenarioCatalog.invalidCoupon());
assertSnapshot("digitalOnlyEuCustomer", expected, ScenarioCatalog.digitalOnlyEuCustomer());
assertSnapshot("paymentDeclined", expected, ScenarioCatalog.paymentDeclined());
assertSnapshot("hazardousCrossBorder", expected, ScenarioCatalog.hazardousCrossBorder());
System.out.println("Kapitel");
}

Diese Erwartung zeigt nicht nur PAYMENT_DECLINED, sondern auch die vorherige Lagerreservierung. Ohne diese Information könnte Kapitel die Reihenfolge ändern, Tests würden grün bleiben, aber Produktion hätte anderes Verhalten.

14. Snapshot Governance: wann darf ein Golden Master geändert werden?

Snapshot Governance

Ein Golden Master ist mächtig und gefährlich. Wer Snapshots leichtfertig aktualisiert, verliert den Schutz. Wer sie nie aktualisiert, blockiert notwendige Änderungen. Deshalb braucht jedes Team eine einfache Governance.

Entscheidungsbaum

TEXT
Snapshot bricht
  |
  +-- war der Test zu eng? -> Snapshot fachlich stabiler machen
  |
  +-- war es unbeabsichtigter Refactoring-Fehler? -> Code korrigieren
  |
  +-- war es gewünschte fachliche Änderung? -> neue Erwartung, Release Note, ggf. Datenmigration

Bei Legacy-Systemen ist diese Trennung besonders wichtig. Ein Entwickler sieht vielleicht einen offensichtlichen Bug. Aber wenn externe Partner, Buchhaltung oder Reporting seit Jahren davon abhängen, ist die Korrektur kein kleines Refactoring mehr.

15. Refactoring-Risikoleiter: nicht jeder Schritt ist gleich gefährlich

Refactoring Risk Ladder

Ein häufiger Fehler ist, jedes Refactoring gleich zu behandeln. Eine lokale Variablenumbenennung ist nicht so riskant wie eine neue Transaktionsgrenze. Eine Methode ohne Seiteneffekt zu extrahieren ist harmloser als Payment und Lager in anderer Reihenfolge aufzurufen.

Kapitel schafft die Grundlage, damit Kapitel mit niedrigen Risikostufen beginnt:

  1. Namen verbessern.
  2. lokale Hilfsmethoden ohne Seiteneffekt extrahieren.
  3. Guard Clauses ordnen.
  4. reine Berechnungen isolieren.
  5. erst später externe Effekte trennen.

Rote Linie

Sobald ein Schritt Nebenwirkungsreihenfolge, Fehlercode, Rundung, Eventtyp oder Transaktionsgrenze verändert, ist er kein rein mechanischer Schritt mehr. Dann braucht man Review, Testergänzung und oft fachliche Zustimmung.

16. Übungsaufgaben für Kapitel

Diese Aufgaben sind bewusst praxisnah formuliert. Sie sollen nicht nur Wissen abfragen, sondern dich zwingen, das Sicherheitsnetz zu benutzen.

Aufgabe 1: Neues Szenario ergänzen

Ergänze ein Szenario für FREESHIP mit physischer Bestellung über 100 EUR. Prüfe, ob Versand wirklich 0 EUR bleibt und ob die Steuer korrekt berechnet wird.

Aufgabe 2: Snapshot enger oder weiter?

Entscheide, ob der Snapshot die Rechnungsnummer enthalten soll. Argumentiere aus Sicht von Refactoring-Sicherheit und Teststabilität.

Aufgabe 3: Bug dokumentieren

Die gemischte Steuerberechnung nutzt einen vereinfachten Satz. Dokumentiere, ob du das als Bug, Legacy-Vertrag oder offene Fachfrage einstufst.

Aufgabe 4: Eine sichere Extraktion vorbereiten

Markiere im Code einen Block, der ohne Nebenwirkung extrahiert werden kann. Führe noch keine Architekturänderung durch. Beschreibe, warum dieser Block sicher ist.

Aufgabe 5: Eine gefährliche Extraktion erkennen

Warum ist es gefährlich, gateway.authorizePayment(...) vor die Lagerreservierung zu verschieben? Beschreibe mindestens drei mögliche Produktionsfolgen.

17. Was Kapitel bewusst noch nicht löst

Kapitel macht Verhalten sichtbar. Er löst noch nicht die Architektur. Das ist Absicht.

Noch nicht gelöst:

Warum nicht? Weil ein großer Umbau ohne Sicherheitsnetz genau das Risiko ist, das Kapitel vermeiden soll. Kapitel darf schneiden, weil Kapitel vorher beobachtbares Verhalten abgesichert hat.

18. Code-Anhang kurz: wichtigste Dateien

Der vollständige Code liegt im ZIP unter code/legacy-characterization-deep-dive-lab/.

Datei Zweck
LegacyOrderProcessor.java Monster-Methode als Ausgangspunkt
LegacyIntegrationGateway.java Seam für externe Effekte
DeterministicLegacyGateway.java Fake für Zeit, Lager, Payment, Invoice, Events
ScenarioCatalog.java fachliche Testdaten
GoldenMasterSnapshot.java stabile Verhaltenssignatur
LegacyCharacterizationTestRunner.java JDK-only Characterization Tests
docs/design-patterns.html Pattern-Dokumentation
docs/refactoring-strategy.html Strategie für Kapitel
⌂ Cockpit