Kdy je CQRS v PHP overkill? 3 signály, že si komplikujete život

CQRS je jen rozdělení jednoho objektu na dva. Složité je všechno, co si k němu lidi přibalí, aniž by to potřebovali. Tři signály, že platíte režii, ze které nic nemáte.

// obsah 8
  1. 01 Co CQRS vlastně slibuje?
  2. 02 Signál 1: read model čte ze stejné entity
  3. 03 Signál 2: nic z toho neběží nezávisle
  4. 04 Signál 3: příkazy vznikají mechanicky podle entit
  5. 05 Kdy CQRS opravdu pomáhá?
  6. 06 Co to stojí?
  7. 07 Co použít místo toho?
  8. 08 Zdroje

CQRS je dobrý pattern. Jen ho většinou potkávám tam, kam nepatří.

Vypadá to obvykle takhle: V src/ (nebo kontextové složce) jsou složky Command/, Query/, Handler/, Bus/ a každá operace má čtyři třídy: CreateOrderCommand, CreateOrderCommandHandler, GetOrderByIdQuery, GetOrderByIdQueryHandler. Pro zobrazení detailu objednávky v administraci, kam chodí pár interních uživatelů.

Na otázku „proč CQRS?“ přitom nejčastěji zazní odpověď „protože je to správně“. Což není úplně relevantní argument.

Co CQRS vlastně slibuje?

Greg Young (autor toho pojmu) ho definuje docela skromně. Objekt, který měl dřív příkazy i dotazy, se rozdělí na dva: jeden nese příkazy (mění stav), druhý dotazy (stav čtou). Vychází to z command-query separation Bertranda Meyera a to je celé. Žádné event sourcing, žádná druhá databáze, žádná fronta. Martin Fowler to píše v podstatě stejně, oba modely klidně můžou sdílet jednu databázi a ta pak slouží jako spojka mezi nimi.

Zajímavější ale je, co za to ten tým dostává.

Smysl toho rozdělení je totiž v tom, že každá strana pak může jít vlastní cestou. Write side potřebuje transakce, agregáty a byznys invarianty. Read side chce hlavně rychlost a data v tom tvaru, v jakém je zobrazuje. Když se ty potřeby fakt rozejdou, dá se každá strana ladit zvlášť: Elasticsearch na čtení, PostgreSQL na zápis, mezi nimi eventual consistency (čtení dohání zápis se zpožděním).

Jenže samotné rozdělení nic z toho nepřinese, je to zatím jenom předpoklad. A tady jsou tři signály, že zůstalo jen u toho předpokladu.

Signál 1: read model čte ze stejné entity

Vezměte si CreateOrderCommand a OrderView z té administrace. Oba sahají na OrderRepository, oba pracují s Doctrine entitou Order. OrderView je v podstatě DTO, které tu samou entitu přemapuje na jiný tvar, ale data bere ze stejné tabulky, přes stejné ORM, ze stejné databáze.

Jednoduchý test: když váš query handler dělá $this->entityManager->find(Order::class, $id), máte oddělení jen na papíře.

final class GetOrderByIdQueryHandler
{
    public function __invoke(GetOrderByIdQuery $query): OrderView
    {
        // Čte přesně tam, kam píše command handler
        $order = $this->orderRepository->findById($query->orderId);
        return OrderView::fromEntity($order); // jen přemapování
    }
}

Dva sloupce, oba mají nahoře command a query. Vlevo z obou vedou šipky dolů do jedné entity Order a z ní do tabulky orders. Vpravo vede zápis dolů přes agregát do tabulky orders a čtení dolů přes read model do tabulky order_detail, a z agregátu vede stranou barevně odlišená šipka projekce do order_detail

Read side, která něco umí, má vlastní čtecí model. Elasticsearch index, materialized view, Redis s denormalizovanou strukturou nebo asynchronně udržovaná projekce ve vlastní tabulce. Klidně ve stejné databázi, na tom nezáleží. Podstatné je, že o agregátech ani Doctrine entitách nic neví a data v něm leží v podobě, ve které je potřebujete zobrazit, ne v té, ve které je ukládáte.

Dokud tenhle krok neuděláte, platíte zbytečně režii dvou modelů a přitom máte model jeden. A ta režie není teoretická, projeví se u každé blbosti. Přidat do detailu objednávky jedno pole znamená sáhnout na entitu, na view, na mapper mezi nimi, na query, na handler a na test každé z těch vrstev. U opravdu odděleného read modelu má ta cesta smysl, protože ten model je jinde a kvůli něčemu. Tady je to jen úprava šesti souborů kvůli jednomu sloupci.

Nikde přitom netvrdím, že čtení musí chodit do jiné databáze. Obyčejná čtecí služba, která sáhne na SQL a vrátí DTO, je úplně v pořádku. Problém začne, až kolem ní postavíte query objekt, handler, bus a mapper a řeknete si, že máte oddělené modely. Pořád máte jeden model, akorát rozepsaný do čtyř tříd.

Signál 2: nic z toho neběží nezávisle

CQRS se dobře snáší s event-driven architekturou. Command přijde, handler ho zpracuje, vyemituje doménovou událost a ta asynchronně spustí další procesy: projekci read modelu, notifikace, invalidaci cache. Write side a read side běží nezávisle, s eventual consistency mezi nimi.

Teď se podívejte na typickou implementaci:

final class CreateOrderCommandHandler
{
    public function __invoke(CreateOrderCommand $command): void
    {
        $order = Order::create($command->customerId, $command->items);
        $this->orderRepository->save($order);

        // Synchronně, ve stejném requestu, ve stejné transakci
        // (že dispatch běží v transakci, je samostatné téma na outbox pattern)
        $this->eventBus->dispatch(new OrderCreated($order->id()));

        // Handler pro OrderCreated aktualizuje… stejnou tabulku
    }
}

Synchronní command bus, synchronní dispatch událostí, jedna databáze. Když každý handler synchronně zavolá event bus a ten synchronně další handler, je to pořád jeden call stack, jen s víc kroky.

Dva call stacky vedle sebe, zanoření je vidět jako schodiště. Vlevo šest kroků: controller, command bus, handler, event bus, handler události a UPDATE, přičemž oba busy jsou barevně odlišené jako kroky, které jen předají zprávu dál. Vpravo tři kroky: controller, PlaceOrder, INSERT. Obě schodiště míří šipkou dolů do jedné tabulky orders

Bacha, jedna námitka je tu na místě. Command bus není zbytečný jen proto, že běží synchronně. Symfony Messenger vám dá middleware: doctrine_transaction obalí handler transakcí, validace i logování mají jedno místo, a až budete chtít část zpráv poslat do fronty, změníte konfiguraci a ne kód. To jsou reálné věci, co se hodí, a nerozporuju je.

Jenže tohle všechno umí command bus sám, i bez query strany. Můžete přes něj posílat jen zápisy a čtení nechat úplně mimo: prostě služba, která vrátí DTO. Ušetříte polovinu tříd a o nic nepřijdete.

Signál 3: příkazy vznikají mechanicky podle entit

Ten je taky vidět rovnou v src/.

UserCommand, ProductCommand, OrderCommand, ke každé entitě jedna sada tříd, mechanicky. Žádné bounded contexts, agregáty jsou přejmenované Doctrine entity a doménové události jsou DTO přehazovaná přes bus. Slovník z DDD sedí, akorát za ním nic není.

Zkuste se na takovém projektu zeptat, co je aggregate root pro objednávku a jaké invarianty chrání. Odpověď bývá „no, Order entita“. Jenže to je tabulka v ORM se dvěma metodami navíc.

Příčina bývá jednoduchá. Tým se s DDD ještě nepotkal. CQRS na DDD nestojí, Young ho definuje bez něj a bez agregátů se obejde. Jenže týmy po něm většinou sáhnou právě proto, že na něj narazily v DDD literatuře, a bez těch základů z něj zbyde ornament. Příkazy se pak krájí podle entit, protože podle čeho jiného.

Ten pocit znám. Taky jsem si kdysi psal vlastní event manager, hlavně proto, že se to tak dělalo.

Když tým s DDD teprve začíná, proberte nejdřív agregáty a bounded contexts. Obojí má hodnotu samo o sobě a bez busů. O CQRS se bavte až potom.

Kdy CQRS opravdu pomáhá?

Nebylo by fér tvrdit, že je to vždycky zbytečné. Tři situace, kde to má smysl i vzhledem k režii.

Event sourcing jako write model. Když ukládáte stav jako sekvenci událostí a ne jako aktuální snapshot, read model je projekce těch událostí do tvaru vhodného pro dotazy. Write a read model mají úplně jinou strukturu a přesně na tohle je pattern navržený.

Reporting oddělený od transakčního úložiště. PostgreSQL na zápisy a transakce, vedle toho Elasticsearch nebo ClickHouse na analytiku a full-text. Read side čte odjinud než write side a potřebujete ji něčím udržovat aktuální. CQRS s asynchronní projekcí to řeší elegantně.

Zápisy se side efekty, které můžou počkat. Notifikace, invalidace cache, generování PDF, odesílání e-mailů. Command zapíše do databáze, událost asynchronně spustí worker (jak takové workery stavím, mám v článku o RabbitMQ) a uživatel na to nečeká.

Ve všech třech případech je za tím to samé: read a write buď čtou z jiného místa, nebo mezi nimi běží něco doopravdy asynchronního.

Co to stojí?

Zadarmo to ale není ani v jednom z těch tří případů. Jakmile read model dohání zápis se zpožděním, přibude vám do aplikace hromada problémů, které předtím nebyly.

Uživatel uloží formulář, hned se podívá na seznam a svoji změnu tam nevidí. Musíte se rozhodnout, jestli mu to rozhraní nějak vysvětlí, jestli se po zápisu výjimečně čte z write side, nebo jestli to prostě necháte být. Kolega, který přijde za rok, na to musí myslet u každého nového pohledu. Jinak napíše test, který občas spadne a nikdo nebude vědět proč. A projekce se klidně rozejdou, takže je někdo musí umět přehrát od začátku. To znamená držet události v pořadí a počítat s tím, že se handler klidně spustí dvakrát.

Nic z toho není důvod CQRS nepoužít, je to prostě cena, kterou za to zaplatíte. Ale má smysl ji platit jenom tehdy, když za ni něco dostanete. Až se pro to rozhodnete, zapište si i proč. Jak si takové rozhodnutí zapisuju, mám v článku o ADR.

Co použít místo toho?

Repository, service layer, transakční metody. Pro drtivou většinu PHP projektů (e-shopy, CRM, interní aplikace, menší SaaS) to prostě stačí. DDD bez CQRS bývá ta správná kombinace: agregáty chrání invarianty, bounded contexts oddělují domény a neplatíte za command a query busy.

A když se vám ta myšlenka líbí, klidně si ji nechte v lehčí podobě. Jedna třída na jednu operaci, invarianty hlídá agregát, čtení si sáhne na SQL rovnou a vrátí DTO:

// Zápis: jedna operace, jedna třída. Repository si flush řeší sám.
final class PlaceOrder
{
    public function __construct(private OrderRepository $orders) {}

    public function __invoke(CustomerId $customerId, OrderItem ...$items): OrderId
    {
        // Order::place hodí výjimku na prázdný seznam položek
        $order = Order::place($customerId, ...$items);
        $this->orders->save($order);

        return $order->id();
    }
}

// Čtení: vlastní SQL, vlastní DTO, žádný handler ani bus.
final class OrderDetailQuery
{
    public function __construct(private Connection $db) {}

    public function __invoke(OrderId $id): ?OrderDetail
    {
        $row = $this->db->fetchAssociative(
            'SELECT o.id, o.placed_at, o.total_cents, c.name AS customer_name
               FROM orders o
               JOIN customers c ON c.id = o.customer_id
              WHERE o.id = ?',
            [$id->toString()],
        );

        return $row === false ? null : OrderDetail::fromRow($row);
    }
}

Ano, čte se ze stejné tabulky, do které se zapisuje. Ale nestojí kolem toho žádný bus ani čtyři třídy na operaci a nikdo netvrdí, že má oddělené modely. A až se čtení a zápis reálně rozejdou, vyměníte vnitřek OrderDetailQuery za projekci a volající kód o tom neví. Jak k podobným rozhodnutím přistupuju systematicky, mám v článku o monolitu vs. mikroslužbách.

Všimněte si taky, podle čeho se to krájí. PlaceOrder je jedna operace, OrderDetailQuery jedna otázka. Žádné UserService, ProductService, OrderService odvozené od tabulek. Tohle je ta osa, která u UserCommand a ProductCommand chyběla, a nepotřebujete na ni ani jeden bus.

Tohle je podle Youngovy definice pořád CQRS. Příkazy v jedné třídě, dotazy v druhé, přesně jak to popsal. Jenom to nevypadá jako Command/, Query/, Handler/, Bus/, protože tak to nikdy vypadat nemuselo.

Zkrátka, CQRS si nechte na chvíli, kdy vás k němu dotlačí konkrétní problém. Dokud za vás rozhoduje jenom pocit, že „je to správně“, platíte za něj a nedostáváte nic.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony a Reactem, zajímám se architekturu softwaru. Žiju v Českých Budějovicích.