Kapitola 12 · Vzory · CQRS v Symfony 8

CQRS v Symfony 8

CQRS v Symfony 8: oddělený model pro zápis a pro čtení, command a query bus v Messengeru, read modely a eventual consistency. A také případy, kdy se CQRS nevyplatí.

Autor M. Katuščák
Doba čtení ≈ 40 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) přenáší oddělení čtení a zápisu z jednotlivé metody na celý model. Popsal ho Greg Young [1]. Kořeny sahají ke Command-Query Separation (CQS) Bertranda Meyera [2]. Young v CQRS Documents dodává, že první roky se o vzoru mluvilo jako o rozšíření CQS na vyšší úrovni, a sám tuto formulaci označuje za nepřesnou. Po letech záměn obou pojmů se CQRS ustálil jako samostatný vzor, ne jako varianta staršího pravidla.

Tradiční aplikace používá pro obojí jednu entitu (typicky Doctrine ORM entitu): objednávku vytváří i zobrazuje v seznamu přes tentýž objekt Order. CQRS tuto odpovědnost rozděluje do dvou modelů, z nichž každý má vlastní úkol a vlastní optimalizační profil.

CQRS se často kombinuje s Event Sourcingem, který místo aktuálního stavu ukládá historii změn jako sekvenci událostí. Oba vzory jsou ale nezávislé. CQRS lze plnohodnotně postavit na klasické Doctrine ORM persistenci na write straně a denormalizovaných tabulkách na straně čtení, bez Event Sourcingu.

12.02 CQS vs. CQRS – kde je hranice?#

Bertrand Meyer formuloval Command-Query Separation (CQS) jako pravidlo na úrovni metod: každá metoda buď mění stav (command), nebo vrací hodnotu (query), nikdy obojí. CQS je návrhové pravidlo pro rozhraní tříd.

Greg Young tutéž myšlenku posunul z metody na model: CQRS rozděluje jeden doménový model na dva, každý s vlastní sadou tříd a vlastním optimalizačním profilem. Oddělené úložiště je volitelné (viz tři mýty o CQRS). Young přitom zdůrazňuje, že CQRS není architektura, nýbrž architektonický vzor. Popisuje něco uvnitř jediného systému nebo komponenty, ne uspořádání celé aplikace [5].

CQS je přirozený výchozí bod pro CQRS. Kdo dodržuje CQS na úrovni metod, brzy zjistí, že metody měnící stav potřebují výrazně jiná data než metody, které stav čtou. CQRS z toho vyvozuje dva oddělené modely.

12.03 Výhody CQRS#

Výhody se projeví hlavně u aplikací s netriviální doménovou logikou a odlišnými požadavky na čtení a zápis.

První je oddělení odpovědností. Write model nese doménovou logiku, invarianty a konzistenci dat; read straně zbývá jediný úkol – dodat data v podobě, jakou vyžaduje obrazovka. Každý model obsahuje jen to, co ke své práci potřebuje, a optimalizuje se nezávisle. Na straně zápisu stojí normalizované relační schéma a Doctrine ORM entity s 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 volnost při evoluci. Read model jde kdykoli přebudovat (rebuild projekcí), přidat další pro nový use case nebo změnit strukturu dotazu, aniž by se to dotklo write modelu.

Dvě další výhody:

  • Škálovatelnost – Young uvádí, že v systémech, a zvlášť ve webových, odbaví dotazovací strana běžně o dva a více řádů víc operací než strana zápisu [1]. CQRS umožňuje škálovat read stranu nezávisle (repliky, cache, CDN), bez dopadu na write stranu.
  • Testovatelnost – Command handlery se testují jako doménová logika (given state → when command → then events/state), u query handlerů se ověřuje jen správnost vrácených dat. Obě odpovědnosti se v jedné testovací sadě nepletou. Viz kapitola Testování DDD.

12.04 Výzvy a omezení CQRS#

Místo jednoho modelu existují dva nebo víc. Každý command i query si žádá vlastní třídu, handler a často i vlastní datovou strukturu: kde by v CRUD stačila jedna třída, vznikne jich několik. Oddělená úložiště přidávají synchronizaci: read model se musí aktualizovat po každé změně write modelu. Selhání propagace (výpadek fronty, chyba projektoru) vede k tomu, že se modely rozejdou.

Patří sem 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 je zaučení týmu. Kdo je zvyklý na jeden model pro všechny operace, musí přemýšlet jinak a porozumět pojmům jako message bus, eventual consistency, idempotence handlerů a projekce read modelu.

Technická kritéria přitom nejsou jedinou osou rozhodování. Udi Dahan, který vzor pomáhal popularizovat, staví na kolaborativnosti domény: CQRS se vyplatí tam, kde více aktérů mění tatáž data podle pravidel závislých na kontextu [6]. Nákupní košík mezi takové domény nepatří, protože nikdo neupravuje košík někoho jiného, a pro CQRS proto podle Dahana nekandiduje. Ke stejné opatrnosti vede Martin Fowler: o vzoru se rozhoduje per Bounded Context, ne pro celý systém [7].

12.05 Symfony Messenger jako základ CQRS#

Message bus není pro CQRS podmínkou. Oddělené command a query třídy volané přímo z controlleru jsou plnohodnotná úroveň 1 a malé aplikaci stačí. Sběrnice se vyplatí, až když kolem zpracování přibývá společná infrastruktura: transakce, validace, logování, odložené vykonání. V Symfony tuto roli plní Messenger. Specializované PHP knihovny z let 2014–2018 mezitím skončily (broadway/broadway je archivovaný) nebo roky nedostaly commit (prooph/service-bus, SimpleBus). Volba se tím zúžila na Messenger, nebo vlastní tenkou vrstvu nad kontejnerem.

Pro CQRS je na Messengeru podstatná možnost definovat více message busů: pro příkazy, pro dotazy a pro doménové události. Každý bus má vlastní sadu middleware a vlastní strategii zpracování. Dokumentace Symfony k tomu dodává podmínku, kterou se vyplatí brát vážně: jeden bus je dobrý výchozí stav a další se přidává tehdy, když potřebuje jiný middleware stack, ne proto, že to nějaký vzor doporučuje [8]. Konfigurace níže podmínku splňuje – command bus obaluje handler do transakce, query bus ne.

FIG. 12.5-A Symfony Messenger jako CQRS bus

Konfigurace definuje tři transporty: async_commands a async_events pro zpracování přes frontu a sync pro okamžité vykonání v témže procesu. Busy jsou také tři. command.bus pro příkazy má middleware doctrine_transaction, tedy automatickou transakci kolem handleru. query.bus má jen validaci. Transport doctrine://default dodává balíček symfony/doctrine-messenger; bez něj Messenger hlásí „No transport supports Messenger DSN“. Pro AMQP je to obdobně symfony/amqp-messenger.

event.bus slouží doménovým událostem a liší se v podstatném bodě: příkaz bez handleru je chyba, událost bez posluchače běžný stav. Proto allow_no_handlers: true; bez něj Messenger vyhodí NoHandlerForMessageException u každé události, kterou zatím nikdo neodebírá.

Jakmile máte víc než jednu sběrnici, přestane stačit prostý type-hint na MessageBusInterface a holý #[AsMessageHandler]. Type-hint dostane default_bus a handler se zaregistruje na všechny sběrnice. Obojí se řeší explicitně:

php snippet.php
1final readonly class OrderPlacedProjectorRegistration2{3    // Injektáž konkrétní sběrnice4    public function __construct(5        #[Target('event.bus')]6        private MessageBusInterface $eventBus,7    ) {}8}9 10// Handler patřící na jednu sběrnici11#[AsMessageHandler(bus: 'event.bus')]12final readonly class OrderPlacedProjector { /* … */ }

Na Outboxu je vidět, proč na tom záleží. Relay, který odešle doménovou událost na command.bus, narazí na chybějící handler a vyčerpá retry. K posluchačům se událost nedostane: bez failure_transport ji Messenger zahodí, s ním skončí v dead letter queue z 12.14 a čeká na ruční zásah. Přesně tomu má Outbox bránit. Dostupné aliasy vypíše debug:autowiring MessageBus.

12.06 Implementace Commands#

V Symfony 8 je command jednoduchá PHP třída: immutabilní datový objekt (DTO), který nese všechna data potřebná pro vykonání operace. Doménovou logiku neobsahuje, jen data přepravuje.

Dobře navržený command:

  • je immutabilní (readonly properties) a po vytvoření se nemění;
  • nese validační atributy, takže ho middleware validation na command busu zkontroluje dřív, než dorazí k handleru;
  • pojmenováním vyjadřuje záměr – RegisterUser, PlaceOrder, CancelSubscription, ne SaveUser nebo UpdateOrder;
  • pracuje s primitivními typy (string, int, float) nebo serializovatelnými hodnotovými objekty (OrderId, Money), protože musí bezpečně projít asynchronním kanálem.

Záměr se přitom nebere odnikud. U Younga stojí před CQRS task-based UI, rozhraní složené z úloh („Změnit doručovací adresu“, „Stornovat objednávku“), ne formulář nad entitou s tlačítkem Uložit. Formulář mapovaný na entitu vyprodukuje jediný command UpdateOrder a informace o tom, co uživatel vlastně chtěl, se ztratí ještě před vstupem do domény. Úlohy v UI přitom obvykle odpovídají doménovým událostem, které tým našel při Event Stormingu.

Validační atributy na commandu a doménová pravidla v handleru řeší dvě různé věci. Dahan odděluje validaci, která na kontextu nezávisí (je e-mail e-mailem, má heslo dost znaků), od business rules, které na něm závisí (tento e-mail už někdo použil, zákazník vyčerpal denní limit). Formálně validní command proto může doménově selhat, protože se mezitím změnily podmínky. Middleware validation doménovou kontrolu nenahrazuje, jen odfiltruje zprávy, které jsou chybné už formálně.

12.07 Implementace Queries#

Query se od commandu liší směrem toku dat: nemění stav systému, jen čte. Implementace je podobná, immutabilní DTO. Rozdíl je v tom, že query vždy vrací hodnotu, kterou handler předá přes HandledStamp.

Dotaz nese jediné pole: ID uživatele, jehož profil se má načíst. Nevalidní UUID odmítne middleware validation ještě před zpracováním.

12.08 Implementace Handlers#

V handleru se zpráva potká s logikou. V Symfony 8 jde o třídu s atributem AsMessageHandler a metodou __invoke(); Messenger ji se zprávou spojí 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 (ta hlídá 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. Doménový model záměrně obchází, protože ten pro čtení optimalizovaný není.

Rozdíl je vidět přímo v závislostech. Command handler pracuje s doménovým modelem (UserRepository, entita User, value objects). Query handler sahá do read repozitáře (UserProfileReadRepository), který vrací rovnou ViewModel – jednoduchou datovou strukturu pro prezentaci – a doménovým modelem vůbec neprochází.

Rozhraní read repozitáře patří do read strany, ne do domény, a je záměrně úzké:

php src/UserManagement/Profile/ReadModel/UserProfileReadRepository.php
1<?php2 3declare(strict_types=1);4 5namespace App\UserManagement\Profile\ReadModel;6 7use App\UserManagement\Profile\ViewModel\UserProfileViewModel;8 9interface UserProfileReadRepository10{11    public function findById(string $userId): ?UserProfileViewModel;12}

12.09 ViewModely a read modely#

ViewModel (nebo read model) je datová struktura navržená pro konkrétní dotaz nebo obrazovku. Doménovou logiku neobsahuje, je čistě prezentační. Doménová entita User chrání invarianty a zapouzdřuje chování; UserProfileViewModel nese 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í. Přes doménový model by stejný pohled znamenal načíst uživatele, jeho objednávky a spočítat úroveň, což je pomalé a porušuje hranice agregátů. Read model má tato data připravená v denormalizované podobě.

12.10 Implementace Command a Query Buses#

Zbývá dopravit příkazy a dotazy ke správnému handleru. Vedle atributu #[Target] z 12.05 funguje v Symfony 8 i named autowiring: parametr konstruktoru pojmenovaný podle busu z messenger.yaml ($commandBus pro command.bus) dostane právě tento bus:

Profil vykresluje ViewModel, ne agregát, takže šablona nic nedopočítává:

twig src/UserManagement/Profile/View/profile.html.twig
1{% extends 'base.html.twig' %}2 3{% block body %}4    <h1>{{ profile.name }}</h1>5    <dl>6        <dt>E-mail</dt>       <dd>{{ profile.email }}</dd>7        <dt>Registrace</dt>   <dd>{{ profile.registeredAt|date('j. n. Y') }}</dd>8        <dt>Objednávek</dt>   <dd>{{ profile.totalOrders }}</dd>9        <dt>Úroveň</dt>       <dd>{{ profile.membershipTier }}</dd>10    </dl>11{% endblock %}

Registrační šablona je běžný Twig formulář; form_row vykreslí i chyby, které do polí vložil kontroler z validace commandu:

twig src/UserManagement/Registration/View/registration.html.twig
1{% extends 'base.html.twig' %}2 3{% block body %}4    {# Bez tohoto bloku se uživatel o kolizi e-mailu nedozví: kontroler5       ji hlásí flashem, ne chybou u pole. #}6    {% for message in app.flashes('error') %}7        <p class="error">{{ message }}</p>8    {% endfor %}9 10    {{ form_start(form) }}11        {# form_errors vypíše chyby na kořeni formuláře, typicky CSRF. #}12        {{ form_errors(form) }}13        {{ form_row(form.name) }}14        {{ form_row(form.email) }}15        {{ form_row(form.password) }}16        <button type="submit">Registrovat</button>17    {{ form_end(form) }}18{% endblock %}

Vertikální řez potřebuje ke kontroleru dvě věci navíc. Šablony leží u feature, ne v centrálním templates/, takže Twig musí jejich adresář znát pod jménem. A formulář je obyčejný FormType vedle nich:

yaml config/packages/twig.yaml (doplněk k receptu)
1# Do souboru z receptu symfony/twig-bundle se doplňuje jen klíč `paths`.2# Zbytek (default_path, file_name_pattern…) zůstává, jak ho recept založil.3twig:4    paths:5        # Bez tohoto řádku Twig hlásí „There are no registered paths6        # for namespace UserManagement“ a šablona u feature se nenajde.7        '%kernel.project_dir%/src/UserManagement': UserManagement
php src/UserManagement/Registration/Form/RegistrationFormType.php
1<?php2 3declare(strict_types=1);4 5namespace App\UserManagement\Registration\Form;6 7use Symfony\Component\Form\AbstractType;8use Symfony\Component\Form\Extension\Core\Type\EmailType;9use Symfony\Component\Form\Extension\Core\Type\PasswordType;10use Symfony\Component\Form\Extension\Core\Type\TextType;11use Symfony\Component\Form\FormBuilderInterface;12use Symfony\Component\OptionsResolver\OptionsResolver;13use Symfony\Component\Validator\Constraints\NotBlank;14 15final class RegistrationFormType extends AbstractType16{17    public function buildForm(FormBuilderInterface $builder, array $options): void18    {19        // NotBlank tu není duplicita pravidla z commandu. Bez data_class20        // formulář constrainty commandu nepřebírá a prázdné pole přijde21        // do konstruktoru jako null – tedy 500 dřív, než se validace spustí.22        $builder23            ->add('name', TextType::class, [24                'label' => 'Jméno',25                'constraints' => [new NotBlank()],26            ])27            ->add('email', EmailType::class, [28                'label' => 'E-mail',29                'constraints' => [new NotBlank()],30            ])31            ->add('password', PasswordType::class, [32                'label' => 'Heslo',33                'constraints' => [new NotBlank()],34            ]);35    }36 37    public function configureOptions(OptionsResolver $resolver): void38    {39        // Bez data_class vrací formulář pole, a to je zde záměr: command40        // má promované readonly vlastnosti, do kterých PropertyAccess41        // zapsat neumí („is a promoted readonly property“). Kontroler42        // z pole postaví command sám.43        $resolver->setDefaults(['data_class' => null]);44    }45}

Oba controllery tedy dostanou svůj bus podle názvu parametru: $commandBus míří na command.bus, $queryBus na query.bus.

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 a denormalizovat data přesně do tvaru, který obrazovka nebo API endpoint očekává.

Ještě před volbou strategie rozhoduje jeden provozní detail: denormalizované tabulky nejsou ORM entity. Doctrine o nich neví, takže je doctrine:schema:update navrhne zahodit a doctrine:schema:validate hlásí nesoulad schématu. Vytvářejí se migrací a z porovnávání schématu je vyřadí filtr:

yaml config/packages/doctrine.yaml (výřez: read model)
1doctrine:2    dbal:3        # Tabulky read modelů spravují migrace, ne ORM. Každou novou4        # projekci je nutné do výčtu doplnit, jinak ji Doctrine při5        # dalším diffu navrhne zahodit.6        schema_filter: '~^(?!order_dashboard|reporting_orders)~'

Blok patří do stejného doctrine.yaml, kde už leží mappings, naming_strategy a vlastní typy z kapitoly o agregátech; url z receptu zůstává. Kniha konfiguraci Doctriny skládá postupně, v projektu je to ale jeden soubor.

Strategie optimalizace read modelů

Denormalizované tabulky jako read model

V praxi nejrozšířenější strategií je denormalizovaná tabulka s daty předpočítanými pro jedinou obrazovku či endpoint. Aktualizuje se přes doménové události, synchronně ve stejné transakci jako agregát, nebo asynchronně přes frontu. Vzniká migrací, ne přes schema:update – Doctrine o ní neví a bez schema_filter výše by ji navrhla zahodit:

php migrations/Version20260906090000.php
1<?php2 3declare(strict_types=1);4 5namespace DoctrineMigrations;6 7use Doctrine\DBAL\Schema\Schema;8use Doctrine\Migrations\AbstractMigration;9 10final class Version20260906090000 extends AbstractMigration11{12    public function getDescription(): string13    {14        return 'Read model: order_dashboard';15    }16 17    public function up(Schema $schema): void18    {19        // DDL pro PostgreSQL. Read model není entita, takže ho20        // `migrations:diff` nevygeneruje – tato migrace se píše ručně21        // a pro jinou platformu se ručně i přepisuje: MySQL chce22        // CHAR(36) místo UUID, DATETIME(6) místo TIMESTAMP(6) a upsert23        // v projektoru níže zapisuje přes ON DUPLICATE KEY UPDATE.24        $this->addSql(<<<'SQL'25            CREATE TABLE order_dashboard (26                order_id        UUID          NOT NULL,27                customer_id     UUID          NOT NULL,28                total_amount    INT           NOT NULL,29                status          VARCHAR(32)   NOT NULL,30                shipment_id     UUID          DEFAULT NULL,31                placed_at       TIMESTAMP(0)  NOT NULL,32                -- Mikrosekundy nejsou kosmetika: na porovnání updated_at33                -- stojí ochrana proti opožděné události a dvě události34                -- jednoho agregátu běžně spadnou do téže vteřiny.35                updated_at      TIMESTAMP(6)  NOT NULL,36                -- ON CONFLICT (order_id) v projektoru se opírá právě o tento klíč.37                PRIMARY KEY (order_id)38            )39        SQL);40 41        // Dashboard se řadí podle data a filtruje podle stavu.42        $this->addSql('CREATE INDEX idx_dashboard_status_placed43            ON order_dashboard (status, placed_at)');44    }45 46    public function down(Schema $schema): void47    {48        $this->addSql('DROP TABLE order_dashboard');49    }50}

Sloupec status nese slovník obrazovky, ne hodnoty enumu OrderStatus. Objednávku, kterou agregát drží ve stavu Confirmed, dashboard zobrazuje jako placed. Read model smí mít vlastní popisky, protože ho nikdo nepoužívá k rozhodování o přechodech.

Kdo doménové události odešle

Projektor výše předpokládá, že mu události OrderShipped či OrderCancelled někdo doručí. V nejjednodušší podobě je po flush() vyzvedne aplikační vrstva z agregátu metodou releaseEvents() a odešle na event bus; mechanismus popisuje sekce Agregát a doménové události: lifecycle. Synchronní projekce pod middlewarem doctrine_transaction je plnohodnotná volba. Projektor běží uvnitř téže transakce, a když commit selže, vrátí se i jeho zápis.

Slabé místo přináší až asynchronní routing, protože odeslání do fronty není se zápisem atomické. Pod middlewarem doctrine_transaction odchází zpráva ještě před commitem. Když commit selže, projekce dostane událost, která se nestala. Bez middlewaru, s odesláním po commitu, hrozí opak: pád procesu mezi commitem a odesláním událost ztratí. V obou případech se projekce tiše rozejde s write modelem. Pro asynchronní projekce proto 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í

Projekce dovolují kompletní rebuild read modelu. Když 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.

Rebuild funguje, jen když jsou zdrojové události stále dostupné (v Event Store nebo v message logu). Bez Event Sourcingu je rebuild možný, ale potřebuje jiný zdroj dat (např. change data capture z write databáze).

12.12 Eventual Consistency v praxi#

Eventual consistency je při zavádění CQRS nejčastějším zdrojem nejistoty. 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 nový záznam ještě neukazuje.

Toto okno je vlastnost asynchronní propagace, ne chyba. Diagram zachycuje datový tok od zápisu přes asynchronní propagaci po čtení a zvýrazňuje okno, ve kterém se eventual consistency projeví:

FIG. 12.12-A Eventual consistency v CQRS toku

Sekvence níže ukazuje v čase, kdy uživatel dostane 404, přestože command proběhl úspěšně:

FIG. 12.12-B Okno zastaralosti – kdy GET vrátí 404 po úspěšném POST

Strategie řešení v UI

Read-your-writes na úrovni HTTP

Strategie z tabulky řeší, co vidí uživatel v prohlížeči. API klienti potřebují tvrdší záruku: „přečti si, co jsi právě zapsal“ (read-your-writes). Dosáhne se jí předáním pozice zápisu: odpověď na command nese číslo verze agregátu nebo offset, na který se projekce musí dostat, a klient hodnotu pošle s dalším dotazem, typicky v hlavičce.

Čtecí endpoint porovná aktuální pozici projekce s požadovanou. Když projekce 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 – a klient data po uvedené pauze načte znovu. 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í#

Asynchronní zpracování navazuje přirozeně na oddělenou command stranu. V Symfony 8 se konfiguruje přes transporty Messengeru. Zpráva směrovaná na asynchronní transport se při dispatchi serializuje a zařadí do fronty, odkud ji worker později vyzvedne a předá handleru.

Konfigurace dává transportu async_events retry strategii: tři opakování s exponenciálním backoffem. Klíč jitter přidá k vypočtenému zpoždění náhodný rozptyl a rozprostře opakování v čase; bez něj se po výpadku vrátí všechny zprávy naráz. Celou strategii lze nahradit vlastní implementací RetryStrategyInterface přes klíč service. Pro kritické události přibývá samostatný transport async_priority_high s vlastní frontou – worker pro ni může běžet s vyšší prioritou nebo na samostatném serveru.

Spolehlivé předání doménových událostí do fronty, atomické se zápisem agregátu, zajišťuje Outbox Pattern.

Transport si zpráva může nést i sama: atribut #[AsMessage(transport: 'async_events')] nad třídou příkazu nebo události nahradí odpovídající řádek v sekci routing:. Je to věc zvyku: YAML drží směrování na jednom místě, atribut u zprávy.

12.14 Zpracování chyb a Dead Letter Queue#

V asynchronním prostředí se chyby zpracovávají jinak než v synchronním. Při synchronním dispatchi výjimka probublá přímo do controlleru a uživatel vidí chybovou hlášku. Při asynchronním zpráva čeká ve frontě; když handler selže, uživatel o tom neví a zprávu je potřeba zpracovat znovu.

Retry strategie

Messenger umí zprávy, které selhaly, automaticky opakovat. Konfigurace retry_strategy na transportu určuje, kolikrát a s jakým zpožděním se handler zavolá znovu (hodnoty z konfigurace ve 12.13):

  • 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).
  • jitter: 0.2 – Náhodný rozptyl zpoždění, hodnota 0 až 1 (výchozí 0.1).

Failed transport (Dead Letter Queue)

Když selžou všechny pokusy, Messenger zprávu přesune na failed transport (dead letter queue). Tam čeká na ruční zásah: vývojář ji prozkoumá, opraví příčinu chyby a odešle znovu.

Klíč failure_transport funguje globálně i u jednotlivého transportu. Projekce tak mohou mít vlastní dead letter frontu, oddělenou od e-mailů a reportů, což usnadní monitoring i hromadné přehrání po opravě projektoru.

12.15 Middleware v CQRS#

Middleware v Symfony Messenger tvoří řetěz kolem handleru a zachytí zprávu před zpracováním i po něm. Tudy do dispatch cyklu vstupuje validace, logování, transakce nebo autorizace, aniž by se měnil handler.

Vestavěné middleware validation a doctrine_transaction už znáte z konfigurace ve 12.05. Pro další potřeby lze napsat vlastní:

Na pořadí middleware záleží. Logování jde první, takže zachytí i validační chyby. Následuje validace, která odmítne nevalidní command ještě před zahájením transakce, a nakonec doctrine_transaction, který obalí handler do databázové transakce.

12.16 Testování CQRS#

Command handlery, query handlery a projektory jsou izolované komponenty s jasně definovanými vstupy a výstupy, takže se testují dobře. Strategie se liší podle komponenty.

Testování command handlerů

Command handler se často testuje jako unit test s in-memory fake repozitářem (Testování DDD). RegisterUserHandler je výjimka: unikátnost e-mailu vynucuje databázový index, ne podmínka v kódu. Fake ani mock repozitáře žádný index nemá, takže by test prošel i nad handlerem, který duplicity pouští dál. Proto běží proti skutečné databázi:

Testování query handlerů

U query handleru se ověřuje mapování dat z read repozitáře na ViewModel. Integrační test s reálnou databází prověří i samotné SQL dotazy:

Testování projektorů

Projektory se nejlépe testují integračně s reálnou databází. Test ověří, že po zpracování sekvence událostí obsahuje read model očekávaná data:

Testování agregátů, value objects i doménových služeb rozebírá kapitola Testování DDD.

12.17 Sága a Process Manager#

CQRS nad více Bounded Contexts dřív či později potřebuje koordinovat dlouhotrvající procesy napříč kontexty. Sága naslouchá doménovým událostem a podle nich odesílá příkazy. Propojuje tak command a event stranu CQRS do ucelených doménových procesů. V orchestrované podobě ji řídí Process Manager, komponenta s vlastním stavem, která rozhoduje o dalším kroku.

Ságy včetně implementace v Symfony Messengeru, kompenzačních strategií a testování rozebírá kapitola 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. Popsal jej Greg Young; kořeny sahají ke staršímu pravidlu CQS Bertranda Meyera, sám Young ale označuje formulaci „CQRS je rozšíření CQS“ za nepřesnou a mluví o samostatném vzoru. 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) posouvá tutéž myšlenku z metody na model: místo jednoho doménového modelu vznikají dva oddělené, 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 podobě modelu uvnitř systému. Více v sekci CQS vs. CQRS.

Kdy se vyplatí CQRS nasadit?

CQRS se vyplatí 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 má význam oddělený audit 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.

Potřebuje CQRS frontu nebo druhou databázi?

Ne. Message bus, asynchronní transport i oddělené úložiště jsou volby, ne součást vzoru. Greg Young popisuje read stranu jako tenkou vrstvu nad toutéž databází, která promítá řádky rovnou do DTO; Azure Architecture Center uvádí, že posílání zpráv není pro CQRS podmínkou. Většině aplikací proto stačí nejjednodušší podoba: oddělené command a query handlery nad jednou databází. Viz Tři mýty o 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ě až tři oddělené sběrnice (command.bus, query.bus a pro doménové události event.bus), každá s vlastní sadou handler tříd a middleware. Dokumentace Symfony přitom doporučuje přidávat další sběrnici jen tehdy, když potřebuje jiný middleware stack. Příkazy mění stav a data nevracejí, nanejvýš identifikátor nového záznamu; dotazy vracejí ViewModely (read modely) a stav nemění. Asynchronní zpracování se zapíná přes transport a vyjme dlouhé operace z cyklu request-response. Více v sekci Symfony Messenger jako základ CQRS.