Das nimmst du mit
- Verantwortungen benennen
- Pure Logic extrahieren
- Parity vergleichen
- Grenzen absichern
Kapitelkompass
Sichere Extraktion trennt fachliche Entscheidungen von Orchestrierung und Infrastruktur, ohne alles gleichzeitig neu zu bauen.
Pricing, Tax und Shipping werden aus einem Legacy-Prozessor in eigene Dienste verschoben.
Mit deterministischer Logik beginnen; I/O und Transaktionsgrenzen später verschieben.
Eine neue Service-Klasse ohne eigene Verantwortung ist nur der alte Monolith in mehr Dateien.
Legacy-Refactoring-Vertiefung: Kapitel.
Kapitel hat das Sicherheitsnetz aufgebaut: Golden Master, Characterization Tests, Fakes und Szenario-Katalog. Kapitel beginnt jetzt mit dem eigentlichen Schneiden der Monster-Methode. Der wichtige Punkt: Refactoring heisst hier nicht, Code hübscher zu formatieren. Refactoring bedeutet, fachliche Entscheidungen aus einem riskanten Block herauszulösen, ohne das beobachtete Verhalten unkontrolliert zu verändern.
In Enterprise-Systemen sind Monster-Methoden oft nicht deshalb
gefährlich, weil sie lang sind. Sie sind gefährlich, weil sie mehrere
Arten von Wissen vermischen: fachliche Vorbedingungen, Preisregeln,
Steuerlogik, technische Integrationen, Audit, Logging, Transaktionen und
Fehlerbehandlung. Wer dort nur extract method ausführt,
erzeugt kleinere Methoden, aber nicht automatisch bessere
Architektur.
Das Ziel von Kapitel ist deshalb: Verantwortung sichtbar machen. Validierung, Preisfindung, Versand, Steuer, Payment, Invoice und Audit werden getrennt. Jede Verantwortung bekommt eigene Namen, eigene Tests und eigene fachliche Grenzen.
Legacy-Methode
-> Validierung extrahieren
-> Preisregeln extrahieren
-> Versand extrahieren
-> Steuer extrahieren
-> Nebenwirkungen hinter Ports legen
-> neuen Application Service parallel betreiben
-> Parity Tests gegen Legacy-Verhalten laufen lassen
Die wichtigste Lernregel lautet: Nicht zuerst abstrahieren. Zuerst beobachten, benennen, testen, dann schneiden.
Eine Legacy-Methode hat oft eine scheinbar technische Form:
processOrder, saveOrder,
createInvoice, handleRequest. Beim Lesen darf
man sich nicht von Variablen, if-Blöcken und API-Aufrufen hypnotisieren
lassen. Man muss fragen: Welche fachliche Entscheidung wird hier gerade
getroffen?
Beispiel aus dem Lab:
if (request.segment() == CustomerSegment.BLOCKED) return "REJECTED|CUSTOMER_BLOCKED";
if (request.lines().isEmpty()) return "REJECTED|EMPTY_ORDER";
Das ist keine technische Prüfung. Das ist eine fachliche Vorbedingung: Ein gesperrter Kunde darf nicht bestellen, und eine Bestellung ohne Positionen ist kein gültiger Auftrag. Diese Regeln gehören in eine Validierung, nicht in die Preisberechnung und nicht neben Payment-Code.
Später im Legacy-Code stehen Versandregeln:
if (afterDiscount.amount().compareTo(new BigDecimal("250.00")) >= 0 && request.shippingCountry() != Country.US) shipping = Money.eur("0");
if (weight > 10_000) shipping = shipping.add(Money.eur("12.00"));
Auch das ist kein technisches Detail. Es ist fachliche Logistik. Wenn diese Logik im selben Block wie Rabatt, Steuer und Payment steht, kann niemand sicher ändern, ohne unabsichtlich Seiteneffekte auszulösen.
Eine gute Refactoring-Analyse markiert deshalb Zeile für Zeile:
| Abschnitt | Fachliche Verantwortung | Gefahr bei Änderung |
|---|---|---|
| Kunde gesperrt | Validierung | falsche Ablehnung/Annahme |
| Rabatt berechnen | Pricing | falsche Rechnung |
| Versand berechnen | Shipping | Marge oder Kundenerlebnis falsch |
| Steuer berechnen | Tax | Compliance-Risiko |
| Payment autorisieren | externe Nebenwirkung | Doppelbuchung oder falscher Status |
| Rechnung erzeugen | externe Nebenwirkung | doppelte Rechnung |
| Audit schreiben | Nachvollziehbarkeit | fehlende Beweisbarkeit |
Validierung ist oft der erste gute Schnitt. Sie ist relativ nah an der Eingabe, meist gut testbar und sollte keine externen Nebenwirkungen haben. Trotzdem muss man vorsichtig sein: Viele Legacy-Systeme brechen beim ersten Fehler ab. Moderne Validierung sammelt oft mehrere Fehler. Das ist fachlich besser, aber nicht immer verhaltensgleich.
Darum trennt Kapitel zwei Fragen:
Im Code sammelt OrderValidationService mehrere
Violations. Für die Parity Tests wird zunächst nur geprüft, ob die
fachliche Entscheidung akzeptiert/abgelehnt gleich bleibt. Das ist
bewusst: Eine 1:1-String-Gleichheit wäre am Anfang zu streng und würde
das Refactoring blockieren.
ValidationResult validation = validationService.validate(request);
if (!validation.ok()) {
auditPort.record(request.orderId(), "ORDER_REJECTED", validation.violations().toString());
return OrderProcessingResult.rejected(request.orderId(), validation.violations());
}
Der Vorteil ist groß: Die fachliche Prüfung ist isoliert und testbar. Sie braucht keine Datenbank, kein Payment, keine Rechnung und keinen Broker. Dadurch wird die erste Verantwortung aus der Monster-Methode herausgelöst, ohne gleich die ganze Anwendung umzubauen.
Typische Fehler bei diesem Schritt:
Preislogik wächst in echten Systemen fast immer: VIP-Rabatt, B2B-Volumenrabatt, Gutscheine, Kampagnen, Channel-Regeln, Partnerpreise, Steuerbasis, Rundung, Währungen. Wenn das alles in einer Methode bleibt, wird jede neue Regel gefährlich.
Kapitel verwendet deshalb DiscountPolicy als
Strategy:
public interface DiscountPolicy {
Money discountFor(PricingContext context);
String name();
}
Die einzelnen Regeln sind danach klein und gezielt testbar:
public final class VipDiscountPolicy implements DiscountPolicy {
@Override public Money discountFor(PricingContext context) {
if (context.segment() != CustomerSegment.VIP) return Money.eur("0");
return context.subtotal().multiply(new BigDecimal("0.10"));
}
}
Das ist nicht nur ein Pattern um des Patterns willen. Die Strategy hilft, fachliche Änderungsgründe zu trennen. Eine neue Gutscheinregel ändert nicht die B2B-Regel. Eine neue VIP-Regel ändert nicht die Steuerlogik. In einer Code Review kann man eine Regel verstehen, ohne die ganze Order-Verarbeitung zu lesen.
Wichtig ist aber: Strategy kann auch übertrieben werden. Nicht jede if-Anweisung braucht ein eigenes Objekt. Der sinnvolle Einsatz beginnt dort, wo Regeln unabhängig wachsen, separat getestet werden müssen oder von unterschiedlichen Fachbereichen verantwortet werden.
Versand und Steuer sind typische Beispiele für Logik, die viele Eingaben braucht, aber nicht gut in ein einzelnes Entity-Objekt passt. Versand hängt an Land, Gewicht, Warenwert, digital/physisch und manchmal Kundensegment. Steuer hängt an Land, VAT-ID, B2B/B2C, Produktart und Compliance-Regeln.
Darum sind ShippingCalculator und
TaxCalculator als Domain Services modelliert. Sie sind
keine technischen Services. Sie kapseln fachliche Regeln.
public Money calculate(OrderRequest request, Money afterDiscount) {
int weight = request.lines().stream().mapToInt(OrderLine::totalWeight).sum();
boolean onlyDigital = request.lines().stream().allMatch(OrderLine::digital);
if (onlyDigital) return Money.eur("0");
...
}
Der Deep-Dive-Punkt ist: Eine Domain Service Klasse darf nicht zum
neuen Sammelbecken werden. Sie braucht einen klaren Grund.
ShippingCalculator darf Versand berechnen, aber nicht
Payment aufrufen. TaxCalculator darf Steuer berechnen, aber
nicht Rechnung speichern.
Produktionshinweis: Steuerlogik muss in echten Systemen versioniert werden. Eine Rechnung von 2024 darf nicht automatisch mit Regeln von 2026 neu interpretiert werden. Im Lab ist das vereinfacht, aber im Buch wird die Grenze sichtbar gemacht.
Die Monster-Methode mischt Berechnung und Nebenwirkung. Das ist das eigentliche Risiko. Eine Preisberechnung kann man beliebig oft ausführen. Eine Payment-Autorisierung nicht. Eine Rechnungserzeugung vielleicht auch nicht. Audit muss zuverlässig passieren, aber soll den Kern nicht dominieren.
Darum werden externe Aktionen hinter Ports gelegt:
public interface PaymentPort {
PaymentDecision authorize(String orderId, String customerId, Money amount);
}
Der neue Application Service kennt nur den Port. Für Tests gibt es
FakePaymentAdapter, FakeInvoiceAdapter und
CollectingAuditAdapter. Damit kann man Verhalten prüfen,
ohne echte Systeme zu berühren.
Das ist ein wichtiger Refactoring-Schritt: Man verbessert nicht sofort die Infrastruktur. Man trennt zuerst den Kern von der Infrastruktur. Später kann ein Spring Adapter, ein Jakarta Adapter oder ein Kafka Adapter folgen. Der Kern bleibt gleich.
Anti-Pattern: Direkt nach dem Extrahieren echte REST-Clients oder Repositorys in die neuen Services einbauen. Dann hat man nur die Monster-Methode verteilt, aber nicht entkoppelt.
Der RefactoredOrderApplicationService ist absichtlich
kein Ort fuer Detailregeln. Er orchestriert. Er sagt: validieren, Preis
berechnen, Payment autorisieren, Invoice erzeugen, Audit schreiben.
Dadurch wird der Use Case lesbar.
ValidationResult validation = validationService.validate(request);
PriceBreakdown price = pricingEngine.calculate(request);
PaymentDecision payment = paymentPort.authorize(request.orderId(), request.customerId(), price.total());
String invoiceId = invoicePort.createInvoice(request.orderId(), price);
Guter Application-Service-Code liest sich fast wie ein fachlicher Ablauf. Schlechter Application-Service-Code enthält wieder alle Details. Die Grenze ist einfach: Wenn der Application Service weiß, wie Steuer in Deutschland berechnet wird, ist er zu tief. Wenn er weiß, dass nach erfolgreichem Payment eine Rechnung erzeugt wird, ist das richtig.
Dieses Muster ist besonders hilfreich beim späteren Umbau auf Spring Boot, Jakarta EE, Quarkus oder Micronaut. Der Framework-Controller ruft nur den Application Service auf. Der Kern bleibt frameworkarm.
Ein gefährliches Missverständnis lautet: Wenn der neue Code sauberer aussieht, ist er richtig. Das stimmt nicht. Beim Refactoring ist richtig, was für definierte Szenarien das gleiche fachliche Verhalten zeigt oder bewusst dokumentiert abweicht.
Kapitel nutzt ParitySnapshot:
public record ParitySnapshot(String scenarioId, String legacyResult, String refactoredResult) {
public boolean equivalentEnough() {
boolean legacyAccepted = legacyResult.startsWith("ACCEPTED");
boolean refAccepted = refactoredResult.startsWith("true|");
if (legacyAccepted != refAccepted) return false;
if (legacyAccepted) return legacyResult.contains(scenarioId) && refactoredResult.contains(scenarioId);
return true;
}
}
Das ist bewusst nicht perfekt. In frühen Refactoring-Phasen ist eine weiche Äquivalenz oft sinnvoller als absolute Gleichheit. Absolute Gleichheit kann man später einführen, wenn Ausgaben normalisiert sind. Anfangs geht es um fachliche Entscheidungen: akzeptiert oder abgelehnt, richtige Order, richtige Nebenwirkungen.
Diese Tests sind der Brückenmechanismus zwischen Legacy-Sicherheit und neuer Architektur.
Refactoring scheitert oft nicht beim ersten Schnitt, sondern beim Rückfall. Ein Entwickler braucht schnell eine Information und greift wieder direkt auf die Legacy-Klasse zu. Nach einigen Wochen hängt der neue Code wieder am alten Kern.
Darum enthält das Lab einen kleinen Architekturcheck:
long forbidden = Files.walk(root)
.filter(p -> p.toString().endsWith(".java"))
.filter(p -> !p.getFileName().toString().contains("LegacyOrderProcessor"))
.filter(p -> Files.readString(p).contains("new LegacyOrderProcessor"))
.count();
In echten Projekten würde man dafür ArchUnit oder ähnliche Werkzeuge nutzen. Im JDK-only-Lab reicht ein einfacher Check, um das Prinzip zu zeigen: Architektur ist nicht nur ein Bild. Architektur braucht ausführbare Regeln.
Wichtige Regeln für diesen Refactoring-Schritt:
Übungen:
BlackFridayDiscountPolicy
hinzu, ohne PricingEngine grundlegend umzubauen.TaxCalculator oder in eine Strategy
gehört.ShippingCalculator keinen PaymentPort
importieren darf.Produktionscheckliste:
Kapitel ist damit nicht der Abschluss des Refactorings. Er ist der Schritt, in dem aus einer Monster-Methode ein lesbarer, testbarer Use Case wird. Kapitel kann darauf aufbauen und die Extraktion weiterführen: schrittweises Vorher/Nachher-Refactoring, Method Object, Feature Flags und Migrationspfad.
package com.example.legacy.Kapitel;
import java.math.BigDecimal;
// Absichtlich unangenehmer Legacy-Code: mehrere Verantwortungen, Seiteneffekte und fachliche Regeln in einer Methode.
public final class LegacyOrderProcessor {
public String process(OrderRequest request) {
if (request.segment() == CustomerSegment.BLOCKED) return "REJECTED|CUSTOMER_BLOCKED";
if (request.lines().isEmpty()) return "REJECTED|EMPTY_ORDER";
if (request.segment() == CustomerSegment.B2B && request.shippingCountry() != Country.US && (request.vatId() == null || request.vatId().isBlank())) {
return "REJECTED|VAT_ID_REQUIRED";
}
Money subtotal = Money.eur("0");
int weight = 0;
boolean onlyDigital = true;
for (OrderLine line : request.lines()) {
if (line.quantity() > 100) return "REJECTED|LINE_QTY_LIMIT";
subtotal = subtotal.add(line.lineTotal());
weight += line.totalWeight();
onlyDigital = onlyDigital && line.digital();
}
Money discount = Money.eur("0");
if (request.segment() == CustomerSegment.VIP) discount = discount.add(subtotal.multiply(new BigDecimal("0.10")));
if (request.couponCode() != null && request.channel() != OrderChannel.PARTNER_PORTAL) {
if (request.couponCode().equals("SAVE5")) discount = discount.add(Money.eur("5"));
if (request.couponCode().equals("WELCOME15")) discount = discount.add(subtotal.multiply(new BigDecimal("0.15")));
}
if (request.segment() == CustomerSegment.B2B && subtotal.amount().compareTo(new BigDecimal("1000.00")) >= 0) {
discount = discount.add(subtotal.multiply(new BigDecimal("0.07")));
}
if (discount.greaterThan(subtotal)) discount = subtotal;
Money afterDiscount = subtotal.subtract(discount);
Money shipping = Money.eur("0");
if (!onlyDigital) {
shipping = switch (request.shippingCountry()) {
case AT, DE -> Money.eur("6.90");
case CH -> Money.eur("14.90");
case US -> Money.eur("29.90");
};
if (afterDiscount.amount().compareTo(new BigDecimal("250.00")) >= 0 && request.shippingCountry() != Country.US) shipping = Money.eur("0");
if (weight > 10_000) shipping = shipping.add(Money.eur("12.00"));
}
Money taxableBase = afterDiscount.add(shipping);
Money tax;
if (request.segment() == CustomerSegment.B2B && request.vatId() != null && request.shippingCountry() != Country.AT) tax = Money.eur("0");
else {
BigDecimal rate = switch (request.shippingCountry()) {
case AT -> new BigDecimal("0.20");
case DE -> new BigDecimal("0.19");
case CH -> new BigDecimal("0.081");
case US -> BigDecimal.ZERO;
};
tax = taxableBase.multiply(rate);
}
Money total = taxableBase.add(tax);
if (total.amount().compareTo(new BigDecimal("2000.00")) > 0) return "REJECTED|LIMIT_EXCEEDED|" + total;
return "ACCEPTED|" + request.orderId() + "|" + total + "|INV-" + request.orderId();
}
}
package com.example.legacy.Kapitel;
// Pattern: Service Layer - kapselt fachliche Validierung ausserhalb der Monster-Methode.
public final class OrderValidationService {
public ValidationResult validate(OrderRequest request) {
ValidationResult result = new ValidationResult();
if (request.segment() == CustomerSegment.BLOCKED) result.add("CUSTOMER_BLOCKED", "blocked customers cannot order");
if (request.lines().isEmpty()) result.add("EMPTY_ORDER", "order must contain at least one line");
if (request.lines().size() > 50) result.add("TOO_MANY_LINES", "manual review required for more than 50 lines");
if (request.channel() == OrderChannel.PARTNER_PORTAL && request.couponCode() != null) {
result.add("PARTNER_COUPON_NOT_ALLOWED", "partner orders cannot use retail coupons");
}
if (request.segment() == CustomerSegment.B2B && request.shippingCountry() != Country.US && blank(request.vatId())) {
result.add("VAT_ID_REQUIRED", "B2B orders in EU/CH require a VAT id");
}
for (OrderLine line : request.lines()) {
if (line.quantity() > 100) result.add("LINE_QTY_LIMIT", "line quantity too high for " + line.sku());
if (!line.unitPrice().isPositive()) result.add("PRICE_NOT_POSITIVE", "unit price must be positive for " + line.sku());
}
return result;
}
private static boolean blank(String s) {
return s == null || s.isBlank();
}
}
package com.example.legacy.Kapitel;
import java.util.List;
// Pattern: Composite Strategy - kombiniert mehrere Rabattstrategien kontrolliert.
public final class PricingEngine {
private final List<DiscountPolicy> policies;
private final ShippingCalculator shippingCalculator;
private final TaxCalculator taxCalculator;
public PricingEngine(List<DiscountPolicy> policies, ShippingCalculator shippingCalculator, TaxCalculator taxCalculator) {
this.policies = List.copyOf(policies);
this.shippingCalculator = shippingCalculator;
this.taxCalculator = taxCalculator;
}
public PriceBreakdown calculate(OrderRequest request) {
Money subtotal = request.lines().stream().map(OrderLine::lineTotal).reduce(Money.eur("0"), Money::add);
PricingContext context = new PricingContext(request.segment(), request.channel(), request.shippingCountry(), request.couponCode(), subtotal);
Money discount = policies.stream().map(p -> p.discountFor(context)).reduce(Money.eur("0"), Money::add);
if (discount.greaterThan(subtotal)) discount = subtotal;
Money shipping = shippingCalculator.calculate(request, subtotal.subtract(discount));
Money taxableBase = subtotal.subtract(discount).add(shipping);
Money tax = taxCalculator.calculate(request, taxableBase);
Money total = taxableBase.add(tax);
return new PriceBreakdown(subtotal, discount, shipping, tax, total);
}
}
package com.example.legacy.Kapitel;
// Pattern: Facade/Application Service - orchestriert extrahierte Verantwortungen ohne fachliche Details zu mischen.
public final class RefactoredOrderApplicationService {
private final OrderValidationService validationService;
private final PricingEngine pricingEngine;
private final PaymentPort paymentPort;
private final InvoicePort invoicePort;
private final AuditPort auditPort;
public RefactoredOrderApplicationService(OrderValidationService validationService, PricingEngine pricingEngine, PaymentPort paymentPort, InvoicePort invoicePort, AuditPort auditPort) {
this.validationService = validationService;
this.pricingEngine = pricingEngine;
this.paymentPort = paymentPort;
this.invoicePort = invoicePort;
this.auditPort = auditPort;
}
public OrderProcessingResult process(OrderRequest request) {
ValidationResult validation = validationService.validate(request);
if (!validation.ok()) {
auditPort.record(request.orderId(), "ORDER_REJECTED", validation.violations().toString());
return OrderProcessingResult.rejected(request.orderId(), validation.violations());
}
PriceBreakdown price = pricingEngine.calculate(request);
PaymentDecision payment = paymentPort.authorize(request.orderId(), request.customerId(), price.total());
if (!payment.approved()) {
auditPort.record(request.orderId(), "PAYMENT_DECLINED", payment.reason());
return OrderProcessingResult.paymentDeclined(request.orderId(), price, payment.reason());
}
String invoiceId = invoicePort.createInvoice(request.orderId(), price);
auditPort.record(request.orderId(), "ORDER_ACCEPTED", invoiceId + " " + price.compact());
return OrderProcessingResult.accepted(request.orderId(), price, invoiceId);
}
}
package com.example.legacy.Kapitel;
import java.util.ArrayList;
import java.util.List;
public final class ResponsibilityRefactoringTestRunner {
public static void main(String[] args) {
LegacyOrderProcessor legacy = new LegacyOrderProcessor();
List<ParitySnapshot> snapshots = new ArrayList<>();
for (OrderRequest request : OrderScenarioCatalog.scenarios()) {
FakeInvoiceAdapter invoice = new FakeInvoiceAdapter();
CollectingAuditAdapter audit = new CollectingAuditAdapter();
RefactoredOrderApplicationService service = RefactoringFactory.create(invoice, audit);
String legacyResult = legacy.process(request);
String refactored = service.process(request).compact();
ParitySnapshot snapshot = new ParitySnapshot(request.orderId(), legacyResult, refactored);
snapshots.add(snapshot);
if (!snapshot.equivalentEnough()) throw new AssertionError("parity failed: " + snapshot);
}
testValidationService();
testPricingBreakdown();
testShippingAndTax();
System.out.println("Kapitel snapshots=" + snapshots.size());
}
private static void testValidationService() {
ValidationResult result = new OrderValidationService().validate(OrderScenarioCatalog.blockedCustomer());
if (result.ok()) throw new AssertionError("blocked customer must be rejected");
}
private static void testPricingBreakdown() {
PricingEngine engine = new PricingEngine(java.util.List.of(new VipDiscountPolicy(), new CouponDiscountPolicy(), new B2BVolumeDiscountPolicy()), new ShippingCalculator(), new TaxCalculator());
PriceBreakdown price = engine.calculate(OrderScenarioCatalog.webVipWithCoupon());
if (!price.discount().isPositive()) throw new AssertionError("VIP/coupon discount expected");
if (!price.total().isPositive()) throw new AssertionError("total expected");
}
private static void testShippingAndTax() {
PricingEngine engine = new PricingEngine(java.util.List.of(), new ShippingCalculator(), new TaxCalculator());
PriceBreakdown price = engine.calculate(OrderScenarioCatalog.heavySwissShipment());
if (!price.tax().isPositive()) throw new AssertionError("CH tax expected");
if (!price.total().isPositive()) throw new AssertionError("CH total expected");
}
}