← Zurück

Kapitelkompass

Parallelbetrieb braucht messbare Parität

Feature Flags und Shadow Traffic reduzieren Cutover-Risiken, wenn Abweichungen erklärt und Zustände kontrolliert werden.

FlagAusgangspunktShadowEinordnenVergleichAbsichernCutoverErgebnis
Der Themenweg zeigt die fachliche Leserichtung dieses Kapitels.

Das nimmst du mit

  • Flags mit Lebenszyklus bauen
  • Shadow Reads nutzen
  • Parity messen
  • Rollback erhalten

Praxisfall

Alt- und Neusystem berechnen parallel denselben Auftrag, aber nur ein Pfad darf schreiben.

Entscheidung

Beobachtung zuerst, Teiltraffic danach, vollständiger Cutover erst bei stabilen Gates.

!

Typisches Risiko

Dauerhafte Flags vervielfachen Zustände und werden selbst zu Legacy.

13. Method Object, Feature Flags und Parallelbetrieb

Thema: Schrittweises Vorher/Nachher-Refactoring mit Method Object, Feature Flag und Parallelbetrieb.

1. Orientierung: Von getesteter Legacy zu kontrolliertem Parallelbetrieb

Kapitel vertieft den Schritt nach Characterization Tests und erster Verantwortungsextraktion. Jetzt geht es nicht mehr nur darum, eine Klasse schöner aussehen zu lassen. Es geht darum, ein produktionsnahes Refactoring so zu steuern, dass die Anwendung jederzeit auslieferbar bleibt.

Ein gefährliches Refactoring beginnt oft mit einem guten Wunsch: Die Monster-Methode soll verschwinden. Das Problem ist nicht dieser Wunsch, sondern die Reihenfolge. Wenn man zuerst die neue Zielarchitektur baut und danach hofft, dass alles gleich funktioniert, entsteht ein Big-Bang-Risiko. In Enterprise-Systemen sind fachliche Sonderfälle, Rundungsregeln, Nebenwirkungen, Mandantenregeln und historische Daten oft nur im alten Code zuverlässig abgebildet.

Das Ziel von Kapitel ist deshalb eine Pipeline:

JAVA
package com.example.enterprise.Kapitel;
// Pattern: Branch by Abstraction + Feature Toggle - alter und neuer Pfad sind hinter einem stabilen Einstiegspunkt steuerbar.
public final class RefactoringRouter {
    private final LegacyOrderProcessor legacy;
    private final RefactoredOrderProcessor modern;
    private final FeatureToggleService toggles;
    private final ParityReporter parityReporter;
    public RefactoringRouter(LegacyOrderProcessor legacy, RefactoredOrderProcessor modern,
    FeatureToggleService toggles, ParityReporter parityReporter) {
        this.legacy = legacy;
        this.modern = modern;
        this.toggles = toggles;
        this.parityReporter = parityReporter;
    }
public OrderResult process(OrderRequest request) {
    ProcessingMode mode = toggles.modeFor(request.customer());
    return switch (mode) {
        case LEGACY -> legacy.process(request);
        case SHADOW -> shadow(request);
        case NEW_WITH_FALLBACK -> newWithFallback(request);
        case NEW -> modern.process(request);
    };
}
private OrderResult shadow(OrderRequest request) {
    OrderResult legacyResult = legacy.process(request);
    try {
        OrderResult modernResult = modern.process(request);
        parityReporter.compare(request, legacyResult, modernResult);
    }
catch (RuntimeException ex) {
    parityReporter.compare(request, legacyResult, OrderResult.rejected("modern exception: " + ex.getClass().getSimpleName()));
}
return legacyResult;
}
private OrderResult newWithFallback(OrderRequest request) {
    try {
        return modern.process(request);
    }
catch (RuntimeException ex) {
    return legacy.process(request);
}
}
}

Wichtig ist: Der Router ist nicht “nur Technik”. Er ist die Sicherheitsarchitektur des Refactorings. Er entscheidet, ob Legacy, Shadow, New-with-Fallback oder New läuft. Dadurch kann man die fachliche Migration betreiben, messen und zurückrollen.

Typischer Fehler

Viele Teams ersetzen eine Monster-Methode direkt durch mehrere Services. Das sieht nach Clean Code aus, aber ohne Shadow-Vergleich weiß niemand, ob die alten Sonderfälle erhalten geblieben sind.

Bessere Reihenfolge

  1. Verhalten einfrieren.
  2. Method Object einführen.
  3. Verantwortungen extrahieren.
  4. Legacy und Neu parallel ausführen.
  5. Unterschiede klassifizieren.
  6. Rollout über Feature Flags steuern.

2. Method Object: Monster-Methode zerlegen ohne sofort alles neu zu designen

Das Method-Object-Pattern ist im Refactoring sehr wertvoll, weil es einen Zwischenzustand erlaubt. Eine lange Methode hat oft viele lokale Variablen. Wenn man einfach Methoden extrahiert, muss man ständig Parameter herumreichen. Das führt zu Methoden mit zehn Parametern oder zu statischem Hilfscode. Ein Method Object macht den Ablauf explizit: Der Workflow wird zu einem Objekt, die Zwischenergebnisse werden Felder, und die Teilschritte bekommen fachliche Namen.

Die Legacy-Methode mischt Validierung, Preisberechnung, Versand, Payment, Persistenz und Outbox:

JAVA
package com.example.enterprise.Kapitel;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
// Legacy-Beispiel: bewusst gemischte Verantwortungen. Nicht als Zielarchitektur verwenden.
public final class LegacyOrderProcessor {
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    public LegacyOrderProcessor(PaymentPort payment, OrderRepository repository, OutboxPort outbox) {
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult process(OrderRequest request) {
    List<String> messages = new ArrayList<>();
    if (request.customer().blocked()) return OrderResult.rejected("customer blocked");
    if (request.lines().isEmpty()) return OrderResult.rejected("empty order");
    Money net = Money.eur("0");
    Money tax = Money.eur("0");
    for (OrderLine line : request.lines()) {
        if (line.sku().startsWith("DISCONTINUED")) return OrderResult.rejected("discontinued sku " + line.sku());
        Money lineNet = line.netTotal();
        net = net.add(lineNet);
        tax = tax.add(lineNet.multiply(line.taxRate()));
    }
if (request.customer().premium()) {
    net = net.subtract(net.multiply(new BigDecimal("0.05")));
    messages.add("premium discount applied");
}
Money shipping = switch (request.shippingMethod()) {
    case "EXPRESS" -> Money.eur("19.90");
    case "PICKUP" -> Money.eur("0.00");
    default -> request.customer().premium() ? Money.eur("0.00") : Money.eur("5.90");
};
Money grand = net.add(tax).add(shipping);
PaymentReceipt receipt = payment.authorize(request.customer().customerId(), grand);
if (!receipt.approved()) return OrderResult.rejected("payment declined: " + receipt.reason());
String orderId = "ORD-" + request.requestId();
OrderResult result = new OrderResult(OrderDecision.ACCEPTED, orderId, net, tax, shipping, grand, messages);
repository.save(orderId, request, result);
outbox.append(orderId, "OrderAccepted", "grandTotal=" + grand.amount());
return result;
}
}

Das Method Object zeigt dieselbe Verarbeitung, aber in benannten Schritten:

JAVA
package com.example.enterprise.Kapitel;
import java.util.ArrayList;
import java.util.List;
// Pattern: Method Object - zerlegt eine lange Methode in einen zustandsbehafteten, benannten Workflow.
public final class OrderProcessingWorkflow {
    private final ValidationService validation;
    private final PricingService pricing;
    private final TaxService taxService;
    private final ShippingService shippingService;
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    private OrderRequest request;
    private Money net;
    private Money tax;
    private Money shipping;
    private Money grand;
    private final List<String> messages = new ArrayList<>();
    public OrderProcessingWorkflow(ValidationService validation, PricingService pricing, TaxService taxService,
    ShippingService shippingService, PaymentPort payment, OrderRepository repository,
    OutboxPort outbox) {
        this.validation = validation;
        this.pricing = pricing;
        this.taxService = taxService;
        this.shippingService = shippingService;
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult execute(OrderRequest request) {
    this.request = request;
    var error = validation.validate(request);
    if (error.isPresent()) return OrderResult.rejected(error.get());
    calculateTotals();
    PaymentReceipt receipt = payment.authorize(request.customer().customerId(), grand);
    if (!receipt.approved()) return OrderResult.rejected("payment declined: " + receipt.reason());
    return persistAndPublish();
}
private void calculateTotals() {
    net = pricing.netTotal(request);
    tax = taxService.taxTotal(request);
    shipping = shippingService.shipping(request);
    if (request.customer().premium()) messages.add("premium discount applied");
    grand = net.add(tax).add(shipping);
}
private OrderResult persistAndPublish() {
    String orderId = "ORD-" + request.requestId();
    OrderResult result = new OrderResult(OrderDecision.ACCEPTED, orderId, net, tax, shipping, grand, List.copyOf(messages));
    repository.save(orderId, request, result);
    outbox.append(orderId, "OrderAccepted", "grandTotal=" + grand.amount());
    return result;
}
}

Warum das tiefer ist als Extract Method

Extract Method ist eine Operation. Method Object ist eine Refactoring-Strategie. Es erlaubt, Zwischenergebnisse bewusst zu halten, ohne globale Variablen zu verwenden. Es macht den Ablauf testbar und schafft Stellen, an denen später echte Domain Services oder Ports eingeführt werden können.

Wann Method Object nicht reicht

Wenn das Method Object selbst wieder groß wird, ist es nicht das Endziel. Es ist dann eine sichere Zwischenstation. Der nächste Schritt ist, fachliche Dienste zu extrahieren: ValidationService, PricingService, TaxService, ShippingService.

3. Feature Flags: Refactoring als Betriebsentscheidung

Ein Feature Flag ist hier nicht nur ein Produktfeature-Schalter. Es ist ein Sicherheitsmechanismus für technische Modernisierung. Es erlaubt, neuen Code neben altem Code zu betreiben, ohne sofort alle Nutzer umzustellen.

Die vier Modi in diesem Kapitel sind bewusst gewählt:

Modus Antwort kommt von Neuer Code läuft? Zweck
LEGACY Legacy nein Ausgangszustand, stabiler Betrieb
SHADOW Legacy ja Parity sammeln ohne Kundenauswirkung
NEW_WITH_FALLBACK Neu nur bei Fehler Legacy vorsichtiger Cutover
NEW Neu ja Zielbetrieb
JAVA
package com.example.enterprise.Kapitel;
import java.util.HashMap;
import java.util.Map;
public final class InMemoryFeatureToggleService implements FeatureToggleService {
    private ProcessingMode defaultMode = ProcessingMode.LEGACY;
    private final Map<String, ProcessingMode> customerModes = new HashMap<>();
    public void setDefaultMode(ProcessingMode defaultMode) {
        this.defaultMode = defaultMode;
    }
public void setModeForCustomer(String customerId, ProcessingMode mode) {
    customerModes.put(customerId, mode);
}
@Override public ProcessingMode modeFor(CustomerProfile customer) {
    return customerModes.getOrDefault(customer.customerId(), defaultMode);
}
}

Warum Shadow Mode so wichtig ist

Im Shadow Mode ist der neue Code nicht kundensichtbar. Er darf aber die gleichen Eingaben bekommen und sein Ergebnis kann mit dem Legacy-Ergebnis verglichen werden. So findet man Regelabweichungen, bevor sie produktiv werden.

Gefahr

Shadow Mode darf keine echten doppelten Seiteneffekte auslösen. Payment, E-Mail, externe Buchung und echte Outbox müssen im Shadow Mode kontrolliert oder simuliert werden. Sonst erzeugt der Vergleich selbst Produktionsfehler.

4. Parallelbetrieb: Legacy und Neu vergleichen

Parallelbetrieb bedeutet nicht blind beide Pfade laufen lassen. Man braucht eine klare Diff-Klassifikation.

JAVA
package com.example.enterprise.Kapitel;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
// Pattern: Observer/Reporter - Shadow-Ergebnisse werden gemeldet, ohne den Use Case zu blockieren.
public final class ParityReporter {
    private final List<ParityEvent> events = new ArrayList<>();
    public void compare(OrderRequest request, OrderResult legacy, OrderResult modern) {
        boolean same = legacy.decision() == modern.decision()
        && Objects.equals(legacy.grandTotal(), modern.grandTotal())
        && Objects.equals(legacy.messages(), modern.messages());
        events.add(new ParityEvent(request.requestId(), same, same ? "EXACT_MATCH" : "BUSINESS_DIFFERENCE",
        "legacy=" + legacy + " modern=" + modern));
    }
public List<ParityEvent> events() {
    return List.copyOf(events);
}
}

Ein Vergleich “Objekt gleich oder nicht” ist zu grob. In echten Systemen gibt es viele Arten von Abweichungen:

Deep-Dive-Regel

Nicht jede Abweichung ist ein Fehler. Aber jede Abweichung braucht eine Klassifikation. Genau hier scheitern viele Refactorings: Es wird zu früh auf “gleich” oder “kaputt” reduziert.

Produktionshinweis

Parity-Events dürfen keine sensiblen Daten ungefiltert loggen. Kundendaten, Zahlungsdaten und personenbezogene Felder müssen maskiert oder nur als technische Referenzen gespeichert werden.

5. Verantwortung extrahieren: Validation, Pricing, Shipping, Tax

Die Verantwortungsextraktion aus Kapitel wird in Kapitel unter Rollout-Bedingungen betrachtet. Extraktion allein ist noch kein Produktionsplan. Die Services müssen in Parity-Tests gegen Legacy bestehen.

JAVA
package com.example.enterprise.Kapitel;
import java.util.Optional;
// Pattern: Extracted Domain Service - Validierung wird fachlich benannt und testbar.
public final class ValidationService {
    public Optional<String> validate(OrderRequest request) {
        if (request.customer().blocked()) return Optional.of("customer blocked");
        if (request.lines().isEmpty()) return Optional.of("empty order");
        return request.lines().stream()
        .filter(line -> line.sku().startsWith("DISCONTINUED"))
        .map(line -> "discontinued sku " + line.sku())
        .findFirst();
    }
}
JAVA
package com.example.enterprise.Kapitel;
import java.math.BigDecimal;
// Pattern: Strategy-Kandidat - Preisregeln sind der erste Schritt zu austauschbaren Preisstrategien.
public final class PricingService {
    public Money netTotal(OrderRequest request) {
        Money net = Money.eur("0");
        for (OrderLine line : request.lines()) net = net.add(line.netTotal());
        if (request.customer().premium()) net = net.subtract(net.multiply(new BigDecimal("0.05")));
        return net;
    }
}
JAVA
package com.example.enterprise.Kapitel;
public final class ShippingService {
    public Money shipping(OrderRequest request) {
        return switch (request.shippingMethod()) {
            case "EXPRESS" -> Money.eur("19.90");
            case "PICKUP" -> Money.eur("0.00");
            default -> request.customer().premium() ? Money.eur("0.00") : Money.eur("5.90");
        };
}
}

Fachliche Tiefe

Validation entscheidet nicht nur über Eingabefehler. In Enterprise-Systemen vermischt sie oft Compliance, Kundensperren, Produktstatus, Vertragsregeln und Mandantenlogik. Deshalb ist es riskant, sie zu früh als simple Bean-Validation zu behandeln.

Pricing ist ebenfalls nicht nur “Summe der Positionen”. Rabatte, Vertragskonditionen, Staffelpreise, Aktionscodes, Rundung und Steuerbasis können historische Sonderfälle haben. Deshalb wird Preislogik zuerst gegen Legacy verglichen und erst danach schöner modelliert.

Shipping und Tax sind gute Beispiele für scheinbar kleine Regeln, die produktiv teuer werden können. Versandkosten können Kundenkommunikation beeinflussen; Steuerfehler können rechtliche Folgen haben.

6. Branch by Abstraction: stabile Einstiegspunkte statt Big Bang

Branch by Abstraction bedeutet: Man schafft eine Abstraktion, hinter der alter und neuer Code austauschbar werden. Der Client nutzt nur den stabilen Einstiegspunkt. In diesem Kapitel ist das der RefactoringRouter.

JAVA
package com.example.enterprise.Kapitel;
// Pattern: Feature Toggle - Rollout-Entscheidung ist konfigurierbar und nicht im Fachcode versteckt.
public interface FeatureToggleService {
    ProcessingMode modeFor(CustomerProfile customer);
}
JAVA
package com.example.enterprise.Kapitel;
// Pattern: Application Service - orchestriert den Use Case, ohne alle Fachregeln selbst zu besitzen.
public final class RefactoredOrderProcessor {
    private final ValidationService validation;
    private final PricingService pricing;
    private final TaxService taxService;
    private final ShippingService shippingService;
    private final PaymentPort payment;
    private final OrderRepository repository;
    private final OutboxPort outbox;
    public RefactoredOrderProcessor(ValidationService validation, PricingService pricing, TaxService taxService,
    ShippingService shippingService, PaymentPort payment, OrderRepository repository,
    OutboxPort outbox) {
        this.validation = validation;
        this.pricing = pricing;
        this.taxService = taxService;
        this.shippingService = shippingService;
        this.payment = payment;
        this.repository = repository;
        this.outbox = outbox;
    }
public OrderResult process(OrderRequest request) {
    return new OrderProcessingWorkflow(validation, pricing, taxService, shippingService, payment, repository, outbox).execute(request);
}
}

Warum nicht einfach Git-Branch?

Ein langer Git-Branch verschiebt Risiko in die Zukunft. Branch by Abstraction hält den Hauptzweig lauffähig und erlaubt kleine produktionsnahe Schritte. Das ist bei großen Enterprise-Systemen oft wichtiger als perfektes lokales Design.

Typischer Fehler

Die Abstraktion wird zu früh fachlich idealisiert. Dann passt Legacy nicht hinein, und Teams beginnen, Adapter voller Sonderlogik zu schreiben. Besser: Die erste Abstraktion ist pragmatisch und stabil. Danach wird sie schrittweise verbessert.

7. Tests: Parity, Characterization und Architekturregeln

Kapitel nutzt Tests nicht nur als Korrektheitsprüfung, sondern als Steuerungsinstrument. Characterization Tests sichern Legacy-Verhalten. Parity Tests vergleichen alt und neu. Architekturregeln verhindern, dass der neue Pfad wieder zur Monster-Methode wird.

JAVA
package com.example.enterprise.Kapitel;
public final class Kapitel {
    public static void main(String[] args) {
        shouldKeepParityInShadowMode();
        shouldRejectBlockedCustomerInBothPaths();
        shouldRouteToNewPathWhenEnabled();
        System.out.println("Kapitel");
    }
private static void shouldKeepParityInShadowMode() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.SHADOW);
    OrderResult result = fixture.router.process(TestData.standardOrder("REQ-SHADOW"));
    assertTrue(result.accepted(), "legacy result should be accepted");
    assertEquals(1, fixture.reporter.events().size(), "shadow parity event expected");
    assertTrue(fixture.reporter.events().get(0).match(), "legacy and modern should match");
}
private static void shouldRejectBlockedCustomerInBothPaths() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.SHADOW);
    OrderResult result = fixture.router.process(TestData.blockedOrder());
    assertEquals(OrderDecision.REJECTED, result.decision(), "blocked customer rejected");
    assertTrue(fixture.reporter.events().get(0).match(), "blocked customer parity");
}
private static void shouldRouteToNewPathWhenEnabled() {
    TestFixture fixture = TestFixture.create();
    fixture.toggles.setDefaultMode(ProcessingMode.NEW);
    OrderResult result = fixture.router.process(TestData.standardOrder("REQ-NEW"));
    assertTrue(result.accepted(), "new path accepted");
    assertEquals(0, fixture.reporter.events().size(), "new mode does not shadow compare");
}
private static void assertTrue(boolean condition, String message) {
    if (!condition) throw new AssertionError(message);
}
private static void assertEquals(Object expected, Object actual, String message) {
    if (!java.util.Objects.equals(expected, actual)) throw new AssertionError(message + " expected=" + expected + " actual=" + actual);
}
private record TestFixture(RefactoringRouter router, InMemoryFeatureToggleService toggles, ParityReporter reporter) {
    static TestFixture create() {
        InMemoryOutbox outbox = new InMemoryOutbox();
        InMemoryOrderRepository repository = new InMemoryOrderRepository();
        PaymentPort payment = new FakePaymentAdapter();
        LegacyOrderProcessor legacy = new LegacyOrderProcessor(payment, repository, outbox);
        RefactoredOrderProcessor modern = new RefactoredOrderProcessor(new ValidationService(), new PricingService(), new TaxService(), new ShippingService(), payment, repository, outbox);
        InMemoryFeatureToggleService toggles = new InMemoryFeatureToggleService();
        ParityReporter reporter = new ParityReporter();
        return new TestFixture(new RefactoringRouter(legacy, modern, toggles, reporter), toggles, reporter);
    }
}
}

Was diese Tests abdecken

Was in echten Projekten zusätzlich nötig ist

8. Cutover und Rollback: Refactoring als kontrollierter Release

Ein Refactoring ist erst dann enterprise-tauglich, wenn der Cutover geplant ist. Das Ziel ist nicht, “neuen Code fertig” zu haben. Das Ziel ist, risikoarm von Legacy auf Neu umzuschalten.

Rollout-Stufen

  1. LEGACY: Ausgangszustand.
  2. SHADOW: Parity sammeln.
  3. NEW_WITH_FALLBACK: neuer Code liefert Antwort, Legacy bleibt Rettungspfad.
  4. NEW: neuer Pfad ist primär.
  5. Legacy-Removal: erst nach Beobachtungsfenster.

Rollback-Regel

Rollback muss vor dem Rollout definiert sein. Wenn man erst während eines Incidents überlegt, wie man zurückschaltet, ist das Feature Flag nur Kosmetik.

Metriken

9. Anti-Patterns bei Refactoring-Rollouts

Anti-Pattern 1: Big-Bang-Cleanup

Alles wird auf einmal schöner gemacht. Danach ist unklar, welche Änderung einen Fehler verursacht hat.

Anti-Pattern 2: Shadow Mode mit echten Seiteneffekten

Der neue Pfad sendet echte E-Mails, bucht echtes Payment oder erzeugt echte externe Tickets. Das macht aus einem Vergleich einen Produktionsfehler.

Anti-Pattern 3: Feature Flag ohne Besitzer

Niemand weiß, wann ein Flag entfernt wird. Nach sechs Monaten ist das System komplexer als vorher.

Anti-Pattern 4: Parity-Diffs ignorieren

Das Team sammelt Unterschiede, klassifiziert sie aber nicht. Shadow Mode wird dann zur Beruhigung, nicht zur Qualitätssicherung.

Anti-Pattern 5: Legacy zu früh löschen

Der neue Code läuft einen Tag stabil und Legacy wird entfernt. Am Monatsende taucht ein Sonderfall auf, der nur im Legacy-Code korrekt war.

10. Produktionscheckliste Kapitel

Vor dem Umschalten auf NEW_WITH_FALLBACK:

Lernaufgabe

Erweitere das Code-Lab um eine bewusste Abweichung in der Steuerberechnung. Lasse Shadow Mode laufen, klassifiziere die Abweichung als BUSINESS_DIFFERENCE, dokumentiere eine ADR und entscheide, ob die neue Regel korrekt ist oder der Legacy-Fall übernommen werden muss.

⌂ Cockpit