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í.
AutorM. Katuščák
Doba čtení≈ 35 min
Náročnost
pokročilá
Publikováno · Aktualizováno·
Obsah kapitoly
12.01 Co je CQRS?
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ý ukládá změny stavu jako sekvenci událostí místo aktuálního stavu.
Tyto dva vzory jsou však nezávislé – lze plnohodnotně implementovat CQRS
s klasickou Doctrine ORM persistencí na write straně a denormalizovanými tabulkami na read straně,
aniž by se sahalo po Event Sourcingu.
12.02 CQS vs. CQRS – kde je hranice?
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) mají výrazně odlišné požadavky na data než metody,
které stav čtou (query methods). CQRS toto pozorování formalizuje rozdělením do dvou explicitních modelů.
12.03 Výhody CQRS
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:
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ě, kterou potřebuje
obrazovka. Každý model obsahuje jen to, co ke své práci potřebuje.
Nezávislá optimalizace – Write model může používat normalizované relační schéma
a Doctrine ORM entity s bohatou doménovou logikou. Read model může být denormalizovaná tabulka,
Elasticsearch index, nebo Redis cache – cokoli, co nejlépe vyhovuje konkrétním dotazům.
Škálovatelnost – Ve většině aplikací výrazně převažuje čtení nad zápisem
(poměr 10:1 až 100:1). 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). Query handlers se testují na správnost
vrácených dat. Žádné propletení obou odpovědností v jedné testovací sadě.
Viz kapitola Testování DDD kódu.
Flexibilita evoluce – Read model lze kdykoli přebudovat (rebuild projekcí),
přidat nový read model pro nový use case nebo změnit strukturu dotazu – bez jakéhokoli
dopadu na write model a doménovou logiku.
12.04 Výzvy a omezení CQRS
CQRS má své limity. Jako každý architektonický vzor přináší kompromisy,
které je nutné pečlivě zvážit ještě před zavedením:
Zvýšená složitost kódu – Místo jednoho modelu existují dva (nebo více).
Každý command a query vyžaduje vlastní třídu, handler, a často i vlastní datovou strukturu.
Pro jednoduchou CRUD operaci to může znamenat 4–6 tříd místo jedné.
Eventual consistency – Při asynchronní propagaci změn z write strany na read stranu
existuje časové okno, kdy read model neodráží poslední zápis. Uživatel může po odeslání
formuláře vidět „starou“ verzi dat. Tento problém řeší konkrétní vzory v UI –
viz sekce Eventual Consistency.
Synchronizace modelů – Při oddělených úložištích je nutné zajistit,
že read model bude vždy aktualizován po každé změně write modelu. Selhání propagace
(výpadek fronty, chyba projektoru) vede k divergenci modelů.
Učební křivka – 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.
12.05 Symfony Messenger jako základ CQRS
Symfony Messenger je komponenta, která poskytuje infrastrukturu pro odesílání a zpracování zpráv.
Pro CQRS je podstatná 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. 13.1-ASymfony Messenger jako CQRS bus
Konfigurace definuje dva transporty: async pro asynchronní zpracování a sync pro synchronní zpracování.
Dva message busy: command.bus pro příkazy s doctrine_transaction
middleware (automatická transakce kolem handleru) a query.bus pro dotazy pouze s validací.
12.06 Implementace Commands
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.
V tomto příkladu je RegisterUser příkaz, který obsahuje data potřebná pro registraci uživatele.
Příkaz používá PHP atributy pro validaci dat – ta proběhne automaticky díky validation middleware
na command busu, ještě než se command dostane k handleru.
12.07 Implementace Queries
Queries v CQRS jsou dotazy, které vrací data bez změny stavu systému. Podobně jako commands
se implementují jako immutabilní DTO třídy, ale na rozdíl od commands vždy vracejí
hodnotu – handler vrací data přes HandledStamp.
V tomto příkladu je GetUserProfile dotaz, který obsahuje ID uživatele, jehož profil chceme získat.
Dotaz používá atributy pro validaci dat – nevalidní UUID bude odmítnuto ještě před zpracováním.
12.08 Implementace Handlers
Handlers v CQRS jsou objekty, které zpracovávají příkazy a dotazy. V Symfony 8 se implementují
jako PHP třídy 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í.
Všimněte si rozdílu: command handler pracuje s doménovým modelem (UserRepository,
User entita, value objects), zatímco query handler pracuje s read repozitářem
(UserProfileReadRepository), který vrací přímo ViewModel – jednoduchou datovou strukturu
optimalizovanou pro prezentaci. Query handler neprochází přes doménový model.
12.09 ViewModely a Read Modely
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í. Zápis přes doménový model by vyžadoval
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ě.
12.10 Implementace Command a Query Buses
Command a Query Buses v CQRS jsou objekty, které směrují příkazy a dotazy na příslušné handlery.
V Symfony 8 se pro injektování správného busu používá named autowiring – názvy parametrů
v konstruktoru musejí odpovídat konfiguraci v messenger.yaml:
V těchto příkladech Symfony injektuje commandBus a queryBus pomocí named autowiring –
přiřadí bus podle názvu parametru v konstruktoru (musí odpovídat klíči v konfiguraci
buses v messenger.yaml, kde command.bus 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í.
12.11 Optimalizace Read Modelů
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á obsahuje přesně ta data, jež potřebuje konkrétní obrazovka nebo API endpoint.
Tabulka se aktualizuje asynchronně přes doménové události.
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).
12.12 Eventual Consistency v praxi
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.
Eventual consistency je vlastnost distribuované architektury, ne bug.
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. 13.2-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. 13.12-AOkno 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
12.13 Asynchronní zpracování
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.
12.14 Zpracování chyb a Dead Letter Queue
V asynchronním prostředí je zpracování chyb podstatně odlišné od synchronního zpracování.
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í selhalých zpráv. 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.
12.15 Middleware v CQRS
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).
12.16 Testování CQRS
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ého komponentu:
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.
12.17 Saga / Process Manager
Při použití CQRS s více Bounded Contexts
vzniká potřeba koordinovat dlouhotrvající procesy napříč kontexty.
Vzor Saga (neboli Process Manager) naslouchá doménovým událostem
a na základě 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.