Das nimmst du mit
- Flags mit Lebenszyklus bauen
- Shadow Reads nutzen
- Parity messen
- Rollback erhalten
Kapitelkompass
Feature Flags und Shadow Traffic reduzieren Cutover-Risiken, wenn Abweichungen erklärt und Zustände kontrolliert werden.
Alt- und Neusystem berechnen parallel denselben Auftrag, aber nur ein Pfad darf schreiben.
Beobachtung zuerst, Teiltraffic danach, vollständiger Cutover erst bei stabilen Gates.
Dauerhafte Flags vervielfachen Zustände und werden selbst zu Legacy.
Thema: Schrittweises Vorher/Nachher-Refactoring mit Method Object, Feature Flag und 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:
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.
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.
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:
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:
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;
}
}
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.
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.
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 |
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);
}
}
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.
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.
Parallelbetrieb bedeutet nicht blind beide Pfade laufen lassen. Man braucht eine klare Diff-Klassifikation.
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:
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.
Parity-Events dürfen keine sensiblen Daten ungefiltert loggen. Kundendaten, Zahlungsdaten und personenbezogene Felder müssen maskiert oder nur als technische Referenzen gespeichert werden.
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.
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();
}
}
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;
}
}
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");
};
}
}
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.
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.
package com.example.enterprise.Kapitel;
// Pattern: Feature Toggle - Rollout-Entscheidung ist konfigurierbar und nicht im Fachcode versteckt.
public interface FeatureToggleService {
ProcessingMode modeFor(CustomerProfile customer);
}
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);
}
}
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.
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.
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.
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);
}
}
}
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.
LEGACY: Ausgangszustand.SHADOW: Parity sammeln.NEW_WITH_FALLBACK: neuer Code liefert Antwort, Legacy
bleibt Rettungspfad.NEW: neuer Pfad ist primär.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.
Alles wird auf einmal schöner gemacht. Danach ist unklar, welche Änderung einen Fehler verursacht hat.
Der neue Pfad sendet echte E-Mails, bucht echtes Payment oder erzeugt echte externe Tickets. Das macht aus einem Vergleich einen Produktionsfehler.
Niemand weiß, wann ein Flag entfernt wird. Nach sechs Monaten ist das System komplexer als vorher.
Das Team sammelt Unterschiede, klassifiziert sie aber nicht. Shadow Mode wird dann zur Beruhigung, nicht zur Qualitätssicherung.
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.
Vor dem Umschalten auf NEW_WITH_FALLBACK:
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.