Kapitola 12 · Vzory · CQRS v Symfony 8

CQRS v Symfony 8

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í.

Autor M. 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ý 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.

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) 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ů.

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.

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.

12.04 Výzvy a omezení CQRS#

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.

12.05 Symfony Messenger jako základ CQRS#

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-A Symfony 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í.

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ěrRegisterUser, 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.

12.07 Implementace Queries#

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.

12.08 Implementace Handlers#

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.

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í. 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ě.

12.10 Implementace Command a Query Buses#

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í.

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á 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čí:

  1. Vytvořit novou verzi projekční tabulky.
  2. Přehrát všechny relevantní události přes projektor.
  3. Přepnout read dotazy na novou tabulku.
  4. 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.

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-A Eventual 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-B Okno 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.

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. Spolehlivé předání doménových událostí do fronty, atomické se zápisem agregátu, zajišťuje Outbox Pattern.

12.14 Zpracování chyb a Dead Letter Queue#

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_retries: 3 – Maximální počet opakování.
  • delay: 1000 – Zpoždění prvního opakování (v ms).
  • multiplier: 2 – Exponenciální backoff: 1s → 2s → 4s.
  • 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é 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.

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 – 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.