Jeder Symfony-Monolith, in den ich gerufen wurde, hat dieselben drei Symptome. Deployments dauern länger als früher. Die Test-Suite hat eine Handvoll Dateien, die niemand mehr lokal laufen lässt. Und irgendwer, meistens im Vorstand, hat kürzlich das Wort Rewrite ausgesprochen.
Ein Rewrite ist nahezu immer die falsche Antwort. Nicht, weil Rewrites nie gelingen (sie tun es manchmal), sondern weil sie ein konkretes Problem, das Sie verstehen, gegen ein abstraktes tauschen, das Sie nicht verstehen. Sie wissen, dass der Monolith langsam zu deployen ist. Sie wissen nicht, ob das Greenfield-System in achtzehn Monaten produktionsreif ist, ob Ihre besten Engineers für die Transition bleiben, oder ob die Form des Geschäfts dieselbe sein wird, wenn Sie fertig sind.
Das Strangler-Fig-Pattern, von Martin Fowler nach einer Pflanze benannt, die ihren Wirt langsam umhüllt, ist die Antwort, die Kontakt mit der Realität übersteht. Sie lassen den Monolithen laufen. Sie routen spezifische Fähigkeiten auf neuen Code um, eine nach der anderen, hinter denselben URLs. Über Monate wächst das neue System. Über weitere Monate schrumpft das alte. Irgendwann stirbt der Wirt, und die Feige ist, was übrig bleibt.
In diesem Essay geht es darum, wie Sie das tatsächlich innerhalb einer Symfony-Codebasis umsetzen. Nicht die Diagramm-Version. Die Version, in der Sie ein src/AppBundle/ aus 2016 haben, eine services.yml, die immer noch .yml verwendet, und eine Jenkins-Pipeline, an die sich niemand vollständig erinnert.
Was der Strangler Fig tatsächlich ist
Das Pattern hat drei bewegliche Teile:
- Einen Routing-Seam. Einen einzelnen Ort vor dem Monolithen, an dem Sie pro Request entscheiden können, ob der alte Code ihn bearbeitet oder ob Sie ihn auf neuen Code umleiten. Das ist meistens auf HTTP-Ebene, manchmal auf Controller-Ebene.
- Herausgelöste Fähigkeiten. Jede Fähigkeit, die Sie abschälen (Billing, Suche, Notifications, ein Admin-Modul), wird entweder ein Service, ein Bounded Context im selben Repo oder eine separate Anwendung, abhängig davon, wo die Ownership-Grenzen liegen.
- Ein Schrumpfungsplan. Der einzige Weg, wie sich das Pattern auszahlt, ist, wenn der alte Code tatsächlich gelöscht wird. Die meisten gescheiterten Strangler-Fig-Projekte scheitern hier, nicht an der Extraktion.
Der Grund, warum Leute das Pattern verpassen, ist, dass sie sich auf den mittleren Schritt konzentrieren, die Service-Extraktion, und den Routing-Seam und den Lösch-Plan überspringen. Beide sind die Stellen, an denen der Wert liegt.
Warum dieses Pattern zu Symfony speziell passt
Symfony hat zwei Eigenschaften, die den Strangler Fig im Vergleich zu etwa einem Rails- oder Django-Monolithen ungewöhnlich handhabbar machen:
- HttpKernel ist komponierbar. Sie können den bestehenden Kernel wrappen, Requests abfangen und an einen anderen Kernel oder Handler dispatchen, ohne einen der Controller umzuschreiben, die er bereits kennt. Das
HttpKernelInterfaceist der Seam. - Der DI-Container ist explizit. In einer großen Symfony-App haben Sie bereits ein Inventar, ob Sie es wissen oder nicht: services.yaml-Dateien,
#[AutoconfigureTag]-Attribute, Compiler Passes. Dieses Inventar zeigt Ihnen die tatsächliche Kopplungskarte, was ehrlicher ist als jedes Architektur-Diagramm.
Was Symfony Ihnen nicht kostenlos mitgibt, ist ein sauberer Split der Persistenz. Doctrines EntityManager wird standardmäßig über Ihre gesamte App hinweg geteilt. Hat Ihre Legacy-Order-Entity eine bidirektionale Beziehung zu Customer, Invoice, Subscription und drei enum-gestützten Value Objects, ist dieser Objektgraph der eigentliche Monolith, nicht der Code.
Genau dort findet der größte Teil der Strangler-Fig-Arbeit tatsächlich statt. Deshalb dauert das Pattern Monate, nicht Wochen.
Die fünf Schritte, die es funktionieren lassen
Über eine Handvoll dieser Projekte hinweg bin ich bei denselben fünf Schritten gelandet, ungefähr in dieser Reihenfolge. Einen davon zu überspringen ist der Punkt, an dem Projekte entgleisen.
1. Pflanzen Sie den Routing-Seam, bevor Sie irgendetwas herausziehen
Bevor Sie einen einzelnen Controller anfassen, fügen Sie eine Schicht vor den Kernel ein, die Requests entweder an die Legacy-App oder an neuen Code routen kann. In Symfony ist das ein Kernel-Request-Listener, der mit sehr hoher Priorität subscribed.
#[AsEventListener(event: KernelEvents::REQUEST, priority: 512)]
final readonly class LegacyRouter
{
public function __construct(
private RouteMatcherInterface $newApp,
private LoggerInterface $logger,
) {}
public function __invoke(RequestEvent $event): void
{
if (!$event->isMainRequest()) {
return;
}
if (!$this->newApp->matches($event->getRequest()->getPathInfo())) {
return;
}
$this->logger->info('Routing {path} to new app', [
'path' => $event->getRequest()->getPathInfo(),
]);
// Dispatch to new handler, set response on the event.
}
}
Der Seam muss noch nichts tun. Er muss existieren, durch Tests abgedeckt sein und in Produktion deployt sein. Sobald er da ist, haben Sie die Frage geändert von “wann schreiben wir neu?” zu “welchen Pfad migrieren wir als Nächstes?”. Das ist eine viel bessere Frage.
2. Machen Sie Authentifizierung und Session geteilt, nicht migriert
Der zweite Schritt ist der, der die meiste Zeit spart. Versuchen Sie nicht, die Authentifizierung früh zu migrieren. Behalten Sie, was das Legacy-System verwendet (Symfonys Session, ein eigenes Cookie, eine Session-Tabelle in der Datenbank), und lassen Sie den neuen Code sie lesen.
Der Fehlermodus hier ist nahezu universal. Teams entscheiden, dass der neue Code ab Tag eins einen “richtigen” JWT-basierten Auth-Layer “verdient”. Achtzehn Wochen später reconcilen sie immer noch drei User-Tabellen, eine user_sessions-Tabelle, die niemand zu droppen wagt, und einen Password-Reset-Flow, der durch beide Apps geht. Währenddessen wurde keine kundenseitig sichtbare Arbeit ausgeliefert.
Die richtige Sequenz lautet: auf der Legacy-Session laufen, solange die Legacy-App existiert, dann den Auth-Layer ersetzen, wenn die alte App klein genug ist, um ein Rundungsfehler zu sein.
3. Definieren Sie Datenownership und erzwingen Sie sie im ORM
Das ist der Schritt, der die meisten Projekte killt. Auch nachdem Sie eine Fähigkeit in einen separaten Service oder Bundle herausgezogen haben, haben Sie nichts getan, wenn er immer noch dieselben Doctrine-Entities liest und schreibt wie der Legacy-Code. Sie haben nur einen neuen Caller zu einer geteilten Datenbank hinzugefügt.
Die praktische Lösung in Symfony: Geben Sie jedem neuen Bounded Context seinen eigenen Entity Manager, auch wenn er auf dieselbe Datenbank zeigt. Verwenden Sie die doctrine.orm.entity_managers-Konfiguration, um einen zweiten Manager zu definieren, der nur auf seine Entities gescoped ist, und markieren Sie den Legacy-Manager als verboten innerhalb des neuen Contexts.
doctrine:
orm:
entity_managers:
legacy:
mappings:
Legacy: ~
billing:
mappings:
Billing: ~
Erzwingen Sie dann eine Lint-Regel (Deptrac ist dafür ideal), die sagt, dass der Billing\-Namespace nicht auf Legacy\ verweisen darf. Die Grenze muss für den Compiler sichtbar sein, nicht nur für das Wiki.
Sobald die Lint-Regel steht, werden Sie Dinge entdecken, die Sie nicht wussten. Dieser “einfache” Rechnungs-PDF-Generator greift in User, Address, TaxProfile und ein Legacy-Settings-Singleton. Dieses Hineingreifen ist die eigentliche Extraktionsarbeit. Der Rewrite, sobald die Abhängigkeiten entwirrt sind, ist meistens der einfache Teil.
4. Verwenden Sie einen Messaging-Bus für kontextübergreifende Kommunikation
Sobald Sie mehr als einen Context haben und sie miteinander reden müssen, widerstehen Sie dem Drang, das synchron über Services über die Grenze hinweg zu injizieren. Nutzen Sie Symfony Messenger.
Ein Billing-Context, der wissen muss, wann ein Benutzer deaktiviert wurde, sollte nicht UserService::deactivate() aufrufen. Er sollte eine UserDeactivated-Message abonnieren, die der User-Context veröffentlicht. Der Transport kann als sync:// starten. Sie brauchen noch keine echte Queue. Was Sie brauchen, ist, dass die Form der Kommunikation message-basiert ist, damit sich, wenn Sie die Apps aufteilen, an den Konsumenten nichts ändert.
Das ist der Teil des Patterns, den die meisten Reviewer kontraintuitiv finden. “Aber wir sind im selben Prozess, warum machen wir uns die Mühe mit async?” Weil der Sinn des Strangler Fig ist, den späteren Split günstig zu machen. Jeder synchrone kontextübergreifende Aufruf, den Sie jetzt hinzufügen, ist eine Schnur, die Sie dann durchschneiden müssen, meistens im schlechtmöglichsten Moment.
5. Liefern Sie Feature Flags aus, bevor Sie den herausgelösten Code ausliefern
Der letzte Schritt ist operativ, nicht architektonisch. Jede neue Fähigkeit sollte hinter einem Feature Flag live gehen, den Sie per User, per Tenant oder per Prozentsatz togglen können. Liefern Sie den neuen Code mit ausgeschaltetem Flag aus. Schalten Sie ihn für Ihre internen Accounts ein. Dann für 1% des Traffics. Dann 10%. Dann 100%. Dann, und erst dann, löschen Sie den Legacy-Code-Pfad.
Symfony hat keine kanonische Feature-Flag-Library: growthbook/growthbook, flagception/flagception-bundle oder ein selbstgebauter Service, gestützt auf Environment-Variablen, funktionieren alle. Die Library ist egal. Die Disziplin zählt: der Flag ist die Übergabe, nicht das Deployment.
Fallen, die Strangler-Fig-Projekte töten
Ich habe dieses Pattern häufiger scheitern als gelingen sehen, und die Fehlschläge clustern:
- Das Falsche zuerst herausziehen. Das Team pickt sich das technisch interessanteste Modul heraus, meistens Suche oder eine Reporting-Engine, und stellt nach drei Monaten fest, dass es niemanden interessiert. Picken Sie etwas mit sichtbarem Kundenschmerz und klarem Geschäftswert. Der Executive-Sponsor muss den Monolithen tatsächlich schrumpfen sehen.
- Kein Lösch-Budget. Engineering reserviert 40% der Sprint-Kapazität für Extraktion und 0% für Löschung. Der alte Code bleibt für immer liegen, ein Paralleluniversum, das jetzt auch noch Wartung braucht. Die Regel, die ich durchsetze: kein Extraktions-PR wird gemerged ohne einen begleitenden Lösch-Plan mit Datum.
- Den Seam nur vortäuschen. Der Routing-Layer existiert in Staging, aber in Produktion wird er umgangen, weil “der Load Balancer macht das schon”. Sie haben jetzt zwei Routing-Layer, von denen keiner kanonisch ist, und jeder Incident erfordert das Verständnis beider.
- Entity-Graph-Migration durch Zermürbung. Der neue Context “besitzt”
Invoice, aber die Legacy-App liestinvoice.customer_idan sechs Stellen immer noch direkt. Jedes Quartal fügt jemand eine siebte hinzu. Die Grenze muss im Code erzwungen werden, nicht per Policy. - Stiller Drift. Der neue Code bekommt einen Linter, strikte Typisierung, PHPStan Level 9 und Tests. Der Legacy-Code nicht. Über zwei Jahre klafft die Lücke so weit auf, dass niemand den alten Code überhaupt noch anfassen will, was bedeutet, er lässt sich auch nicht löschen. Halten Sie beide Codebasen am selben Maßstab, oder seien Sie explizit darin, dass der Legacy-Code im Hospiz liegt und niemand ihn editiert.
Wie Sie Fortschritt messen
Sie können nicht erdrosseln, was Sie nicht zählen können. Ich bestehe darauf, dass ab Woche eins drei Zahlen in einem Dashboard sichtbar sind:
- Prozentsatz der Produktions-Requests, die von neuem Code bedient werden. Gemessen am Routing-Seam, nach URL-Pattern gebucketet. Diese Zahl sollte nur steigen.
- Zeilen Legacy-Code. Lassen Sie
clocwöchentlich über den Legacy-Namespace laufen. Feiern Sie, wenn er sinkt. Untersuchen Sie, wenn er steigt. - Anzahl kontextübergreifender Aufrufe. Der Zähler der Deptrac-Verletzungen. Der sollte gegen null tendieren.
Ist eine der drei Zahlen zwei Monate in Folge flach, stimmt etwas nicht. Meistens bedeutet es, dass das Team Extraktionsarbeit leistet, die den tatsächlichen Abhängigkeitsgraphen nicht berührt: Dateien umschichten, Services umbenennen, Interfaces hinzufügen, die darunter immer noch auf dieselben Legacy-Klassen zeigen.
Wann Sie dieses Pattern NICHT verwenden sollten
Der Strangler Fig ist nicht immer richtig. Ich würde Sie davon weglenken, wenn:
- Der Monolith unter 30k Zeilen Code hat und weniger als vier Engineers. Refactoren Sie ihn einfach vor Ort. Der Koordinations-Overhead paralleler Systeme lohnt sich in dieser Größenordnung nicht.
- Sie gerade dabei sind, das Produkt substanziell zu pivotieren. Strangler Fig ist von Natur aus konservativ. Er bewahrt bestehendes Verhalten. Ändert sich das Verhalten selbst gerade radikal, ist ein gezielter Rewrite der Teile, die sich ändern werden, während der Rest in Ruhe gelassen wird, manchmal schneller.
- Das Datenmodell selbst kaputt ist. Ist das Problem, dass Ihre
orders-Tabelle 180 Spalten hat und kein Kunde tatsächlich zu denen passt, zu denen das Schema meint, dass er passt, hilft es nicht, Services um diese Tabelle herum herauszuziehen. Reparieren Sie zuerst das Datenmodell.
Ein realistischer Erster-Monat-Plan
Werde ich an Tag eins in einen Symfony-Monolithen geworfen und bekomme einen Monat, ist das, was ich ausliefere:
- Woche 1: Audit. PHPStan laufen lassen, Rector-Dry-Run, Deptrac mit einem Ein-Layer-Baseline. Einen Abhängigkeitsgraphen erzeugen, der die tatsächliche Kopplung zeigt. Die drei Kandidaten-Fähigkeiten identifizieren, die zuerst herausgezogen werden, nach Geschäftsschmerz gerankt.
- Woche 2: Den Routing-Seam pflanzen. In Produktion deployen, noch ohne angehängte Routen. Die Observability (Per-Pfad-Request-Counts, Legacy vs. Neu) in ein Dashboard legen.
- Woche 3: Die kleinste Kandidaten-Fähigkeit auswählen. In einen eigenen Namespace mit eigenem Entity Manager herausziehen. Hinter einem Feature Flag bei 0% Rollout halten. Contract Tests schreiben, die beweisen, dass das Verhalten Byte für Byte dem Legacy-Pfad entspricht.
- Woche 4: Den Flag hochziehen. 1%. 10%. 50%. 100%. Dann, und das ist der Teil, den Teams überspringen, den Legacy-Code-Pfad löschen. Den Lösch-PR im selben Sprint mergen, in dem der Flag 100% erreicht.
Am Ende des Monats haben Sie die Schleife bewiesen: picken, extrahieren, routen, löschen. Jede zukünftige Fähigkeit folgt derselben Schleife. Das Projekt ist nicht fertig, aber es ist jetzt eine vorhersagbare Maschine statt eines offenen Rewrites.
Schauen Sie auf einen Symfony-Monolithen und wägen einen Rewrite gegen eine Strangler-Fig-Migration ab, dann ist mein Engagement zur Monolith-Modernisierung genau um diese Schleife herum aufgebaut. Ein vierwöchiger Audit- und Seam-Pflanz-Sprint, dann Extraktion in der Kadenz, die Ihr Team halten kann, mit der Löschung in jedem PR eingebaut.
Referenzen
- StranglerFigApplication von Martin Fowler: der ursprüngliche Artikel zum Pattern für das schrittweise Ersetzen von Legacy-Systemen.
- Patterns of Legacy Displacement von Martin Fowler, Ian Cartwright, Rob Horn und James Lewis: eine längere Abhandlung zu Event Interception, Legacy Mimic und inkrementellem Cutover.
- Feature Toggles (aka Feature Flags) von Pete Hodgson: die kanonische Taxonomie von Feature Flags, inklusive Release Toggles für sicheres Rollout.
- Symfony HttpKernel component docs: Referenz für
HttpKernelInterface, den komponierbaren Request/Response-Seam, auf dem dieser Beitrag aufbaut. - Symfony multiple entity managers: offizielle Konfiguration, um Doctrine-Entity-Manager pro Bounded Context zu scopen.
- Symfony Messenger: der Message Bus für kontextübergreifende Kommunikation, inklusive des
sync://-Transports für In-Process-Handler. - Deptrac: statisches Analyse-Tool zum Deklarieren und Durchsetzen architektonischer Layer in PHP-Projekten.