Zum Inhalt
wannacode

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.

// das alte Modul (2016)
class Legacy {
total(order) {
const s = order.subtotal;
return s >= 50 ? s : s + 5;
}
}
const FREE_FROM = 50;
const DELIVERY = 5;
class Fresh {
total(order) {
const free = order.subtotal >= FREE_FROM;
return order.subtotal + (free ? 0 : DELIVERY);
}
}

JavaScript hat keine Interfaces: Es genügt, dass Legacy und Fresh eine Methode total haben. Der Router ist ebenfalls so ein Modul:

class Router {
byLegacy = 0;
byFresh = 0;
mismatches = [];
constructor(legacy, fresh, percent) {
this.legacy = legacy;
this.fresh = fresh;
this.percent = percent;
}
total(order) {
// dieselbe Bestellung geht immer an dieselbe Stelle
if ((order.id % 10) * 10 < this.percent) {
this.byFresh++;
return this.fresh.total(order);
}
this.byLegacy++;
const answer = this.legacy.total(order);
// im Schatten: das Neue rechnet, antwortet aber nicht
if (this.fresh.total(order) !== answer)
this.mismatches.push(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 vergessen
class FreshDraft {
total(order) {
return order.subtotal + DELIVERY;
}
}

Zehn Bestellungen, bei 0 %:

const orders = [
{ id: 1001, subtotal: 30 }, { id: 1002, subtotal: 64 }, { id: 1003, subtotal: 18 },
{ id: 1004, subtotal: 45 }, { id: 1005, subtotal: 120 }, { id: 1006, subtotal: 9 },
{ id: 1007, subtotal: 50 }, { id: 1008, subtotal: 22 }, { id: 1009, subtotal: 75 },
{ id: 1010, subtotal: 40 },
];
const router = new Router(new Legacy(), new FreshDraft(), 0);
for (const o of orders) router.total(o);
console.log("0%: das Alte " + router.byLegacy + ", das Neue " + router.byFresh);
console.log("Abweichungen: " + "[" + router.mismatches.join(", ") + "]");
Ausgabe
0%: das Alte 10, das Neue 0
Abweichungen: [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 {
byLegacy = 0;
byFresh = 0;
mismatches = [];
constructor(legacy, fresh, percent) {
this.legacy = legacy;
this.fresh = fresh;
this.percent = percent;
}
total(order) {
// dieselbe Bestellung geht immer an dieselbe Stelle
if ((order.id % 10) * 10 < this.percent) {
this.byFresh++;
return this.fresh.total(order);
}
this.byLegacy++;
const answer = this.legacy.total(order);
// im Schatten: das Neue rechnet, antwortet aber nicht
if (this.fresh.total(order) !== answer)
this.mismatches.push(order.id);
return answer;
}
}
Schritt für Schritt, in Worten
  1. Router steht vor dem alten Bestellmodul (2016) und dem neuen. Der Shop spricht nur mit Router; er entscheidet, wer antwortet.
  2. 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.
  3. Korrigiert und weiter bei 0 %: 0 Abweichungen. Kunden haben keine einzige falsche Summe gesehen.
  4. 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.
  5. 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.
  6. 100 %: Das Neue beantwortet alles. Das Alte bekommt keinen Verkehr, läuft aber noch und hat noch seine Daten.
  7. 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:

const orders = [
{ id: 1001, subtotal: 30 }, { id: 1002, subtotal: 64 }, { id: 1003, subtotal: 18 },
{ id: 1004, subtotal: 45 }, { id: 1005, subtotal: 120 }, { id: 1006, subtotal: 9 },
{ id: 1007, subtotal: 50 }, { id: 1008, subtotal: 22 }, { id: 1009, subtotal: 75 },
{ id: 1010, subtotal: 40 },
];
const legacy = new Legacy();
const fresh = new Fresh();
for (const percent of [0, 10, 50, 100]) {
const router = new Router(legacy, fresh, percent);
for (const o of orders) router.total(o);
console.log(String(percent).padStart(3) + "%: das Neue " + router.byFresh + ", das Alte " + router.byLegacy + ", Abweichungen " + router.mismatches.length);
}
Ausgabe
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 0
100%: das Neue 10, das Alte 0, Abweichungen 0

Die 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

const percent = 30;
for (const id of [1007, 1012, 1020])
console.log(id + " " + ((id % 10) * 10 < percent ? "das Neue" : "das Alte"));
Antwort

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 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:

  1. Expand: Die neue Tabelle wird neben die alte gelegt. Nichts wird entfernt.
  2. Doppeltes Schreiben (Dual Write): Jede neue Bestellung wird in beide geschrieben.
  3. Backfill: Die Historie (die Bestellungen von vor dem doppelten Schreiben) wird in die neue kopiert.
  4. Abgleich (Reconciliation): Die beiden werden verglichen, bis die Abweichungen 0 sind.
  5. Das Lesen wechselt zur neuen, in die alte wird nicht mehr geschrieben.
  6. 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 Maps, keine Datenbank, damit man die Logik sieht:

function cents(text) {
const [euros, rest] = text.split(",");
const value = Number(euros) * 100 + Number(rest);
if (!Number.isInteger(value)) throw new RangeError(text);
return value;
}
class Migration {
// die alte Tabelle (2016): der Betrag ist Text mit Dezimalkomma
oldRows = new Map();
// die neue Tabelle: der Betrag steht in Cent
newRows = new Map();
// Dual Write: jede neue Bestellung geht in beide
place(id, total) {
this.oldRows.set(id, total);
this.newRows.set(id, cents(total));
}
// Backfill: kopiert die Historie, ohne neue Zeilen zu überschreiben
backfill() {
for (const [id, total] of this.oldRows) {
try {
if (!this.newRows.has(id)) this.newRows.set(id, cents(total));
} catch {
console.log(`✗ backfill ${id}: "${total}"`);
}
}
}
// Abgleich: jede alte Zeile muss auch in der neuen Tabelle stehen
reconcile() {
return [...this.oldRows.keys()].filter((id) => !this.newRows.has(id));
}
}
const m = new Migration();
// die Historie: Bestellungen von vor der Migration, nur in der alten Tabelle
m.oldRows.set(1001, "35,00");
m.oldRows.set(1002, "64,00");
m.oldRows.set(1003, "23,00");
m.oldRows.set(1004, "5O,00");
// ab heute: Dual Write
m.place(1011, "42,50");
m.place(1012, "99,90");
m.backfill();
const missing = m.reconcile();
console.log("alt: " + m.oldRows.size + ", neu: " + m.newRows.size + ", fehlen im Neuen: " + "[" + missing.join(", ") + "]");
console.log(missing.length === 0 ? "Lesen kann auf die neue Tabelle wechseln" : "noch nicht: erst die Abweichung beheben");
Ausgabe
✗ backfill 1004: "5O,00"
alt: 6, neu: 5, fehlen im Neuen: [1004]
noch nicht: erst die Abweichung beheben

Bestellung 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 (const [id, total] of this.oldRows)
  if (!this.newRows.(id)) this.newRows.set(id, cents(total));

Probier es aus

Bring die Schritte in die richtige Reihenfolge

  1. contract(); // die alte entfernen
  2. reconcile(); // vergleichen, bis die Abweichungen 0 sind
  3. switchReads(); // Lesen aus der neuen
  4. dualWrite(); // jedes Schreiben in beide
  5. stopOldWrites(); // die alte nur noch lesbar
  6. backfill(); // die Historie in die neue
  7. expand(); // 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.

Ausgabe
1004: nur im Alten
1012: alt 9900, neu 9990
1013: nur im Neuen
Abweichungen: 3

Weitere 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

1. Was macht ein Strangler Fig?
2. Warum läuft das Neue zuerst im Schatten?
3. Der Router schickt zufällige 10 % der Anfragen zum Neuen. Was ist das Problem?
4. Warum überschreibt Backfill keine Zeilen, die schon in der neuen Tabelle stehen?
5. Wann wird die alte Tabelle entfernt (Contract)?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·