Implementace CQRS (Command Query Responsibility Segregation) v Symfony 8 s využitím DDD principů – oddělení operací čtení a zápisu, optimalizace read modelů, řešení eventual consistency a stavba škálovatelných aplikací.
CQRS vychází z prostého pozorování: model, který slouží k zápisu dat, nemusí být tentýž model,
který slouží k jejich čtení. CQRS (Command Query Responsibility Segregation) tento princip
přenáší na úroveň architektury – popsal jej Greg Young
[1]
jako rozšíření Command-Query Separation (CQS) od Bertranda Meyera
[2].
V tradičních aplikacích používáme jednu entitu (např. Doctrine ORM entity)
pro obojí – vytváříme objednávku i zobrazujeme seznam objednávek přes tentýž objekt Order.
CQRS tuto zodpovědnost explicitně rozděluje do dvou oddělených modelů, z nichž každý
nese vlastní úkol a vlastní optimalizační profil.
CQRS se často kombinuje s Event Sourcing,
což je vzor, který místo aktuálního stavu ukládá historii změn jako sekvenci událostí.
Tyto dva vzory jsou však nezávislé. CQRS lze plnohodnotně implementovat
s klasickou Doctrine ORM persistencí na write straně a denormalizovanými tabulkami na straně čtení,
aniž by se sahalo po Event Sourcingu.
Bertrand Meyer formuloval princip Command-Query Separation (CQS) jako pravidlo
na úrovni metod: každá metoda by měla buď měnit stav (command), nebo vracet hodnotu (query),
ale nikdy obojí. CQS je návrhové pravidlo pro rozhraní tříd.
Greg Young posunul tuto myšlenku na architektonickou úroveň: CQRS není pravidlo
pro jednotlivé metody, ale rozhodnutí o struktuře celé aplikace. Místo jednoho doménového modelu
vznikají dva oddělené modely – každý s vlastní sadou tříd, vlastním úložištěm a vlastním
optimalizačním profilem.
V praxi se CQS přirozeně stává výchozím bodem pro CQRS. Pokud dodržujete CQS na úrovni metod,
zjistíte, že metody měnící stav (command methods) potřebují výrazně jiná data než ty,
které jej čtou (query methods). CQRS toto pozorování formalizuje rozdělením do dvou explicitních modelů.
CQRS přináší architektonické výhody zejména u aplikací
s netriviální doménovou logikou a odlišnými požadavky na čtení a zápis.
První výhodou je oddělení odpovědností. Write model nese doménovou logiku, validaci invariantů
a konzistenci dat; read straně zbývá jediný úkol – dostat data v podobě, jakou vyžaduje obrazovka.
Každý model obsahuje jen to, co ke své práci potřebuje, a lze ho optimalizovat nezávisle:
na straně zápisu normalizované relační schéma a Doctrine ORM entity s bohatou doménovou logikou,
na straně čtení denormalizovaná tabulka, Elasticsearch index nebo Redis cache – cokoli,
co nejlépe vyhovuje konkrétním dotazům. Z téhož oddělení plyne i volnost při evoluci:
read model jde kdykoli přebudovat (rebuild projekcí), doplnit o nový read model pro nový use case
nebo změnit strukturu dotazu – bez jakéhokoli dopadu na write model a doménovou logiku.
Dvě další výhody:
Škálovatelnost – Ve většině aplikací výrazně převažuje čtení nad zápisem
(řádově 10:1 až 100:1; zkušenostní heuristika, ne měřený standard). CQRS umožňuje nezávisle škálovat read stranu (repliky, cache, CDN)
bez dopadu na write stranu.
Testovatelnost – Command handlers se testují jako čistě doménová logika
(given state → when command → then events/state). U query handlerů se ověřuje jen správnost
vrácených dat. Žádné propletení obou odpovědností v jedné testovací sadě.
Viz kapitola Testování DDD kódu.
CQRS má své limity. Kompromisy, které přináší, je lepší znát ještě před zavedením.
Místo jednoho modelu existují dva (nebo více) a každý command či query vyžaduje vlastní
třídu, handler a často i vlastní datovou strukturu – pro jednoduchou CRUD operaci 4–6 tříd
místo jedné. Při oddělených úložištích se přidává synchronizace: read model se musí aktualizovat
po každé změně write modelu, aby se s ním nerozešel. Selhání propagace (výpadek fronty, chyba
projektoru) vede k divergenci modelů.
Sem patří i eventual consistency. Mezi zápisem a aktualizací read modelu vzniká okno,
kdy uživatel po odeslání formuláře vidí „starou“ verzi dat. Vzory pro UI popisuje
sekce Eventual Consistency.
Poslední cenou jsou nároky na zaučení týmu. CQRS vyžaduje změnu myšlení oproti tradičnímu přístupu,
kde jeden model pokrývá všechny operace. Vývojáři musejí porozumět konceptům jako message bus,
eventual consistency, idempotence handlerů a read model projekce.
Pro CQRS je na komponentě Symfony Messenger podstatná jedna schopnost: definovat
více message busů – jeden pro příkazy (command bus) a jeden pro dotazy (query bus).
Každý bus může mít vlastní sadu middleware, vlastní transport a vlastní strategii zpracování.
FIG. 12.5-ASymfony Messenger jako CQRS bus
Konfigurace definuje dva transporty: async pro zpracování přes frontu a sync pro okamžité vykonání v témže procesu.
Dva message busy: command.bus pro příkazy s doctrine_transaction
middleware (automatická transakce kolem handleru) a query.bus pro dotazy pouze s validací.
Commands v CQRS jsou příkazy, které mění stav systému. V Symfony 8 se implementují jako jednoduché
PHP třídy – immutabilní datové objekty (DTO), které nesou veškerá data potřebná pro vykonání operace.
Command sám o sobě neobsahuje žádnou doménovou logiku; je to pouhý přepravní kontejner dat.
Dobře navržený command má několik vlastností:
Je immutabilní (readonly properties) – po vytvoření se nemění.
Obsahuje validační atributy – díky middleware validation na command busu se command validuje ještě před předáním handleru.
Pojmenování vyjadřuje záměr – RegisterUser, PlaceOrder, CancelSubscription. Ne SaveUser nebo UpdateOrder.
Pracuje typicky s primitivními typy (string, int, float) nebo serializovatelnými hodnotovými objekty (např. OrderId, Money). Command musí jít bezpečně přenést přes asynchronní kanál.
Query se od commandu liší směrem toku dat: nemění stav systému, jen čte. Implementace
vypadá podobně – immutabilní DTO třída – s jedním rozdílem: query vždy vrací
hodnotu, kterou handler předá přes HandledStamp.
Dotaz nese jediné pole: ID uživatele, jehož profil chceme získat. Nevalidní UUID odmítne
validation middleware ještě před zpracováním.
Handler je místo, kde se zpráva potká s logikou. V Symfony 8 jde o třídu s atributem
AsMessageHandler a metodou __invoke().
Symfony Messenger automaticky spojí handler s jeho command/query podle type-hintu parametru.
Command handler a query handler mají odlišnou odpovědnost:
Command handler – Načte agregát z repozitáře, zavolá na něm doménovou metodu
(která validuje invarianty) a uloží změny. Může emitovat doménové události.
Pracuje s doménovým modelem (entity, value objects, repozitáře).
Query handler – Čte data z optimalizovaného zdroje (denormalizovaná tabulka,
Elasticsearch, cache) a vrací je jako ViewModel. Nepracuje s doménovým modelem
– obchází ho záměrně, protože doménový model není optimalizovaný pro čtení.
Rozdíl je vidět přímo v závislostech: command handler pracuje s doménovým modelem (UserRepository,
User entita, value objects), zatímco query handler sahá do read repozitáře
(UserProfileReadRepository), který vrací přímo ViewModel – jednoduchou datovou strukturu
optimalizovanou pro prezentaci. Query handler neprochází přes doménový model.
ViewModel (nebo Read Model) je datová struktura navržená výhradně pro potřeby konkrétního dotazu
nebo obrazovky. Na rozdíl od doménové entity neobsahuje žádnou doménovou logiku – je to čistě
prezentační objekt. Zatímco doménová entita User chrání invarianty a zapouzdřuje
chování, ViewModel UserProfileViewModel obsahuje přesně ta data, která potřebuje
šablona nebo API endpoint.
ViewModel často obsahuje data z více agregátů – v příkladu výše kombinuje
údaje o uživateli s počtem objednávek a členskou úrovní. Sestavení téhož pohledu přes doménový model by vyžadovalo
načtení uživatele, jeho objednávek a propočet úrovně – pomalé a porušující hranice
agregátů. Read model tato data drží
připravená v denormalizované podobě.
Zbývá dopravit příkazy a dotazy ke správnému handleru. V Symfony 8 se pro injektování busu
používá named autowiring – názvy parametrů v konstruktoru musejí odpovídat konfiguraci
v messenger.yaml:
V těchto příkladech Symfony přiřadí bus podle názvu parametru v konstruktoru: klíč
command.bus z konfigurace buses se namapuje na $commandBus.
Tím končí popis základní infrastruktury CQRS – příkazů, dotazů, handlerů a busů.
Následující sekce se věnují pokročilejším aspektům: optimalizaci read strany
pro konkrétní dotazy, eventual consistency a provozním problémům
v asynchronním prostředí.
Read strana má volnou ruku ve výběru struktury. Write model drží normalizaci kvůli konzistenci dat;
read model může jít opačným směrem – denormalizovat data přesně do tvaru, který obrazovka
nebo API endpoint očekává.
Strategie optimalizace read modelů
Denormalizované tabulky jako read model
Nejrozšířenější strategií v praxi je denormalizovaná tabulka,
která drží data předpočítaná pro jedinou obrazovku či endpoint.
Tabulka se aktualizuje asynchronně přes doménové události.
Kdo doménové události odešle
Projektor výše předpokládá, že mu události OrderPlaced či OrderShipped někdo
doručí. V nejjednodušší podobě je po flush() vyzvedne aplikační vrstva z agregátu
metodou releaseEvents() a dispatchne je na event bus – celý mechanismus popisuje
sekce Agregát a doménové události: lifecycle.
Pro vývoj a méně kritické projekce tato synchronní cesta stačí.
Má ale slabé místo: dispatch po flushi není atomický. Spadne-li proces mezi commitem
transakce a odesláním do fronty, událost se ztratí a projekce tiše diverguje od write
modelu. Produkční řešení ukládá události do outbox tabulky ve stejné transakci jako
agregát a do fronty je publikuje samostatný relay proces – podrobně v kapitole
Outbox Pattern.
Rebuild projekcí
CQRS s asynchronními projekcemi umožňuje kompletní rebuild read modelu.
Pokud se změní struktura denormalizované tabulky (nový sloupec, jiný formát dat), stačí:
Vytvořit novou verzi projekční tabulky.
Přehrát všechny relevantní události přes projektor.
Přepnout read dotazy na novou tabulku.
Smazat starou tabulku.
Tento přístup je realizovatelný pouze tehdy, jsou-li zdrojové události stále dostupné
(v Event Store nebo v message logu).
Bez Event Sourcingu je rebuild projekcí možný, ale musíte mít alternativní zdroj dat
(např. change data capture z write databáze).
Eventual consistency je nejčastějším zdrojem nejistoty při zavádění CQRS. Při asynchronní
propagaci změn z write strany na read stranu existuje časové okno (typicky
milisekundy až jednotky sekund), kdy read model ještě neodráží poslední zápis. Uživatel
odešle formulář, dostane potvrzení o úspěchu, ale seznam na další stránce ještě nezobrazuje
nový záznam.
Nejde o bug, ale o vlastnost distribuované architektury.
Následující diagram zachycuje celý datový tok – od zápisu přes asynchronní propagaci
až po čtení – a zvýrazňuje okno, ve kterém k eventual consistency dochází:
FIG. 12.12-AEventual consistency v CQRS toku
Konkrétnější časový pohled na to, kdy uživatel vidí 404 navzdory tomu, že command
proběhl úspěšně, je v následující sekvenci:
FIG. 12.12-BOkno zastaralosti – kdy GET vrátí 404 po úspěšném POST
Existuje několik osvědčených vzorů, jak eventual consistency v UI řešit:
Strategie řešení v UI
Read-your-writes na úrovni HTTP
Strategie z tabulky výše řeší vnímání uživatele v prohlížeči. API klienti potřebují
tvrdší záruku: „přečti si, co jsi právě zapsal“ (read-your-writes). Docílit jí lze
předáním pozice zápisu – odpověď na command nese číslo verze agregátu nebo offset,
na který se projekce musí dostat. Klient hodnotu pošle s následujícím dotazem,
typicky v hlavičce.
Čtecí endpoint porovná aktuální pozici projekce s požadovanou. Pokud projekce
ještě zaostává, krátce počká (desítky až stovky milisekund) a porovnání zopakuje.
Po vypršení limitu vrátí klientovi signál k opakování – 202 Accepted
s hlavičkou Retry-After, načež klient data po uvedené pauze načte znovu (refetch).
Stavový kód 304 se k tomu nehodí: znamená „vaše cache je platná“, ne „data ještě nejsou“.
Vzor se vyplatí jen na cestách, kde klient bezprostředně po zápisu čte tatáž data.
Plošné nasazení by čtecí stranu zatížilo čekáním, které většina dotazů nepotřebuje.
CQRS otevírá dveře asynchronnímu zpracování příkazů. V Symfony 8 se asynchronní
zpracování konfiguruje přes transporty v Messenger komponentě. Příkaz označený pro asynchronní
transport je při dispatchi serializován a zařazen do fronty; Messenger worker jej později
vyzvedne a předá handleru.
Tato konfigurace směruje příkazy pro odesílání e-mailů a generování reportů na asynchronní
transport s retry strategií (3 pokusy s exponenciálním backoffem). Pro kritické události
definuje samostatný transport async_priority_high s vlastní frontou – Messenger worker
pro tuto frontu může běžet s vyšší prioritou nebo na dedikovaném serveru.
Spolehlivé předání doménových událostí do fronty, atomické se zápisem agregátu,
zajišťuje Outbox Pattern.
Zpracování chyb se v asynchronním prostředí podstatně liší od synchronního světa.
Při synchronním dispatchi výjimka probublá přímo do controlleru a uživatel vidí chybovou
hlášku. Při asynchronním dispatchi je zpráva ve frontě – pokud handler selže, uživatel
o tom neví a zpráva musí být zpracována znovu.
Retry strategie
Symfony Messenger podporuje automatické opakování zpráv, které selhaly. Konfigurace
retry_strategy na transportu definuje, kolikrát a s jakým zpožděním
se handler znovu zavolá:
max_delay: 60000 – Maximální zpoždění (60 sekund).
Failed transport (Dead Letter Queue)
Když selžou všechny pokusy o retry, Messenger zprávu přesune na failed transport
(dead letter queue). Zprávy na failed transportu čekají na manuální zpracování –
vývojář je může prozkoumat, opravit příčinu chyby a znovu odeslat.
Middleware v Symfony Messenger tvoří řetěz komponent kolem handleru – zachycuje zprávu
před zpracováním a po něm. Tudy do dispatch cyklu vstupuje validace, logování,
transakce nebo autorizace, aniž by se musel měnit handler.
Vestavěné middleware validation a doctrine_transaction se objevily
v dřívější konfiguraci. Pro pokročilejší scénáře si můžete vytvořit vlastní middleware:
Na pořadí middleware záleží: v příkladu výše se logování provede jako první (zachytí
i validační chyby), následuje validace (odmítne nevalidní command ještě před zahájením
transakce) a nakonec doctrine_transaction (obalí handler do DB transakce).
CQRS usnadňuje testování. Command handlers, query handlers a projektory jsou izolované
komponenty s jasně definovanými vstupy a výstupy. Testovací strategie se liší
podle testované komponenty:
Testování command handlerů
Command handler se testuje jako unit test s mocknutým repozitářem. Ověřujete, že handler
správně validuje invarianty, volá doménový model a ukládá změny:
Testování query handlerů
Query handler se testuje na správnost mapování dat z read repozitáře na ViewModel.
Pro integrační testy s reálnou databází můžete ověřit i správnost SQL dotazů:
Testování projektorů
Projektory se nejlépe testují jako integrační testy s reálnou databází. Ověřujete,
že po zpracování sekvence událostí read model obsahuje očekávaná data:
Kompletnější přehled testovacích strategií pro DDD kód – včetně testování agregátů,
value objects a doménových služeb – najdete v kapitole
Testování DDD kódu.
Při použití CQRS s více Bounded Contexts
vzniká potřeba koordinovat dlouhotrvající procesy napříč kontexty.
Vzor Saga – v orchestrované podobě označovaný Process Manager – naslouchá
doménovým událostem a podle nich odesílá příkazy, čímž propojuje command a event stranu CQRS
do ucelených doménových procesů.
Podrobný výklad ság – včetně implementace v Symfony Messenger,
kompenzačních strategií a testování – najdete v kapitole
Ságy a Process Managery.
Časté otázky
Co je CQRS?
CQRS (Command Query Responsibility Segregation) je architektonický vzor, který rozděluje aplikaci na dva oddělené modely: write model pro změny stavu a read model pro dotazy. Write model se soustředí na doménovou logiku a validaci invariantů, read model na rychlou prezentaci dat uživateli. Každý model lze nezávisle optimalizovat i škálovat. Zformuloval jej Greg Young jako rozšíření staršího principu CQS od Bertranda Meyera. Viz úvodní sekce.
Jaký je rozdíl mezi CQS a CQRS?
CQS (Command Query Separation) je návrhové pravidlo na úrovni metod – každá metoda by měla buď měnit stav, nebo vracet hodnotu, ne obojí. CQRS (Command Query Responsibility Segregation) povyšuje tuto myšlenku na architektonickou úroveň: místo jednoho doménového modelu vznikají dva oddělené modely, každý s vlastními třídami, úložištěm i optimalizačním profilem. CQS je tedy princip ve třídě, CQRS rozhodnutí o struktuře celé aplikace. Více v sekci CQS vs. CQRS.
Kdy se vyplatí CQRS nasadit?
CQRS přináší hodnotu v aplikacích, kde se požadavky na zápis a čtení výrazně liší – například doménově bohatý write model s mnoha invarianty proti výrazně převažujícím dotazům, které potřebují denormalizovaná data. Uplatní se také tam, kde má čtení nezávislý škálovací profil (repliky, cache, full-text vyhledávání) nebo kde je hodnota v odděleném auditu změn. U jednoduchých CRUD operací zvyšuje počet tříd bez odpovídajícího přínosu. Podrobný rozbor ve Výhodách CQRS a Výzvách a omezeních.
Musím použít Event Sourcing, když používám CQRS?
Ne. CQRS a Event Sourcing jsou nezávislé vzory, které se často kombinují, ale každý z nich lze zavést samostatně. CQRS lze plnohodnotně implementovat s klasickou Doctrine ORM persistencí na write straně a denormalizovanými SQL tabulkami na read straně. Event Sourcing lze naopak zavést i bez CQRS – byť kombinace obou je v praxi běžná, protože si vzájemně prospívají. Rozbor vztahu obou vzorů v sekci Co je CQRS.
Jak se CQRS implementuje v Symfony?
Základním stavebním kamenem je komponenta Symfony Messenger, která funguje jako sběrnice pro příkazy a dotazy. Pro CQRS se obvykle definují dvě oddělené sběrnice (command.bus a query.bus), každá s vlastní sadou handler tříd a middleware. Příkazy mění stav a nevracejí data; dotazy vracejí ViewModely (read modely) a stav nemění. Asynchronní zpracování lze zapnout přes transport, což umožňuje dlouhé operace vytáhnout z request-response cyklu. Více v sekci Symfony Messenger jako základ CQRS.