Legacy-Extraktions-Lab

34 Java-Dateien. 8 zentrale Dateien werden direkt mit echtem Quellcode und ihrem Zusammenspiel erklärt.

Zurück zu Code-Labs

Kapitel 12 · Legacy-Extraktion

Was dieses Lab zeigt

Zerlegt einen überladenen Legacy-Prozessor in fachliche Verantwortungen. Ports, Policies, Workflow-Schritte und Tests machen Abhängigkeiten sichtbar und austauschbar.

Lernziele

  • Verantwortungen schneiden
  • Ports aus Legacy-Code extrahieren
  • Schrittweise refaktorieren

Technik und Schwerpunkte

Java 2134 Java-Dateien1 Tests/RunnerRefactoringPorts & AdaptersResponsibilities
Echter Quellcode aus diesem Lab

Geführter Codepfad

Das Verantwortungs-Lab zeigt denselben Bestellfall vor und nach der Extraktion. Validierung, Pricing, Steuer, Versand, Zahlung und Rechnung werden aus dem Monolithen in explizite Komponenten verschoben und per Parity-Prüfung verglichen.

ResponsibilityRefactoringDemoLegacyOrderProcessorRefactoredOrderApplicationServiceOrderValidationServicePricingEngineTaxCalculatorShippingCalculatorArchitectureBoundaryCheck
Lesereihenfolge der zentralen Klassen. Die Pfeile zeigen den didaktischen Weg durch den realen Quellcode, nicht zwingend jeden Laufzeitaufruf.
1. ResponsibilityRefactoringDemoFührt Legacy- und refaktorierten Pfad mit denselben Szenarien aus.
2. LegacyOrderProcessorZeigt die gebündelten Verantwortungen im ursprünglichen Prozessor als Ausgangspunkt.
3. RefactoredOrderApplicationServiceOrchestriert die extrahierten Dienste und Ports, ohne deren Fachlogik wieder zu vermischen.
4. OrderValidationServiceKapselt fachliche Eingangsregeln und liefert strukturierte Violations.
5. PricingEngineKombiniert konkrete DiscountPolicies zu einer nachvollziehbaren Preisberechnung.
6. TaxCalculatorBerechnet Steuer abhängig vom fachlichen Kontext.
7. ShippingCalculatorErmittelt Versandkosten getrennt von Preis- und Zahlungslogik.
8. ArchitectureBoundaryCheckPrüft, dass die neu gezogenen Verantwortungsgrenzen nicht durch unerlaubte Abhängigkeiten unterlaufen werden.

1. ResponsibilityRefactoringDemo

src/main/java/com/example/legacy/run7b/ResponsibilityRefactoringDemo.java
Java-Datei öffnen
Rolle im Ablauf

Führt Legacy- und refaktorierten Pfad mit denselben Szenarien aus.

Im Lesepfad folgt LegacyOrderProcessor: Zeigt die gebündelten Verantwortungen im ursprünglichen Prozessor als Ausgangspunkt.

Typ
class ResponsibilityRefactoringDemo
Verwendet
LegacyOrderProcessor, RefactoredOrderApplicationService
Verwendet von
Einstiege
main(String[] args)
package com.example.legacy.run7b;

public final class ResponsibilityRefactoringDemo {
    public static void main(String[] args) {
        LegacyOrderProcessor legacy = new LegacyOrderProcessor();
        for (OrderRequest request : OrderScenarioCatalog.scenarios()) {
            FakeInvoiceAdapter invoice = new FakeInvoiceAdapter();
            CollectingAuditAdapter audit = new CollectingAuditAdapter();
            RefactoredOrderApplicationService service = RefactoringFactory.create(invoice, audit);
            System.out.println("SCENARIO " + request.orderId());
            System.out.println(" legacy     = " + legacy.process(request));
            System.out.println(" refactored = " + service.process(request).compact());
            System.out.println(" audit      = " + audit.events());
        }
    }
}

2. LegacyOrderProcessor

src/main/java/com/example/legacy/run7b/LegacyOrderProcessor.java
Java-Datei öffnen
Rolle im Ablauf

Zeigt die gebündelten Verantwortungen im ursprünglichen Prozessor als Ausgangspunkt.

Im Lesepfad folgt RefactoredOrderApplicationService: Orchestriert die extrahierten Dienste und Ports, ohne deren Fachlogik wieder zu vermischen.

Typ
class LegacyOrderProcessor
Verwendet
Verwendet von
ResponsibilityRefactoringDemo, ArchitectureBoundaryCheck
Einstiege
process(OrderRequest request)
package com.example.legacy.run7b;

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();
    }
}

3. RefactoredOrderApplicationService

src/main/java/com/example/legacy/run7b/RefactoredOrderApplicationService.java
Java-Datei öffnen
Rolle im Ablauf

Orchestriert die extrahierten Dienste und Ports, ohne deren Fachlogik wieder zu vermischen.

Im Lesepfad folgt OrderValidationService: Kapselt fachliche Eingangsregeln und liefert strukturierte Violations.

Typ
class RefactoredOrderApplicationService
Verwendet
OrderValidationService, PricingEngine
Verwendet von
ResponsibilityRefactoringDemo
Einstiege
process(OrderRequest request)
Facade/Application Service
package com.example.legacy.run7b;

// 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);
    }
}

4. OrderValidationService

src/main/java/com/example/legacy/run7b/OrderValidationService.java
Java-Datei öffnen
Rolle im Ablauf

Kapselt fachliche Eingangsregeln und liefert strukturierte Violations.

Im Lesepfad folgt PricingEngine: Kombiniert konkrete DiscountPolicies zu einer nachvollziehbaren Preisberechnung.

Typ
class OrderValidationService
Verwendet
Verwendet von
RefactoredOrderApplicationService
Einstiege
validate(OrderRequest request)
Service Layer
package com.example.legacy.run7b;

// 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(); }
}

5. PricingEngine

src/main/java/com/example/legacy/run7b/PricingEngine.java
Java-Datei öffnen
Rolle im Ablauf

Kombiniert konkrete DiscountPolicies zu einer nachvollziehbaren Preisberechnung.

Im Lesepfad folgt TaxCalculator: Berechnet Steuer abhängig vom fachlichen Kontext.

Typ
class PricingEngine
Verwendet
TaxCalculator, ShippingCalculator
Verwendet von
RefactoredOrderApplicationService
Einstiege
calculate(OrderRequest request)
Composite Strategy
package com.example.legacy.run7b;

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);
    }
}

6. TaxCalculator

src/main/java/com/example/legacy/run7b/TaxCalculator.java
Java-Datei öffnen
Rolle im Ablauf

Berechnet Steuer abhängig vom fachlichen Kontext.

Im Lesepfad folgt ShippingCalculator: Ermittelt Versandkosten getrennt von Preis- und Zahlungslogik.

Typ
class TaxCalculator
Verwendet
Verwendet von
PricingEngine
Einstiege
calculate(OrderRequest request, Money taxableBase)
Domain Service
package com.example.legacy.run7b;

import java.math.BigDecimal;

// Pattern: Domain Service - Steuerlogik ist nachvollziehbar und separat testbar.
public final class TaxCalculator {
    public Money calculate(OrderRequest request, Money taxableBase) {
        if (request.segment() == CustomerSegment.B2B && request.vatId() != null && request.shippingCountry() != Country.AT) {
            return Money.eur("0"); // vereinfachtes Reverse-Charge-Beispiel
        }
        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 -> new BigDecimal("0.00");
        };
        return taxableBase.multiply(rate);
    }
}

7. ShippingCalculator

src/main/java/com/example/legacy/run7b/ShippingCalculator.java
Java-Datei öffnen
Rolle im Ablauf

Ermittelt Versandkosten getrennt von Preis- und Zahlungslogik.

Im Lesepfad folgt ArchitectureBoundaryCheck: Prüft, dass die neu gezogenen Verantwortungsgrenzen nicht durch unerlaubte Abhängigkeiten unterlaufen werden.

Typ
class ShippingCalculator
Verwendet
Verwendet von
PricingEngine
Einstiege
calculate(OrderRequest request, Money afterDiscount)
Domain Service
package com.example.legacy.run7b;

// Pattern: Domain Service - Versandentscheidung braucht mehrere Daten, gehoert aber nicht in Entity.
public final class ShippingCalculator {
    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");
        if (afterDiscount.amount().compareTo(new java.math.BigDecimal("250.00")) >= 0 && request.shippingCountry() != Country.US) return Money.eur("0");
        Money base = switch (request.shippingCountry()) {
            case AT, DE -> Money.eur("6.90");
            case CH -> Money.eur("14.90");
            case US -> Money.eur("29.90");
        };
        if (weight > 10_000) return base.add(Money.eur("12.00"));
        return base;
    }
}

8. ArchitectureBoundaryCheck

src/main/java/com/example/legacy/run7b/ArchitectureBoundaryCheck.java
Java-Datei öffnen
Rolle im Ablauf

Prüft, dass die neu gezogenen Verantwortungsgrenzen nicht durch unerlaubte Abhängigkeiten unterlaufen werden.

Damit ist der zentrale Pfad abgeschlossen; der Test-/Runner-Code und die vollständige Dateiliste darunter zeigen die übrigen Varianten.

Typ
class ArchitectureBoundaryCheck
Verwendet
LegacyOrderProcessor
Verwendet von
Einstiege
main(String[] args)
package com.example.legacy.run7b;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

public final class ArchitectureBoundaryCheck {
    public static void main(String[] args) throws IOException {
        Path root = Path.of("src/main/java/com/example/legacy/run7b");
        if (!Files.exists(root)) root = Path.of("code/legacy-refactoring-responsibility-lab/src/main/java/com/example/legacy/run7b");
        long forbidden = Files.walk(root)
                .filter(p -> p.toString().endsWith(".java"))
                .filter(p -> !p.getFileName().toString().contains("LegacyOrderProcessor"))
                .filter(p -> !p.getFileName().toString().contains("TestRunner"))
                .filter(p -> !p.getFileName().toString().contains("Demo"))
                .filter(p -> !p.getFileName().toString().contains("ArchitectureBoundaryCheck"))
                .filter(p -> {
                    try { return Files.readString(p).contains("new LegacyOrderProcessor"); }
                    catch (IOException e) { throw new RuntimeException(e); }
                }).count();
        if (forbidden != 0) throw new AssertionError("new code depends on legacy processor");
        System.out.println("RUN7B_ARCHITECTURE_OK");
    }
}
Alle Projektdateien öffnen (38 Einträge)
⌂ Cockpit