Ein laufendes System verändern (Strangler Fig, Expand–Contract)
Kurz gesagt: Strangler Fig ersetzt ein altes System Schritt für Schritt, indem ein Router davor den Verkehr zum neuen verschiebt, und Expand–Contract verändert die Daten mit doppeltem Schreiben, Backfill und Abgleich.
Problem
Das große Umschalten
Das Bestellmodul ist von 2016. Es berechnet den Betrag: Unter 50 € zahlt der Kunde 5 € Lieferung, ab 50 € ist die Lieferung kostenlos. Das Team schreibt ein neues Modul, mit neuem Code und einer neuen Tabelle. Der Plan: Am Samstagabend wird die Seite angehalten, das Alte wird abgeschaltet, das Neue wird eingeschaltet.
Überlege, was passiert, wenn das Neue einen Fehler hat, den die Tests nicht gefunden haben. Am Montag sehen Kunden in drei Ländern falsche Summen. Zurück zum Alten ist inzwischen schwer: Seit Samstag schreibt das Neue die Bestellungen in die neue Tabelle, und das Alte kennt sie nicht. Du hast die Wahl zwischen schlecht und schlechter: live reparieren, oder zurückgehen und Bestellungen verlieren.
Das Problem ist nicht der neue Code. Das Problem ist, dass das Umschalten ein einziger großer Schritt ohne Rückweg ist.
Die Idee
Strangler Fig: das Neue wächst um das Alte
Der Name ist der einer Pflanze: Die Würgefeige keimt auf einem anderen Baum, schickt Wurzeln um ihn herum nach unten und ersetzt ihn mit den Jahren. Der Baum verschwindet, und die Form bleibt.
So wird auch ein System ersetzt. Vor das Alte wird ein Vermittler gestellt (hier der Router), mit dem der Rest des Shops spricht. Am Anfang schickt er alles zum Alten. Dann übernimmt das Neue einen kleinen Teil, dann einen größeren, am Ende alles. Das Alte wird zuletzt abgeschaltet, wenn es nichts mehr bekommt. Das ist ein Strangler Fig.
Jeder Schritt ist klein und hat einen Rückweg: Verhält sich das Neue schlecht, wird der Prozentsatz gesenkt. Der Router ist dieselbe Art Stelle wie ein Anti-Corruption Layer: eine Grenze, hinter der man austauschen kann, was dahinter ist, ohne dass die anderen es merken.
Sieh es dir an
Der Router im Code
Beide Module beantworten dieselbe Frage: wie viel die Bestellung kostet.
record Order(int id, int subtotal) {}
interface Orders { int total(Order order);}// das alte Modul (2016)class Legacy implements Orders { public int total(Order order) { int s = order.subtotal(); return s >= 50 ? s : s + 5; }}static final int FREE_FROM = 50, DELIVERY = 5;class Fresh implements Orders { public int total(Order order) { boolean free = order.subtotal() >= FREE_FROM; return order.subtotal() + (free ? 0 : DELIVERY); }}Legacy und Fresh implementieren ein Interface Orders, deshalb weiß der Shop nicht, welches von beiden ihm antwortet. Der Router ist ebenfalls so ein Modul:
class Router implements Orders { private final Orders legacy, fresh; private final int percent; int byLegacy = 0, byFresh = 0; final List<Integer> mismatches = new ArrayList<>();
Router(Orders legacy, Orders fresh, int percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
public int total(Order order) { // dieselbe Bestellung geht immer an dieselbe Stelle if (order.id() % 10 * 10 < percent) { byFresh++; return fresh.total(order); } byLegacy++; int answer = legacy.total(order); // im Schatten: das Neue rechnet, antwortet aber nicht if (fresh.total(order) != answer) mismatches.add(order.id()); return answer; }}Die Bestellung geht zum Neuen, wenn ihre letzte Ziffer mal 10 unter dem Prozentsatz liegt: bei 10 % nur die Ziffer 0, bei 50 % die Ziffern 0 bis 4. Wenn das Alte antwortet, rechnet das Neue dieselbe Bestellung im Schatten: Sein Ergebnis wird nur verglichen, der Kunde sieht es nicht.
Sieh es dir an
Im Schatten
Die erste Version des Neuen:
// die erste Version des Neuen: die kostenlose Lieferung vergessenclass FreshDraft implements Orders { public int total(Order order) { return order.subtotal() + DELIVERY; }}Zehn Bestellungen, bei 0 %:
var orders = List.of( new Order(1001, 30), new Order(1002, 64), new Order(1003, 18), new Order(1004, 45), new Order(1005, 120), new Order(1006, 9), new Order(1007, 50), new Order(1008, 22), new Order(1009, 75), new Order(1010, 40));var router = new Router(new Legacy(), new FreshDraft(), 0);for (Order o : orders) router.total(o);System.out.println("0%: das Alte " + router.byLegacy + ", das Neue " + router.byFresh);System.out.println("Abweichungen: " + router.mismatches);0%: das Alte 10, das Neue 0Abweichungen: [1002, 1005, 1007, 1009]Alle 10 werden vom Alten beantwortet, und alle Summen stimmen. Die Liste aber sagt, dass das Neue bei 4 Bestellungen abweicht, alle ab 50 €: Es hat die kostenlose Lieferung vergessen. Der Fehler wird an den echten Bestellungen gefunden, ohne dass ein einziger Kunde ihn sieht. Die Korrektur ist das Fresh von oben.
class Router implements Orders { private final Orders legacy, fresh; private final int percent; int byLegacy = 0, byFresh = 0; final List<Integer> mismatches = new ArrayList<>();
Router(Orders legacy, Orders fresh, int percent) { this.legacy = legacy; this.fresh = fresh; this.percent = percent; }
public int total(Order order) { // dieselbe Bestellung geht immer an dieselbe Stelle if (order.id() % 10 * 10 < percent) { byFresh++; return fresh.total(order); } byLegacy++; int answer = legacy.total(order); // im Schatten: das Neue rechnet, antwortet aber nicht if (fresh.total(order) != answer) mismatches.add(order.id()); return answer; }}Schritt für Schritt, in Worten
- Router steht vor dem alten Bestellmodul (2016) und dem neuen. Der Shop spricht nur mit Router; er entscheidet, wer antwortet.
- 0 %: Das Alte beantwortet alle 10. Das Neue rechnet im Schatten und weicht bei 4 ab: 1002, 1005, 1007, 1009. Alle liegen über 50 €: Es hat die kostenlose Lieferung vergessen.
- Korrigiert und weiter bei 0 %: 0 Abweichungen. Kunden haben keine einzige falsche Summe gesehen.
- 10 %: Die Bestellung, deren letzte Ziffer 0 ist (1010), geht zum Neuen. Dieselbe Bestellung geht immer denselben Weg, deshalb springt ein Kunde nicht zwischen den beiden hin und her.
- 50 %: 5 von 10 zum Neuen. Verhält es sich schlecht, ist der Rückweg eine einzige Zahl: wieder 10, und dieselben Bestellungen gehen zum Alten zurück.
- 100 %: Das Neue beantwortet alles. Das Alte bekommt keinen Verkehr, läuft aber noch und hat noch seine Daten.
- Das Abschalten des Alten ist der Punkt ohne Rückweg. Er kommt zuletzt, nach Wochen mit 0 Abweichungen und mit archivierten Daten.
Sieh es dir an
Schritt für Schritt
Mit dem korrigierten Neuen, durch den ganzen Plan:
var orders = List.of( new Order(1001, 30), new Order(1002, 64), new Order(1003, 18), new Order(1004, 45), new Order(1005, 120), new Order(1006, 9), new Order(1007, 50), new Order(1008, 22), new Order(1009, 75), new Order(1010, 40));var legacy = new Legacy();var fresh = new Fresh();for (int percent : new int[] {0, 10, 50, 100}) { var router = new Router(legacy, fresh, percent); for (Order o : orders) router.total(o); System.out.printf("%3d%%: das Neue %d, das Alte %d, Abweichungen %d%n", percent, router.byFresh, router.byLegacy, router.mismatches.size());} 0%: das Neue 0, das Alte 10, Abweichungen 0 10%: das Neue 1, das Alte 9, Abweichungen 0 50%: das Neue 5, das Alte 5, Abweichungen 0100%: das Neue 10, das Alte 0, Abweichungen 0Die Abweichungen bleiben 0, auch bei 10 % und bei 50 %. In einem echten Shop vergehen zwischen zwei Schritten Tage: Jeder Schritt wird beobachtet, bis Fehler, Antwortzeit und Beschwerden zeigen, dass es ruhig ist.
Die Wahl richtet sich nach der Nummer der Bestellung, nicht nach dem Zufall. So geht eine Bestellung immer an dieselbe Stelle, und wenn der Prozentsatz von 50 auf 10 gesenkt wird, gehen dieselben Bestellungen zum Alten zurück. In einem echten Projekt richtet sich die Wahl nach etwas, das den Kunden an einer Stelle hält: der Nummer des Kunden oder des Warenkorbs.
Der Prozentsatz ist eine Einstellung (ein Flag), kein Code: In einem echten Projekt ändert er sich ohne neues Release, deshalb dauert auch der Rückweg nur Sekunden.
Probier es aus
Sag die Ausgabe voraus
int percent = 30;for (int id : new int[] {1007, 1012, 1020}) System.out.println(id + " " + (id % 10 * 10 < percent ? "das Neue" : "das Alte"));1007 % 10 ist 7, mal 10 ist 70, und 70 ist nicht unter 30: das Alte. 1012 ergibt 20, unter 30: das Neue. 1020 ergibt 0, ebenfalls das Neue. Bei 30 % nimmt das Neue die Ziffern 0, 1 und 2.
Probier es aus
Finde den Fehler
Ein Kollege schreibt einen kürzeren Router: „10 % sind 10 %, soll es doch zufällig sein.“ Kunden fangen an zu schreiben, dass sich die Summe im Warenkorb bei jedem Neuladen ändert. In welcher Zeile liegt der Fehler?
Wähle die Zeile mit dem Fehler.
Die Wahl ist bei jedem Aufruf zufällig: Dieselbe Bestellung geht einmal zum Alten, einmal zum Neuen. Solange die beiden nicht gleich rechnen, sieht der Kunde zwei Summen. Und den Prozentsatz zurückzunehmen bringt nicht dieselben Bestellungen zurück. Die Wahl muss von etwas Beständigem abhängen, wie id % 10 * 10 < percent im Router oben. Auch der Schatten fehlt: Ohne ihn weiß niemand, ob die beiden gleich rechnen.
Die Idee
Die Daten: Expand–Contract
Der Code wird mit dem Router ersetzt. Die Daten sind schwerer: Das Neue will eine neue Tabelle. In der alten ist der Betrag Text mit Dezimalkomma ("64,00"), in der neuen eine Zahl in Cent (6400). Und solange der Verkehr wandert, müssen beide Tabellen stimmen.
Im Kapitel Schema-Migrationen lief die Änderung einer Spalte in Schritten: erweitern, übergehen, zusammenziehen. Hier ist es dasselbe, aber für eine ganze Tabelle und mit einer Kontrolle:
- Expand: Die neue Tabelle wird neben die alte gelegt. Nichts wird entfernt.
- Doppeltes Schreiben (Dual Write): Jede neue Bestellung wird in beide geschrieben.
- Backfill: Die Historie (die Bestellungen von vor dem doppelten Schreiben) wird in die neue kopiert.
- Abgleich (Reconciliation): Die beiden werden verglichen, bis die Abweichungen 0 sind.
- Das Lesen wechselt zur neuen, in die alte wird nicht mehr geschrieben.
- Contract: Die alte Tabelle wird entfernt.
Das ist Expand–Contract. Bis Schritt 6 lässt sich jeder Schritt zurücknehmen: Die alte Tabelle ist noch da, und sie stimmt.
Sieh es dir an
Doppeltes Schreiben, Backfill und Abgleich
Die beiden Tabellen sind hier TreeMaps (nach Nummer sortiert), keine Datenbank, damit man die Logik sieht:
class Migration { // die alte Tabelle (2016): der Betrag ist Text mit Dezimalkomma final Map<Integer, String> oldRows = new TreeMap<>(); // die neue Tabelle: der Betrag steht in Cent final Map<Integer, Long> newRows = new TreeMap<>();
static long cents(String text) { String[] p = text.split(","); return Long.parseLong(p[0]) * 100 + Long.parseLong(p[1]); }
// Dual Write: jede neue Bestellung geht in beide void place(int id, String total) { oldRows.put(id, total); newRows.put(id, cents(total)); }
// Backfill: kopiert die Historie, ohne neue Zeilen zu überschreiben void backfill() { for (var row : oldRows.entrySet()) { try { newRows.putIfAbsent(row.getKey(), cents(row.getValue())); } catch (NumberFormatException e) { System.out.println("✗ backfill " + row.getKey() + ": \"" + row.getValue() + "\""); } } }
// Abgleich: jede alte Zeile muss auch in der neuen Tabelle stehen List<Integer> reconcile() { List<Integer> missing = new ArrayList<>(); for (int id : oldRows.keySet()) if (!newRows.containsKey(id)) missing.add(id); return missing; }}var m = new Migration();// die Historie: Bestellungen von vor der Migration, nur in der alten Tabellem.oldRows.put(1001, "35,00");m.oldRows.put(1002, "64,00");m.oldRows.put(1003, "23,00");m.oldRows.put(1004, "5O,00");// ab heute: Dual Writem.place(1011, "42,50");m.place(1012, "99,90");m.backfill();var missing = m.reconcile();System.out.println("alt: " + m.oldRows.size() + ", neu: " + m.newRows.size() + ", fehlen im Neuen: " + missing);System.out.println(missing.isEmpty() ? "Lesen kann auf die neue Tabelle wechseln" : "noch nicht: erst die Abweichung beheben");✗ backfill 1004: "5O,00"alt: 6, neu: 5, fehlen im Neuen: [1004]noch nicht: erst die Abweichung behebenBestellung 1004 von 2016 hat im Betrag den Buchstaben O statt einer Null. Das alte Modul lebt damit, aber in Cent umrechnen lässt sich das nicht. Backfill versteckt es nicht: Er sagt es laut und macht weiter. Der Abgleich fängt es auf und sagt „noch nicht“. Jemand repariert die Zeile in der alten Tabelle, Backfill läuft noch einmal, und der Abgleich zeigt 0.
Backfill kann so oft laufen, wie nötig: Er kopiert nur, was fehlt. Das doppelte Schreiben kann als zwei Schreibvorgänge in einer Transaktion laufen (Datenbanken) oder über eine Outbox, damit kein Schreibvorgang nur in einer der Tabellen landet.
Probier es aus
Ergänze die Lücke
Backfill darf keine Zeile überschreiben, die schon in der neuen Tabelle steht. Wie?
for (var row : oldRows.entrySet())
newRows.(row.getKey(), cents(row.getValue()));putIfAbsent schreibt nur, wenn der Schlüssel fehlt. Die Zeile in der neuen Tabelle kam vom doppelten Schreiben, sie ist neuer als die Kopie der Historie. Wenn Backfill sie überschreibt, macht er eine Änderung von heute rückgängig.
Probier es aus
Bring die Schritte in die richtige Reihenfolge
contract(); // die alte entfernenreconcile(); // vergleichen, bis die Abweichungen 0 sindswitchReads(); // Lesen aus der neuendualWrite(); // jedes Schreiben in beidestopOldWrites(); // die alte nur noch lesbarbackfill(); // die Historie in die neueexpand(); // die neue Tabelle neben der alten
Die Idee
Der Punkt ohne Rückweg
Jeder Schritt bis hier hat einen Rückweg: Der Prozentsatz wird gesenkt, das Lesen geht zur alten Tabelle zurück, das doppelte Schreiben läuft weiter. Zwei Schritte haben keinen: das Abschalten des alten Moduls und das Entfernen der alten Tabelle. Danach heißt Zurückgehen, aus einem Archiv wiederherzustellen.
Deshalb kommen sie zuletzt und werden bewusst entschieden. Davor: Wochen mit 0 Abweichungen beim Abgleich, kein Code mehr, der das Alte liest oder schreibt, und ein Archiv der Daten. Es ist gut, den Plan, das Kriterium für jeden Schritt und den Rückweg vor dem Start in einem ADR festzuhalten: Mitten in einer Migration will jeder sie schneller abschließen.
Eine Migration, die auf halbem Weg stehen bleibt, ist das Schlimmste: zwei Systeme, zwei Datensätze und ein doppeltes Schreiben, die für immer gepflegt werden. Der Plan muss bis zum Contract reichen.
Die Idee
Wann es schadet
- Ein kleines System mit wenigen Daten. Wenn ein Stopp von einer halben Stunde in der Nacht vertretbar ist und die Daten in Minuten umziehen, sind Router, Schatten und Abgleich Wochen Arbeit ohne Nutzen. Eine einzige Migration mit einem Plan für den Rückweg (ein Archiv davor) genügt.
- Wenn es keinen Platz für einen Router gibt. Wird das Alte direkt von zwanzig Stellen aufgerufen, braucht es zuerst eine Grenze davor (eine Facade, einen Hub), und erst dann kann man es erwürgen.
- Der Schatten ist nicht gratis. Das Neue rechnet alles noch einmal. Wenn es E-Mails verschickt oder Geld abbucht, darf das im Schatten nicht passieren: Der Schatten muss frei von Seiteneffekten sein.
- Ein endloses „fast“. Wenn die 100 % nie kommen (die letzten 5 % sind die schwersten), bleiben zwei Systeme zu pflegen. Ohne Frist und Kriterium wird ein Strangler Fig zu zwei Monolithen statt einem.
Genauer, für Neugierige
Der Name stammt aus einem Artikel von Martin Fowler von 2004 (Strangler Fig Application). Expand–Contract wird auch als Parallel Change beschrieben.
Der Router in einem echten Projekt sitzt meist in einem Gateway oder Load Balancer vor den beiden Systemen: eine Regel nach Pfad (/orders zum Neuen) oder nach Prozent. Sie werden als Infrastruktur ausgerollt und überwacht, und das gehört in den DevOps-Kurs ZeroToOps. Der Prozentsatz ist oft ein Feature-Flag: eine Einstellung, die sich ohne neues Release ändern lässt (LaunchDarkly, Unleash, OpenFeature).
Schema-Migrationen werden mit Werkzeugen wie Flyway und Liquibase (Java), EF Core Migrations (.NET), Alembic (Python), Prisma oder Knex (JavaScript) geschrieben. Sie führen die Schritte der Reihe nach aus, entscheiden aber nicht, wann: Das ist der Plan für Expand–Contract.
Statt eines doppelten Schreibens im Code wird manchmal CDC (Change Data Capture) benutzt: Ein Werkzeug wie Debezium liest das Log der alten Datenbank und trägt jede Änderung in die neue. Der Code bleibt unberührt, aber es kommt ein weiteres System zum Überwachen dazu.
Der Vergleich im Schatten wird im großen Maßstab asynchron gemacht: Die Antworten werden aufgezeichnet und später verglichen, damit das Neue den Kunden nicht ausbremst.
Übung
Abgleich nach Wert
Löse sie in deinem Editor.
Der Abgleich oben prüft nur, ob die Zeile da ist. Schreibe reconcile, die zwei Tabellen mit Beträgen in Cent vergleicht und für jeden Unterschied eine Zeile zurückgibt: eine Zeile nur in der alten, eine Zeile nur in der neuen oder ein anderer Betrag. Die Nummern stehen in Reihenfolge.
Die alte Tabelle: 1004 → 5000, 1011 → 4250, 1012 → 9900. Die neue: 1011 → 4250, 1012 → 9990, 1013 → 1800.
1004: nur im Alten1012: alt 9900, neu 99901013: nur im NeuenAbweichungen: 3Weitere Aufgaben
- Nach Kunde. Ändere den Router so, dass er nach der Nummer des Kunden statt nach der Nummer der Bestellung wählt. Füge ein Feld für den Kunden hinzu und lass zwei Bestellungen eines Kunden bei 50 % laufen.
- Zurückgehen. Lass den Plan 0, 10, 50, 10, 100 laufen und prüfe, dass beim zweiten Mal 10 % dieselben Bestellungen dabei sind wie beim ersten Mal.
Test
Teste dich
Verwandte Lektionen
Abschnitt „Verwandte Lektionen“- Baut auf: API-Design: Vertrag, Versionen, wiederholte Anfragen · ADR: Architekturentscheidungen festhalten · Eine Transaktion über Services hinweg (Saga) · Alte und fremde Systeme anbinden
- Geht weiter in: Die Unternehmenslandschaft und ihre Capabilities · Deine erste Architekturaufgabe
- Aufgaben zum Thema: Der Architekt bei der Arbeit