Legacy PHP

Vier Migrationsstrategien im Vergleich: Big-Bang, Strangler, Branch-by-Abstraction, Parallel Run

Es gibt nicht die eine Migrationsstrategie, sondern vier brauchbare – jede mit klaren Mindestvoraussetzungen. Wer die falsche wählt, zahlt mit Downtime oder mit einem Projekt, das nach Monat neun niemand mehr anfasst. Die vier im Vergleich, mit Code-Ankern und einem Entscheidungsbaum am Ende.

24. Juli 2026 9 min · 1.752 Wörter

„Strangler ist gerade hip, also machen wir Strangler.“ Diesen Satz habe ich vor einiger Zeit in einem Architektur-Meeting gehört. Das Team hatte zwei Entwickler, keine CI und einen Monolithen ohne klare Modulgrenzen. Strangler war die schlechteste der vier Optionen, die auf dem Tisch lagen – nur wusste das keiner, weil niemand die anderen drei kannte. Hätten sie vorher gefragt: Branch-by-Abstraction. Weniger elegant im Blogpost, deutlich passender für ihre Lage.

Es gibt nämlich nicht die Migrationsstrategie, nur die, die zu deiner Struktur passt. Vier tragen in der Praxis, jede mit Voraussetzungen, unter denen sie funktioniert – und darunter eben nicht. Wer die falsche wählt, zahlt mit Downtime oder mit einem 14-Monats-Projekt, das nach Monat neun keiner mehr anfasst. Also der Reihe nach.

Big-Bang-Cutover

Der Big-Bang ist der, den alle meinen, wenn sie „Migration“ sagen und dabei ein mulmiges Gefühl haben. Altes System aus, neues an, Stichtag. Er hat einen schlechten Ruf, und meistens zu Recht – aber nicht immer.

Wann er trägt: kleine Codebases, sagen wir unter 20k Zeilen, mit einer klaren Spezifikation und der realen Möglichkeit, Downtime einzuplanen. Ein internes Tool, das nachts und am Wochenende niemand braucht. Ein abgegrenzter Dienst mit überschaubarem Funktionsumfang.

Voraussetzungen, ohne die ich ihn nicht mitmache: vollständige Testabdeckung des alten Verhaltens und ein Rollback-Plan, der bis auf die Datenbank-Ebene durchdacht ist. Nicht „wir können ja zurückdeployen“ – sondern: Was passiert mit den Daten, die zwischen Cutover und Rollback im neuen Schema entstanden sind? Wer diese Frage nicht beantworten kann, hat keinen Rollback-Plan, sondern eine Hoffnung.

Woran er scheitert: an fast jeder Codebase, die diesen Absatz hört und leise kichert. Sobald das System groß, geschäftskritisch und rund um die Uhr in Betrieb ist, ist der Big-Bang eine Wette. Ich habe genau einen Big-Bang in den letzten Jahren mitgetragen, und der betraf ein Reporting-Tool mit 8.400 Zeilen, das genau vierzehn interne Nutzer hatte. Da ging es. Sonst selten.

Strangler-Fig

Das Strangler-Fig-Pattern legt das neue System schrittweise um das alte, Route für Route, bis das alte irgendwann nichts mehr tut und abgeschaltet wird. Der Name kommt von der Würgefeige, die um ihren Wirtsbaum wächst, bis der Wirt weg ist und sie allein steht. Ich habe dem Pattern einen eigenen Artikel gewidmet, hier nur die Einordnung im Vergleich.

Wann es trägt: wenn die Architektur HTTP-Routing-fähig ist und man einen Proxy davorhängen kann, der pro Route entscheidet, ob alt oder neu antwortet. Klassisch für Web-Anwendungen mit sauber getrennten URL-Bereichen.

Voraussetzungen: eine Deployment-Pipeline mit Feature-Flags und Observability auf Route-Ebene. Genau den Punkt hat das Zwei-Personen-Team aus dem Einstieg übersehen. Ohne Metriken pro Route weiß niemand, ob die frisch migrierte Route sich genauso verhält wie vorher. Man merkt den Unterschied dann daran, dass der Support anruft – die teuerste Form von Monitoring.

Woran es scheitert: an tief verschränkten Modulen ohne klare URL-Grenzen. Wenn dieselbe Geschäftslogik über zwölf Einstiegspunkte erreichbar ist und quer durch den Code Zustände teilt, gibt es keine saubere Kante, an der der Proxy schneiden könnte. Genau das war beim Zwei-Personen-Team der Fall.

Branch-by-Abstraction

Branch-by-Abstraction migriert innerhalb des Codes statt davor. Man zieht ein Interface über den Teil, der ausgetauscht werden soll, baut die neue Implementierung daneben, und ein Schalter entscheidet, welche läuft. Beide Pfade bleiben lauffähig, bis der neue bewiesen ist.

Wann es trägt: modul-interne Migrationen. Eine neue Persistenzschicht neben der alten, ein neuer Berechnungskern, ein Austausch der Template-Engine. Überall, wo es keine saubere URL-Kante gibt, aber eine saubere Code-Kante gezogen werden kann.

Voraussetzungen: eine vernünftige Testabdeckung des migrierten Bereichs und die Disziplin, beide Pfade lauffähig zu halten, auch wenn es nervt. Der alte Pfad darf nicht verrotten, solange der neue nicht steht.

Woran es scheitert: an Teams, die den alten Pfad zu früh löschen. „Läuft ja jetzt neu, weg damit.“ Drei Wochen später kommt der Edge-Case, den nur der alte Pfad kannte, und es gibt kein Zurück mehr. Die Abstraktion lebt vom Nebeneinander. Wer das eine Bein zu früh wegzieht, steht nicht auf dem anderen, sondern liegt.

So sieht die Kante konkret aus. Ausgangslage: eine Klasse spricht direkt eine handgeschriebene SQL-Schicht an, und die soll durch Doctrine ersetzt werden – ohne dass die Aufrufer davon etwas merken.

interface KundenRepository
{
    public function findeById(int $id): ?Kunde;
}

// Alt: bestehende Implementierung, unverändert lauffähig
final class SqlKundenRepository implements KundenRepository
{
    public function findeById(int $id): ?Kunde
    {
        $row = DB::query(
            "SELECT * FROM kunden WHERE id = " . (int) $id
        )->fetch();

        return $row ? Kunde::ausZeile($row) : null;
    }
}

// Neu: läuft daneben, noch nicht scharf geschaltet
final class DoctrineKundenRepository implements KundenRepository
{
    public function __construct(private EntityManagerInterface $em) {}

    public function findeById(int $id): ?Kunde
    {
        return $this->em->find(Kunde::class, $id);
    }
}

Der Schalter bleibt bewusst simpel – eine Umgebungsvariable, kein Framework:

$repository = getenv('KUNDEN_REPO') === 'doctrine'
    ? new DoctrineKundenRepository($em)
    : new SqlKundenRepository();

Damit kann ich in der Testumgebung doctrine schalten, die Testsuite gegen beide Implementierungen laufen lassen und in Production erst umlegen, wenn beide dasselbe liefern. Der alte Pfad bleibt drin, bis der neue über Wochen bewiesen ist. Erst dann fliegt SqlKundenRepository raus – in einem eigenen Commit, den man im Zweifel isoliert zurückrollen kann.

Parallel Run

Der Parallel Run ist die vorsichtigste der vier. Beide Systeme, alt und neu, verarbeiten dieselben Eingaben gleichzeitig, und ein Vergleich prüft, ob die Ergebnisse übereinstimmen. Ausgeliefert wird weiter das alte. Das neue läuft mit, still, und beweist sich an echten Daten, bevor es jemals scharf geschaltet wird.

Wann es trägt: bei kritischen Berechnungs- oder Reporting-Pfaden, bei denen ein falsches Ergebnis richtig weh tut. Preisberechnung, Provisionen, Zins- und Tilgungspläne, alles, wo eine Abweichung in der dritten Nachkommastelle am Monatsende ein Compliance-Thema wird.

Voraussetzungen: beide Systeme müssen dieselben Inputs verarbeiten können, und es braucht eine Infrastruktur, die die Ergebnisse vergleicht und Abweichungen protokolliert. Ohne die ist der Parallel Run nur doppelte Last ohne Erkenntnis.

Woran er scheitert: an Operationen, die nicht idempotent sind. Wenn der „mitlaufende“ neue Pfad selbst E-Mails verschickt, Buchungen schreibt oder ein externes System anspricht, läuft eben nicht nur eine Berechnung doppelt, sondern eine Nebenwirkung. Der Parallel Run funktioniert nur für Pfade, die man gefahrlos zweimal ausführen kann.

So sieht der Vergleichs-Wrapper aus. Er ruft beide Implementierungen, liefert das alte Ergebnis aus und meldet jede Diskrepanz, ohne den laufenden Betrieb zu stören:

final class ParallelRunPreis implements PreisBerechnung
{
    public function __construct(
        private PreisBerechnung $alt,
        private PreisBerechnung $neu,
        private LoggerInterface $log,
    ) {}

    public function berechne(Warenkorb $korb): Geldbetrag
    {
        $alt = $this->alt->berechne($korb);

        try {
            $neu = $this->neu->berechne($korb);
            if (! $alt->gleich($neu)) {
                $this->log->warning('preis_diff', [
                    'korb' => $korb->id(),
                    'alt'  => $alt->cent(),
                    'neu'  => $neu->cent(),
                ]);
            }
        } catch (\Throwable $e) {
            // der neue Pfad darf den alten nie umbringen
            $this->log->error('neu_pfad_fehler', ['msg' => $e->getMessage()]);
        }

        return $alt; // ausgeliefert wird das erprobte Ergebnis
    }
}

Der try/catch ist hier nicht Kosmetik, sondern der Kern. Der mitlaufende Pfad darf unter keinen Umständen den produktiven umreißen. Und die Diskrepanz-Logs brauchen einen Besitzer – jemanden, der sie tatsächlich anschaut. Ein Parallel Run, dessen Diff-Auswertung niemandem zugeordnet ist, produziert nur ein volles Log, das keiner liest.

In der Praxis mischt man sie

Die vier sauber getrennt zu beschreiben ist gut fürs Verständnis, aber ehrlicherweise laufen echte Mandate selten mit nur einer Strategie. Meistens greifen zwei ineinander.

Ein typisches Muster aus einem Banking-Mandat: Strangler-Fig auf der Route-Ebene, um die Anwendung Stück für Stück hinter einen Proxy zu ziehen – und innerhalb der frisch gezogenen Routen dann Parallel Run für den einen Berechnungspfad, bei dem eine Abweichung ein Meldethema geworden wäre. Der Proxy schnitt grob, der Parallel Run sicherte fein. Zusammen haben wir 31 Routen in knapp elf Monaten migriert, ohne dass die Fachabteilung eine einzige Abweichung gemeldet hätte – weil die Diff-Logs sie vor den Kunden gefunden haben.

Ein anderes Muster: Branch-by-Abstraction als Vorbereitung für alles Weitere. Erst die Persistenzschicht hinter ein Interface ziehen, dann ist der Rest der Migration überhaupt erst testbar. Die Abstraktion ist dann kein Selbstzweck, sondern die Naht, an der später Strangler oder ein sauberer Cutover ansetzen können. Wer die Strategien als Werkzeugkasten liest statt als Entweder-oder, trifft die besseren Entscheidungen.

Der Entscheidungsbaum

Fünf Fragen, ehrlich beantwortet, führen meistens schon zur passenden Strategie. Nicht als Ersatz fürs Nachdenken, aber als Startpunkt. Die Grundregel dahinter: Die beste Strategie ist nicht die modernste, sondern die, deren Voraussetzungen ihr heute schon erfüllt.

FrageJaNein
Ist die Codebase klein und Downtime planbar?Big-Bang möglichweiter
Gibt es saubere URL-/Route-Grenzen und einen Proxy davor?Strangler-Figweiter
Lässt sich eine Code-Kante als Interface ziehen?Branch-by-Abstractionweiter
Ist der Pfad kritisch und muss das Ergebnis verifiziert werden?Parallel Run (wenn idempotent)weiter
Nichts davon trifft zu?erst Voraussetzungen schaffen (Tests, CI, Observability), dann neu bewerten

Die letzte Zeile ist die wichtigste, und die am häufigsten übersprungene. Wenn keine der Strategien passt, ist das kein Grund, die am wenigsten schlechte zu nehmen. Es ist ein Grund, erst das Fundament zu legen – Testabdeckung, eine Pipeline, Metriken – und dann noch einmal zu schauen. Das Zwei-Personen-Team wollte diese Zeile überspringen. Genau das kostet später Monate.

Und die Voraussetzungen pro Strategie noch mal kompakt:

StrategieMindestvoraussetzung
Big-Bangvollständige Testabdeckung, Rollback bis DB-Ebene, planbare Downtime
Strangler-FigFeature-Flags, Observability pro Route, Proxy-fähige Architektur
Branch-by-AbstractionTestabdeckung des Moduls, Disziplin beide Pfade zu halten
Parallel Runidempotenter Pfad, Diff-Logging mit klarem Besitzer

Stolperfallen

Strangler ohne Observability ist Blindflug. Man migriert Route für Route und merkt Verhaltensänderungen erst, wenn der Support anruft. Die Metriken pro Route sind keine Kür, sie sind die Bedingung, unter der das Pattern überhaupt sicher ist.

Branch-by-Abstraction, bei der nach drei Monaten niemand mehr weiß, welcher Pfad live ist. Der Schalter war als Provisorium gedacht und ist inzwischen Architektur. Beide Implementierungen driften auseinander, Bugfixes landen mal hier, mal da. Regel: Ein Branch-by-Abstraction-Schalter braucht ein Ablaufdatum im Ticket, nicht nur im Kopf.

Parallel Run, bei dem die Diff-Auswertung niemandem gehört. Das Logging läuft, die Abweichungen sammeln sich, und niemand schaut hin. Nach zwei Monaten schaltet man den neuen Pfad scharf „weil ja nichts eskaliert ist“ – dabei hat nur niemand die Warnungen gelesen. Ein Parallel Run ohne benannten Auswerter ist teurer Selbstbetrug.


Wenn Sie vor einer Migration stehen und unsicher sind, welche der vier zu Ihrem Fall passt: Die Entscheidung ist meist in einer Stunde geklärt, wenn man die richtigen Fragen in der richtigen Reihenfolge stellt. Genau das mache ich am Anfang jedes Mandats, bevor eine einzige Zeile migriert wird. Termin buchen.