Das nimmst du mit
- Beobachtbares Verhalten sichern
- Seams finden
- Golden Master begrenzen
- Risiken priorisieren
Kapitelkompass
Characterization Tests beschreiben das tatsächliche Verhalten eines Legacy-Systems, auch wenn es fachlich unbequem ist.
Ein alter Order-Prozessor mischt Preis, Steuer, Zahlung und Seiteneffekte in einer Methode.
Zuerst kritische Pfade und Seiteneffekte charakterisieren, nicht jede Zeile konservieren.
Ein Test auf interne Aufrufreihenfolge friert Struktur statt fachliches Verhalten ein.
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.
extract method und extract class
anwenden.Legacy-Code -> Characterization Tests -> kleine Refactoring-Schritte -> gleiche Tests -> Architektur verbessern
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:
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.
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:
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.
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:
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:
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.
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.
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.
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:
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:
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);
}
}
}
Characterization Tests frieren Verhalten ein. Das ist Absicht, aber es hat eine Nebenwirkung: Sie konservieren auch historisches Fehlverhalten. Beispiel in der Monster-Methode:
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.
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.
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.
Ein Snapshot, der jeden internen Logeintrag prüft, verhindert Refactoring. Man sollte fachliche Signale prüfen, nicht Implementierungsrauschen.
Das klingt gut, ist aber gefährlich. Wer beim Refactoring fachliche Bugs ändert, braucht Migrationsentscheidung, Datenkorrektur und Kommunikation. Sonst ist es kein Refactoring mehr.
Wenn jedes Szenario 40 Zeilen Testdaten kopiert, versteht niemand mehr den Unterschied. Ein Szenario-Katalog hält die Fachsprache sichtbar.
Die Methode ist schwer zu refactoren, weil die Reihenfolge fachlich relevant ist:
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:
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.
Kapitel ist nicht das Ende, sondern das Sicherheitsnetz für die nächsten Vertiefungen. Kapitel kann danach die ersten Verantwortungen extrahieren:
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.
Vor dem ersten produktionsnahen Refactoring sollten diese Punkte erfüllt sein:
[ ] 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.
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.
| 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.
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.
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.
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.
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.
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:
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.
Diese Aufgaben sind bewusst praxisnah formuliert. Sie sollen nicht nur Wissen abfragen, sondern dich zwingen, das Sicherheitsnetz zu benutzen.
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.
Entscheide, ob der Snapshot die Rechnungsnummer enthalten soll. Argumentiere aus Sicht von Refactoring-Sicherheit und Teststabilität.
Die gemischte Steuerberechnung nutzt einen vereinfachten Satz. Dokumentiere, ob du das als Bug, Legacy-Vertrag oder offene Fachfrage einstufst.
Markiere im Code einen Block, der ohne Nebenwirkung extrahiert werden kann. Führe noch keine Architekturänderung durch. Beschreibe, warum dieser Block sicher ist.
Warum ist es gefährlich, gateway.authorizePayment(...)
vor die Lagerreservierung zu verschieben? Beschreibe mindestens drei
mögliche Produktionsfolgen.
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.
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 |