TL;DR: Strangler Fig pattern umožní migrovat zastaralou aplikaci po částech – tedy bez big-bang rewritu a bez výpadku. Nový systém obaluje ten starý a doménu po doméně přebírá provoz.
Jak už jsem psal dřív: Rewrite je svůdná past. Sedíte nad legacy aplikací, kterou nikdo nechce udržovat, a říkáte si: přepíšu to od nuly, tentokrát správně. Slýchám to pravidelně (a kdysi jsem tomu podlehl i sám) a bohužel pravidelně to dopadá špatně.
Joel Spolsky to popsal už v roce 2000: rewrite je nejnebezpečnější věc, kterou vývojáři mohou udělat. Přepíšete vše, včetně bugů, které se po čase staly funkcemi, a workaroundů, na kterých uživatelé stavějí svoje workflow. Po celou dobu přepisování stará aplikace dostává minimální údržbu a ta nová ještě nepřináší hodnotu, protože není hotová, takže firma stagnuje.
Existuje ale třetí cesta mezi „žít s tím dál“ a „přepsat to celé“: Strangler Fig pattern. Migrace po částech, doménu po doméně, bez jediné minuty výpadku. Tenhle pattern používám na legacy PHP projektech a v tomhle článku projdu celý proces – co to je, jak postupuju krok za krokem, kde mi pomáhá AI a kdy pattern naopak nepoužít.
Co je Strangler Fig?
Pattern pojmenoval Martin Fowler v roce 2004 podle fíkovníku banyánového (někdy jako „strom škrtič“ a v angličtině právě strangler fig). Vyklíčí v koruně hostitelského stromu, roste dolů, obalí kmen a původní strom nakonec zevnitř odumře.
Nový systém se pak chová stejně. Nejprve začne od původního přebírat jednotlivé části, až ten starý nakonec úplně vytlačí.
V praxi to znamená, že nový systém nekopíruje staré rozhraní, ale obaluje ho proxy vrstvou. Volání přichází nejdřív do proxy, která rozhodne, jestli to je směrované na nový systém, nebo na starý.
flowchart LR
Browser["Prohlížeč / API klient"]
Proxy["Proxy / Router\n(Nginx + Symfony)"]
New["Nový systém\n(Symfony)"]
Old["Starý systém\n(legacy PHP)"]
Browser --> Proxy
Proxy -->|"Nová doména\n(kontakty, uživatelé)"| New
Proxy -->|"Stará doména\n(příležitosti, integrace)"| Old
New -->|"Anti-corruption layer\n(ACL)"| Old
Tohle je jen princip. Jak to vypadá konkrétně (co přesně kam teče v jednotlivých fázích) a jak se architektura postupně mění ukazuju v diagramech níž v textu.
Klíčové vlastnosti tohoto přístupu:
- Postupný přechod – nikdy nemusíte mít oba systémy v dokonalé synchronizaci. Migrace probíhá doménu po doméně.
- Žádné výpadky – proxy přepne provoz na novou část bez výpadku, protože starý systém stále běží.
- Možnost okamžitě vše vrátit – pokud nová část selže, proxy přepnete zpátky. Rollback je jen otázka konfigurace, ne databázových migrací.
- Okamžitá hodnota – každá migrovaná doména rovnou něco přinese: rychlejší odezvu, lepší zabezpečení, kód, který jde konečně otestovat. Nemusíte čekat na kompletně „hotový“ systém.
Jak provádět migraci?
Tohle je postup, kterým migraci dělám. Pět fází, od inventarizace po finální odstžihnutí
Fáze 1: Inventarizace toho, co vlastně vůbec mám
Než napíšu první řádek nového kódu, musím vědět, co migruju. Tohle je krok, který když se přeskočí (přece vím, co migruju ne?), tak vás to dokáže pořádně dohnat.
Začínám třemi věcmi a velkou část každé z nich dneska udělá AI. Já pak rozhoduju a hlavně kontroluju výstup.
Dependency mapa. Vezmu Claude Code, nechám ho celý původní kód projít a sestavit graf závislostí. Výsledek bývá někdy deprimující, soubory se závislostmi, kde skoro každý závisí na řadě dalších. Často je to kruhový graf. U zdravého kódu by graf měl téct jedním směrem — vyšší vrstvy závisí na nižších (což u legacy systémů často není). Jenže přesně tohle potřebuju vidět, protože to ukáže části aplikace, kde jsou závislosti slabší a tam s migrací začínám.
Databázová analýza. Kolik tabulek, kolik z nich je bez cizích klíčů, kde jsou relace schované v PHP místo v databázi, kde chybí primární klíče, indexy atd. Tohle často ukáže, kde jsou přirozené hranice kontextů (zde kontextem myslím bounded context). I tady AI vytáhne různé anomálie. Někdy se hranice kontextů v datech (oproti těm v kódu) rozcházejí, ale to je také praktická informace.
Traffic analýza. Nginx access logy za posledních pár týdnů. Zjistím, které URL jsou nejpoužívanější, jaký je poměr čtení vs. zápisů, kde jsou peaky. Typicky se ukáže, že drtivá většina provozu jde na pár nejčastějších operací. To jsou kandidáti na migraci jako první.
Z inventory sestavím prioritizační matici: jaká doména má nejvyšší provoz, nejnižší komplexnost závislostí a největší potenciální přínos migrace. Například:
| Doména | Provoz | Závislosti | Přínos migrace | Priorita |
|---|---|---|---|---|
| Modul kontaktů | Vysoký | Nízké | Výkon, bezpečnost | 1 |
| Uživatelé a práva | Střední | Střední | Bezpečnost | 2 |
| Příležitosti a pipeline | Střední | Vysoké | Bezpečnost, výkon | 3 |
| Integrace s externími systémy | Nízký | Střední | Nízký | 4 |
| Reporting | Nízký | Nízké | Nízký | 5 |
Vyhrává doména, která vyjde ideálně dobře ve všech třech sloupcích zároveň – vysoký provoz, nízké závislosti, jasný přínos.
Fáze 2: Anti-corruption layer
Zde opět navazuju na předchozí článek, kde to popisuju více do detailu (sice v jiném kontextu, ale nový systém je pro starý taky cizí, a proto je potřeba je touto vrstvou propojit).
Anti-corruption layer (ACL) je vrstva mezi starým a novým systémem, která překládá koncepty. Starý systém má třeba contact_id jako číslo v URL parametru a nový systém pracuje s ContactId jako s value objectem. ACL dělá překlad – nový systém nikdy „nevidí“ interní reprezentaci toho starého.
ACL v praxi vypadá například takhle: Symfony služba, která volá starý systém přes jeho databázi nebo přes interní HTTP API, které na starý systém přidám právě pro tenhle účel. Drtivou většinu boilerplate ACL adapterů a mapperů dnes udělá AI – po pár prompt-tuninzích dostávám funkční kód, který mi stačí doladit.
// ACL: překládá starý contact_id na nový ContactId value object
// a načítá data z legacy databáze přes Doctrine "legacy" entity manager
final class LegacyContactAdapter implements ContactRepositoryInterface
{
public function __construct(
private readonly EntityManagerInterface $legacyEntityManager,
private readonly ContactMapper $mapper,
) {}
public function findById(ContactId $id): ?Contact
{
// Čte z legacy tabulky, ale vrací nový domain objekt
$legacyRow = $this->legacyEntityManager
->getRepository(LegacyContact::class)
->find($id->toInt());
if ($legacyRow === null) {
return null;
}
return $this->mapper->toDomain($legacyRow);
}
}
Každé takové rozhodnutí (volba Symfony, dvě databáze, design ACL) si od prvního dne zapisuju jako ADR. Má to dvojí smysl: pojistka proti vlastní zapomnětlivosti, a v dnešní době hlavně pro AI kontext, aby pozdější fáze nešly proti dřívějším rozhodnutím.
Fáze 3: První bounded context
První na řadě je vždy ta nejizolovanější doména s vysokým provozem (z prioritizační matice). Symfony Bundle, Doctrine entity, repository interface s implementací pro legacy databázi (přes ACL) i pro novou databázi.
Synchronizační období. Zápisy zatím míří pořád jen na starý systém do MySQL – nový systém z nich rovnou čte. ACL data ze starého MySQL průběžně propisuje do nového schématu, takže nová databáze zůstává v konzistenci, aniž bych musel zápisy duplikovat na úrovni aplikace. Proxy Nginx posílá čtecí requesty (GET) na nový Symfony endpoint, ale zápisy dál na starý systém.
flowchart TD
Sync["Externí synchronizace\n(email, ERP)"]
OldDB["Legacy databáze\n(MySQL)"]
ACL["Anti-corruption layer\nsynchronizace dat\n(Symfony service)"]
NewDB["Nová databáze\n(PostgreSQL)"]
Nginx["Nginx proxy"]
OldApp["Starý PHP\n(write operace)"]
NewApp["Symfony\n(read operace)"]
Browser["Browser"]
Sync --> OldApp
OldApp --> OldDB
OldDB --> ACL
ACL --> NewDB
Browser --> Nginx
Nginx -->|"GET /kontakt/*"| NewApp
Nginx -->|"POST příležitost, aktivita"| OldApp
NewApp --> NewDB
Čtení (GET) už obsluhuje nový systém, zápisy i externí synchronizace pořád starý – to je to synchronizační období. Celkový pohled na systém, kde takhle běží víc modulů naráz, je níže.
A ještě k ACL: tady ukazují šipky tok dat (starý -> nový), ne směr volání. Vrstva pořád patří novému systému – jen už nečte legacy za běhu jako ve fázi 2, místo toho data průběžně propisuje do nového schématu.
Než přepnu proxy, ověřím konzistenci dat automatickými testy. Jednoduchý PHP script spouštěný cronem: vezme náhodné contact_id z legacy databáze, zavolá starý i nový endpoint a porovná JSON output. Pokud se liší, tak test selže. Tenhle script umí zachytit rozdíl v byznys logice ještě před přepnutím.
Po ověření přepnu proxy: všechny GET requesty na /kontakt/* a /firma/* jdou na Symfony. Starý systém zatím obsluhuje zbytek. No, a tohle je klíčový moment pro klienta – první měřitelný výsledek. Obchodní tým si rychlejší karty zákazníka všimne hned ráno po deployi, a u klienta tím získáte důvěru, že to skutečně půjde dotáhnout a že to má smysl.
Fáze 4: Těžší moduly a feature flagy
Další moduly bývají provázanější. Modul uživatelů (účty, role, oprávnění) a modul příležitostí, kde jedna „příležitost“ v legacy systému není jedna entita, ale sada tabulek propojených přes byznys logiku v PHP, ne přes cizí klíče.
U těchto modulů nepřepínám celou doménu najednou. Místo toho jdu přes feature flagy: nový workflow vidí nejdřív malé procento uživatelů, zbytek jede po starém a postupně podíl zvyšuju. Tomu se říká Canary deployment a díky tomu případnou chybu zachytím při nižším využití systému. Zde taky závisí na tom, kolik lidí systém používá, aby to vůbec dávalo smysl.
// Feature flag rozhoduje, která implementace obslouží request
// Flag v Redis, updatovatelný bez deploye
final class OpportunityController extends AbstractController
{
public function __construct(
private readonly FeatureFlags $flags,
private readonly NewOpportunityService $newWorkflow,
private readonly LegacyOpportunityBridge $legacyWorkflow,
) {}
public function create(Request $request): Response
{
if ($this->flags->isEnabled('new_opportunity', $this->getUser())) {
return $this->newWorkflow->initiate($request);
}
// Přeposílá na starý systém přes interní proxy
return $this->legacyWorkflow->proxy($request);
}
}
Implementace flagu nemusí být složitá. V Symfony stačí Redis klíč s procentem a přiřazení uživatele, aby ten samý uživatel byl vždy ve stejné skupině:
// Jednoduchý feature flag přes Redis
// Flag: "contacts_v2_rollout_percent" => 5 (int, 0-100)
final class FeatureFlags
{
public function isEnabled(string $flag, ?User $user = null): bool
{
$percent = (int) $this->redis->get($flag . '_percent');
if ($percent === 0) return false;
if ($percent === 100) return true;
// Deterministické přiřazení uživatele (stejný user vždy ve stejné skupině)
$userId = $user?->getId() ?? random_int(1, PHP_INT_MAX);
return ($userId % 100) < $percent;
}
}
Při migraci uživatelů se vyplatí ještě jeden trik: lazy migration dat v okamžiku interakce. Když legacy systém hashoval hesla MD5, nemusím (a zde ani nemohu) je migrovat dávkově. Při prvním přihlášení uživatele přes nový systém heslo rehashuju na bcrypt a uložím do nové databáze. Za pár týdnů má většina uživatelů hesla zmigrovaná.
Nové datové schéma navrhuju „správně“ od začátku – aggregát s value objekty, stavy jako enum, audit log jako vlastní entita. Sada legacy tabulek se tak zkonsoliduje na pár čistých tabulek s cizími klíči a explicitními constrainty.
Fáze 5: Integrace, reporting, finální odstřihnutí
Modul integrací (externí synchronizace s emailem, ERP, kalendáři, atd.) bývá relativně izolovaný a má nejjasnější hranice. Externí systém pošle data, integrace je transformuje a uloží, hotovo. Žádné sdílené session stavy, žádné obří transakce. Přesně takový části je radost migrovat.
Reporting nechávám obvykle na konec, protože bývá nejméně urgentní a po migraci dat se přepíše snadněji, díky CQRS read modelu na PostgreSQL, denormalizované tabulky pro reportovací dotazy.
Finální odstřihnutí: Nginx přestane mít routing pravidla pro starý systém. Legacy PHP aplikaci nechám pro jistotu spuštěnou ještě pár týdnů jako záchrannou síť, ale žádný request na ni nejde.
Pak ji vypnu.
Kdy Strangler Fig nepoužít?
Strangler Fig se nehodí na všechno.
Aplikace bez viditelných modulů. Strangler Fig předpokládá, že dokážete identifikovat hranice domén – části systému, které lze migrovat samostatně. Pokud je aplikace skutečně jeden velký špagety blob bez jakékoliv přirozené separace, tak inventarizace (fáze 1) ukáže, že žádné ohraničené kontexty neexistují. V takovém případě je Strangler Fig téměř nemožný.
Bez senior architekta (a AI nepomůže). Strangler Fig vyžaduje architektonická rozhodnutí, která musí být konzistentní přes celou dobu migrace. Kdo navrhne ACL? Kdo rozhodne, kde jsou hranice bounded contexts? Kdo uhlídá, že nový systém nepřejímá technický dluh starého? Bez zkušeného architekta hrozí, že nový systém začne kopírovat anti-patterny starého, jen v modernějším PHP. AI vám pomůže s exekucí, ale rozhodovat za vás nebude.
Extrémní časový tlak. Strangler Fig je práce na měsíce (záleží na velikosti projektu zamozřejmě). Pokud byznys říká „za měsíc musí být hotovo nebo končíme“, tak tohle není cesta.
Shrnutí
Strangler Fig není zadarmo – je to více práce na infrastruktuře (proxy, synchronizace dat, ACL …) a delší čas. Co za to ale dostanete, je riziko rozložené do malých kroků místo jedné velké sázky na buď a nebo.
Je to prostě disciplinovaný a zdlouhavý proces, žádná magie. Nový systém začne na okrajích starého, obalí ho proxy vrstvou a doménu po doméně přebírá provoz. Inventory ukáže, kde začít. ACL drží nový systém čistý od konceptů starého. Feature flagy rozloží riziko do malých kroků. A starý systém běží celou dobu, takže byznys nestagnuje.