Kapitola 07 · Taktika · Návrh agregátu

Návrh agregátu

Hranice agregátu rozhoduje o transakční konzistenci, velikosti zámků a o tom, zda projekt obstojí v provozu. Tato kapitola shrnuje pravidla z Vernonovy trilogie Effective Aggregate Design, ukazuje konkrétní mapování v Doctrine ORM a věnuje se obtížným tématům: large-collection problem, hot aggregates, snapshoty v Event Sourcingu, partitioning a strategie referencování napříč agregáty.

Autor M. Katuščák
Doba čtení ≈ 35 min
Náročnost náročná
Publikováno · Aktualizováno ·
Obsah kapitoly

Agregát je nejnáročnější taktický vzor v DDD, protože jeho hranice je kompromis mezi konzistencí, výkonem a škálovatelností. Eric Evans ho popsal v šesté kapitole své knihy z roku 2003 [1]. Vaughn Vernon mu věnoval třídílnou esej (2011) [2] a celou desátou kapitolu Implementing Domain-Driven Design (2013) [3]. Vlad Khononov v knize Learning Domain-Driven Design (2021) shrnuje praktická vodítka z dalšího desetiletí provozu [4]. Tato kapitola navazuje na Základní koncepty a předchází CQRS, Event Sourcing a Ságy.

07.01 Proč existují agregáty#

Agregát je skupina doménových objektů, která je pro vnější svět nedělitelnou jednotkou konzistence. Eric Evans ho zavedl jako odpověď na dvě otázky, které objektově orientovaný model neřeší sám od sebe. První: kdo je zodpovědný za vymáhání invariantů. Druhá: co se uloží v jedné transakci. Vstupním bodem do agregátu je kořen agregátu (aggregate root); ostatní objekty uvnitř hranice nesmí být pro zbytek aplikace přímo dostupné.

Bez explicitní hranice doménový model degraduje dvěma směry. Buď se objektový graf rozroste a pokrývá celou doménu jediným transakčním kontextem (typicky přes obousměrné OneToMany relace v Doctrine) a přináší zámky i deadlocky. Nebo se naopak rozpadne na anemicky tenké objekty, u nichž nikdo nevymáhá invarianty a logika se rozteče po službách. Agregát tyto dva extrémy řeší kompromisem: malá konzistentní jednotka plus jasné pravidlo, jak se mění.

Hranice ale neurčuje jen transakci. Evans ji v DDD Reference (2015) [8] spojuje i s rozmístěním: agregát má zůstat pohromadě na jednom serveru, zatímco různé agregáty smí být rozprostřené mezi uzly. Kde vede hranice agregátu, tam později vede i hranice shardu nebo služby.

FIG. 07.1-A Hranice agregátu Order vs. Customer

07.02 Čtyři pravidla podle Vaughna Vernona#

Vaughn Vernon shrnul nejčastější pasti návrhu agregátu do série tří esejů Effective Aggregate Design z roku 2011 [2]. Doporučení vycházejí z analýzy reálných projektů, kde příliš velké agregáty zablokovaly výkon a kde příliš malé rozbily invarianty. Vernon je nenazývá pravidly, ale rules of thumb, tedy vodítky. Rozdíl není kosmetický: ke každému z nich sám uvádí situace, kdy se poruší. Čtyři vodítka, která doporučuje aplikovat v pořadí:

  1. Modelujte skutečné invarianty uvnitř konzistenční hranice. Pokud pravidlo musí platit v každý okamžik (například „součet položek faktury se rovná celkové ceně“), patří dovnitř jednoho agregátu. Pokud pravidlo smí být porušené po několik sekund (například „uživateli s podpisem smlouvy se odešle vítací e-mail“), eventual consistency stačí.
  2. Navrhujte malé agregáty. Výchozí volba je agregát s jediným kořenovým objektem a několika hodnotovými objekty. Větší agregát potřebuje konkrétní obhajobu invariantem, ne pohodlí ORM nebo mentální setrvačnost vrstveného CRUD. Vernon to podkládá číslem z projektu, který analyzoval: zhruba 70 % agregátů tvořil samotný kořen s několika hodnotovými objekty, zbývajících 30 % mělo dvě až tři entity celkem.
  3. Reference mezi agregáty pouze přes identitu. Místo objektové reference se drží OrderId, CustomerId. Doctrine asociace mezi agregáty je signál, že někde chybí hranice nebo že eventual consistency čeká na zavedení.
  4. Eventual consistency mimo hranici – po otázce, čí je to práce. Změnu napříč agregáty řešte doménovou událostí a samostatnou transakcí. „Když se X stane v agregátu A, sága upraví agregát B.“ Dovětek o čí práci Vernon do formulace pravidla přidal ve třetím dílu série a rozebírá ho sekce Invarianty.

Khononov v Learning DDD (2021) dodává páté pravidlo. Z Vernonových implicitně plyne, ale vyplatí se ho říct nahlas: jedna databázová transakce mění právě jeden agregát. Potřeba commitnout změny ve více agregátech je podle něj signálem špatně vedené hranice. Objeví-li se v jednom command handleru dvě volání save() na různé repozitáře, prověřte hranici. Buď mají vzniknout dva commandy, nebo jde o ságu – dvoufázový proces s vlastní transakcí pro každý krok.

07.03 Invarianty jako východisko návrhu#

Hranici agregátu nelze odvodit z databázového schématu, ER diagramu ani z existujícího kódu. Vychází se z invariantů, tedy z pravidel, která musí platit v každý okamžik. Jinak je doménový model nekonzistentní. Pojetí invariantu jako predikátu pochází z Design by Contract: Bertrand Meyer ho v Object-Oriented Software Construction (1997) [9] definuje jako podmínku, která platí před každou veřejnou operací objektu i po ní. Vernon tomu dává užší doménové čtení. Invariant je byznys pravidlo, které platí pořád; u agregátu se přitom myslí konzistence transakční. Typické zdroje:

  • Sumační pravidla. Součet položek odpovídá celkové ceně. Počet rezervovaných míst nepřekračuje kapacitu. Bilance debetů a kreditů je nulová.
  • Stavové přechody. Fakturu ve stavu PAID nelze vrátit do stavu DRAFT. Objednávku po SHIPPED nelze stornovat bez kompenzační operace.
  • Existenční pravidla. Faktura musí mít alespoň jednu položku. Tým musí mít alespoň jednoho administrátora.
  • Kvantitativní limity. Maximální počet účastníků v týmu. Limit slevy v procentech z ceny objednávky. Maximální výše úvěru pro daný kreditní rating.
  • Vzájemné závislosti polí. Pokud je type = SUBSCRIPTION, renewalDate nesmí být null. Pokud je shippingMethod = PICKUP, address může být null.

Pro každý invariant rozhoduje jedna otázka: musí být porušení nemožné v každý okamžik, nebo se stačí dorovnat se zpožděním? První kategorie definuje hranici agregátu. Druhá patří mimo ni a řeší ji sága nebo process manager (kapitola Ságy a Process Managery).

Odpověď se hledá špatně, dokud se ptáme na techniku. Vernon proto přebírá od Evanse vodítko, které míří na uživatele: čí je to práce udržet ta data konzistentní? Pokud ji má odvést uživatel, který use case spouští, patří pravidlo do jedné transakce a tedy do jednoho agregátu. Pokud ji má odvést jiný uživatel nebo systém sám, stačí eventual consistency. Otázka funguje proto, že odhalí skutečné invarianty domény místo těch, které vypadají jako invarianty jen kvůli tvaru databázového schématu. Tímto sítem projde každé pravidlo ze seznamu výše dřív, než vznikne první náčrt hranic.

07.04 Velikost agregátu a její dopady#

Velký agregát vypadá bezpečně: „raději víc v jedné transakci než riziko nekonzistence“. V praxi ale platí opak. Tři důvody:

  • Konkurence. Větší agregát = větší zámek = více konfliktů mezi uživateli. Pokud Project drží všechny Tasky, dvě paralelní úpravy úkolů si konkurují, i když spolu věcně nesouvisejí. V e-shopovém kontextu má jeden zákazník typicky jednu objednávku v rozpracovaném stavu, takže Order jako agregát s desítkami OrderItem je v pořádku. Naproti tomu Project s tisícem Task dává každému členovi týmu šanci na konflikt s každým jiným.
  • Paměť a IO. Při načtení agregátu se hydratuje celá hranice. Project s tisícem úkolů znamená tisíc řádků v každé operaci, i když měníme jediný úkol. V Doctrine to navíc zhoršují asociace s lazy loadingem, které generují N+1 dotazů.
  • Kompozitní invarianty. Velký agregát obsahuje pravidla, která spolu věcně nesouvisejí. Každá změna musí projít validací všech naráz a režie roste s počtem chráněných invariantů.

Praktická heuristika: pokud nemáte konkrétní invariant, který by si vynutil vzájemnou přítomnost dvou entit v jedné transakci, jsou to dva agregáty. „Pohodlí“ Doctrine asociace není doménový důvod.

07.05 Transakční konzistence: jeden agregát na transakci#

Pravidlo „jeden agregát na transakci“ je jedno z nejpřísnějších v DDD a v Symfony projektech se porušuje nejčastěji. Důvody pravidla:

  • Transakční hranice je kontrakt. Pokud spolu dva agregáty mění stav v jedné transakci, prakticky se z nich stává jeden agregát, jen rozdělený do dvou tříd.
  • Atomická úprava napříč agregáty znemožňuje pozdější rozdělení do microservices nebo jiného Bounded Contextu. Hranice agregátu je hranice škálování.
  • Optimistický zámek (#[ORM\Version] v Doctrine) hlídá jednu instanci agregátu, a to jen v rozsahu změn, které se dotknou pole na kořeni (viz sekce Mapování v Doctrine). Snaha pokrýt jím dva agregáty najednou končí u pesimistického zámku, který snižuje propustnost a zvyšuje riziko deadlocku.
  • Helland v Life Beyond Distributed Transactions [5] ukazuje, že distributed transactions (XA, two-phase commit) v praxi nefungují udržitelně. Jeho entity je kolekce dat, kterou lze atomicky změnit uvnitř, ale nikdy ne napříč hranicemi. Tutéž hranici popsal šest let před Vernonem a nezávisle na DDD.

V Symfony 8 to znamená: EntityManager::flush() uvnitř command handleru by měl ukládat změny jednoho agregátu. Změna v dalším agregátu patří do separátního handleru, spuštěného přes Messenger po publikaci doménové události.

php src/Banking/Application/TransferMoneyHandler.php (ANTI-VZOR)
1<?php2 3declare(strict_types=1);4 5namespace App\Banking\Application;6 7use App\Banking\Domain\Account\AccountRepository;8use Doctrine\ORM\EntityManagerInterface;9 10// ANTI-VZOR: transakce přes dva agregáty11final class TransferMoneyHandler12{13    public function __construct(14        private readonly AccountRepository $accounts,15        private readonly EntityManagerInterface $em,16    ) {}17 18    public function __invoke(TransferMoney $cmd): void19    {20        $this->em->wrapInTransaction(function () use ($cmd): void {21            $source = $this->accounts->get($cmd->sourceId);22            $target = $this->accounts->get($cmd->targetId);23 24            $source->withdraw($cmd->amount);  // změna agregátu A25            $target->deposit($cmd->amount);   // změna agregátu B26 27            // Doctrine flush() commitne obojí atomicky.28            // Vypadá to bezpečně, ale ve skutečnosti:29            //   1) zámek napříč dvěma agregáty zabíjí škálování,30            //   2) deadlock při souběžných transferech (A→B vs. B→A),31            //   3) tuto třídu nelze rozdělit na microservices,32            //   4) chybí auditní stopa o pokusu o převod (selhání = nic se nestalo).33        });34    }35}
php src/Banking/Application/InitiateTransferHandler.php
1<?php2 3declare(strict_types=1);4 5namespace App\Banking\Application;6 7use App\Banking\Domain\Account\AccountRepository;8use App\Banking\Domain\Transfer\TransferId;9 10// SPRÁVNĚ: jeden agregát na transakci, sága přes doménovou událost11final class InitiateTransferHandler12{13    public function __construct(14        private readonly AccountRepository $accounts,15    ) {}16 17    public function __invoke(InitiateTransfer $cmd): void18    {19        $source = $this->accounts->get($cmd->sourceId);20 21        // Withdraw publikuje event MoneyWithdrawn(transferId, sourceId, targetId, amount).22        // Druhý handler (TransferSaga) reaguje a v separátní transakci provede deposit23        // na cílovém účtu, případně kompenzaci (refund) při selhání.24        $source->withdraw($cmd->amount, $cmd->targetId, $cmd->transferId);25 26        $this->accounts->save($source);27        // Optimistický zámek na $source brání souběžným withdraw.28        // Pokud by paralelně přišel jiný InitiateTransfer, druhý dostane29        // OptimisticLockException a celá operace se může zopakovat.30    }31}
FIG. 07.5-A Tok transakce: jeden agregát na transakci + sága

Kdy se vodítko poruší

Vernon k pravidlu připojil sekci Reasons To Break the Rules a jmenuje v ní čtyři situace, ve kterých zkušený tým commitne víc agregátů najednou. Vždy s vědomím, co za to platí.

  1. Pohodlí uživatelského rozhraní. Formulář zakládá dávku instancí naráz. Pokud je vytvoření dávky sémanticky totéž jako opakované vytvoření po jedné, je porušení bez následků.
  2. Chybějící technický mechanismus. Projekt nemá messaging, plánovač ani vlákna, takže eventual consistency nemá čím doručit.
  3. Vynucené globální transakce. Podnikové prostředí předepíše 2PC a rozhodnutí leží mimo tým.
  4. Výkon dotazů. Občas se vyplatí držet přímou referenci na jiný agregát, protože dohledání přes repozitář by dotaz zdražilo.

K tomu Vernon zavádí pojem user-aggregate affinity: rozhoduje, kolik uživatelů sahá na tutéž množinu instancí ve stejný okamžik. Pracuje-li na nich v daném okamžiku jeden jediný, riziko konfliktu je nízké a porušení levné. Sdílí-li je celý tým, roste cena každého takového ústupku.

Khononov k tomu přidává diagnostiku, ne zákaz: potřeba commitnout změny ve více agregátech signalizuje špatně vedenou transakční hranici. Hranici proto prověřte dřív, než rozhodnete, zda jde opravdu o jednu ze čtyř výjimek. Vernon celou sérii uzavírá poznámkou, že se pro porušení vodítek nehledají výmluvy.

07.06 Eventual consistency mezi agregáty#

Eventual consistency vyvolává obavy v týmech, které přicházejí z monolitické CRUD aplikace. Transakci napříč agregáty nahrazují čtyři explicitní kroky:

  1. Kořen agregátu A vykoná operaci a publikuje doménovou událost (např. OrderPlaced).
  2. Outbox Pattern (kapitola Outbox) zajistí, že událost se spolehlivě dostane do message brokera, i když selže jiný komponent.
  3. Handler nebo sága přijme událost a v separátní transakci modifikuje agregát B.
  4. Pokud krok 3 selže, sága vykoná kompenzaci nebo retry; doména je explicitně připravena na chvilkovou nekonzistenci.

Rozhodující otázka: jak dlouho smí nekonzistence trvat? Odpověď nepatří vývojáři, ale doménovému expertovi, a bývá velkorysejší, než se čeká. Vernon k tomu píše, že experti běžně připustí štědrý počet sekund, minut, hodin, někdy i dnů. Vystavení faktury po dokončení objednávky snese minuty; propagace změny adresy do druhotných kontextů také. Teprve procesy, u kterých expert žádné zpoždění nepřipustí, jsou kandidáty na jeden agregát, ne na ságu.

Klasickým příkladem je e-commerce checkout. Místo „v jedné transakci uložit objednávku, srazit zásoby a poslat e-mail“ se proces rozdělí na tři kroky. Agregát Order uloží objednávku a publikuje OrderPlaced event. Sága InventoryReservationSaga ve své transakci sníží zásoby v agregátu InventoryItem. Potvrzovací e-mail pak odešle OrderConfirmationEmailSaga, opět v samostatné transakci. Pokud rezervace zásob selže (zboží mezitím vyprodáno), OrderCanceledDueToOutOfStock event spustí kompenzaci a stornuje objednávku.

07.07 Reference přes identitu, ne přes objekty#

Třetí Vernonovo vodítko zní: mezi agregáty se odkazujte přes identifikátor (Value Object typu OrderId, CustomerId), ne přes objektovou referenci. Důvody:

  • Objektová reference svádí k řetězené úpravě „$order->getCustomer()->changeAddress(...)“. V jediné transakci tak měníme dva agregáty. Programátor často ani neví, že to dělá.
  • Lazy loading u Doctrine sice teoreticky odděluje načtení, prakticky ale skrývá, že druhý agregát musí být v paměti, aby se dotaz vykonal. Při souběžném přístupu vzniká skrytý zámek.
  • Identifikátorová reference funguje stejně na monolitu, modulárním monolitu i na microservices. Migrace mezi těmito tvary nasazení nevyžaduje refaktoring doménového modelu, jen výměnu CustomerRepository::get() za HTTP volání.
  • Identifikátor je serializovatelný. Doménová událost, která ho nese, se přenáší přes message broker beze ztráty informace.

Výjimku připouští i sám Vernon. Ve třetím dílu série jeho tým kvůli režii dotazů zvolí přímou lazy-loaded referenci na cizí agregát a mapování k ní přizpůsobí. Hranici mezi výjimkou a chybou drží jediná podmínka: taková reference slouží ke čtení. Jakmile se přes ni volá doménová metoda cizího agregátu, transakce se rozlezla přes hranici a výhoda je pryč. Evans ve stejném duchu připouští předání reference na vnitřní člen agregátu ven, ale jen pro jedinou operaci, tedy bez uchování a bez zápisu.

php src/Ordering/Domain/ValueObject/OrderId.php
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\ValueObject;6 7use Symfony\Component\Uid\Uuid;8 9final readonly class OrderId10{11    public function __construct(12        public string $value,13    ) {14        if (!Uuid::isValid($value)) {15            throw new \InvalidArgumentException('OrderId must be a valid UUID');16        }17    }18 19    public static function generate(): self20    {21        return new self((string) Uuid::v7());22    }23 24    public static function fromString(string $value): self25    {26        return new self($value);27    }28 29    public function __toString(): string30    {31        return $this->value;32    }33 34    public function equals(self $other): bool35    {36        return $this->value === $other->value;37    }38}
php src/Ordering/Domain/Model/Order.php
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Model;6 7use App\Ordering\Domain\Event\OrderPlaced;8use App\Ordering\Domain\Event\OrderShipped;9use App\Ordering\Domain\Event\OrderCancelled;10use App\Ordering\Domain\Event\OrderConfirmed;11use App\Ordering\Domain\Event\OrderItemAdded;12use App\Ordering\Domain\Event\OrderPaid;13// Výjimky vypisují pozdější kapitoly: první dvě Implementace v Symfony 814// (10.12), OrderLockedBySagaException kapitola o ságách (14.06).15use App\Ordering\Domain\Exception\EmptyOrderException;16use App\Ordering\Domain\Exception\OrderLockedBySagaException;17use App\Ordering\Domain\Exception\InvalidOrderStateTransitionException;18use App\Ordering\Domain\ValueObject\CustomerId;19use App\Ordering\Domain\ValueObject\OrderId;20use App\Ordering\Domain\ValueObject\OrderStatus;21use App\Ordering\Domain\ValueObject\ProductId;22// ShipmentId patří cizímu kontextu – přes hranici jde jen identita,23// stejný tvar jako OrderId (final readonly class s validovaným UUID).24use App\Shipping\Domain\ValueObject\ShipmentId;25use App\SharedKernel\Domain\AggregateRoot;26use Doctrine\Common\Collections\ArrayCollection;27use Doctrine\Common\Collections\Collection;28use App\SharedKernel\Domain\Money;29 30class Order extends AggregateRoot31{32    /** @var Collection<int, OrderItem> */33    private Collection $items;34 35    // Asymetrická viditelnost: přečte kdokoli, zapíše jen kód uvnitř třídy.36    // Getter tím odpadá a stavové přechody zůstávají jediným místem zápisu.37    public private(set) OrderStatus $status;38 39    // Čas potvrzení drží agregát, protože na něm stojí doménové pravidlo:40    // storno lhůta v kapitole o autorizaci. Údaj čitelný jen z události41    // by k tomu agregát nutil sahat do vlastní historie.42    public private(set) ?\DateTimeImmutable $placedAt = null;43 44    // Semantic lock: dokud nad objednávkou běží proces, nesmí do ní sáhnout45    // uživatel. Podrobněji v kapitole o ságách, sekce Izolace ság.46    private bool $sagaInProgress = false;47 48    private function __construct(49        public readonly OrderId $id,50        public readonly CustomerId $customerId, // POZOR: ID, ne objekt Customer51    ) {52        $this->status = OrderStatus::Draft;53        $this->items = new ArrayCollection();54    }55 56    // Kanonická továrna knihy: identita a vlastník, nic víc. Volá ji deset57    // dalších kapitol, takže se odsud nesmí ztratit.58    public static function place(OrderId $id, CustomerId $customerId): self59    {60        $order = new self($id, $customerId);61        $order->record(new OrderPlaced($id, $customerId));62 63        return $order;64    }65 66    // Invariant „objednávka má alespoň jednu položku“ vymáhá signatura:67    // bez první položky objednávka nevznikne. Vedle place() je to druhá68    // továrna, ne jeho náhrada.69    public static function placeWithFirstItem(70        CustomerId $customerId,71        ProductId $productId,72        int $quantity,73        Money $unitPrice,74        ?\DateTimeImmutable $at = null,75    ): self {76        $order = self::place(OrderId::generate(), $customerId);77        $order->addItem($productId, $quantity, $unitPrice);78        // Objednávka přišla kompletní, takže rovnou opouští Draft.79        // Draft je stav rozpracovaného košíku, ne odeslané objednávky.80        $order->confirm($at);81 82        return $order;83    }84 85    public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void86    {87        if ($this->status !== OrderStatus::Draft) {88            throw InvalidOrderStateTransitionException::notAllowedInState(89                'přidání položky',90                $this->status->value,91            );92        }93 94        // INVARIANT: jedna položka na produkt – sčítáme quantity, neduplikujeme95        foreach ($this->items as $existing) {96            if ($existing->productId->equals($productId)) {97                $existing->increaseQuantity($quantity);98                $this->record(new OrderItemAdded($this->id, $productId, $quantity));99 100                return;101            }102        }103 104        // Položka dostane referenci na kořen – ta drží ManyToOne protistranu105        // kolekce. Vlastní doménové ID nemá, viz mapování níž.106        $this->items->add(new OrderItem($this, $productId, $quantity, $unitPrice));107        $this->record(new OrderItemAdded($this->id, $productId, $quantity));108    }109 110    // Čas jde vložit zvenku ze stejného důvodu jako u cancel(): jinak111    // se scénář „potvrzeno v 10:00, stornováno ve 12:00" nedá otestovat112    // jinak než reflexí.113    public function confirm(?\DateTimeImmutable $at = null): void114    {115        if ($this->status !== OrderStatus::Draft) {116            throw InvalidOrderStateTransitionException::cannotTransition(117                $this->status->value,118                OrderStatus::Confirmed->value,119            );120        }121 122        if ($this->items->isEmpty()) {123            throw EmptyOrderException::cannotConfirm();124        }125 126        $this->status = OrderStatus::Confirmed;127        $this->placedAt = $at ?? new \DateTimeImmutable();128        $this->record(new OrderConfirmed($this->id, $this->customerId, $this->placedAt));129    }130 131    // Bez tohohle přechodu je ship() nedosažitelná: do stavu Paid132    // se objednávka jinak nedostane.133    public function markPaid(): void134    {135        // Příkaz jde přes asynchronní transport s garancí at-least-once,136        // takže opakované doručení není chyba volajícího.137        if ($this->status === OrderStatus::Paid) {138            return;139        }140 141        if ($this->status !== OrderStatus::Confirmed) {142            throw InvalidOrderStateTransitionException::cannotTransition(143                $this->status->value,144                OrderStatus::Paid->value,145            );146        }147 148        $this->status = OrderStatus::Paid;149        $this->record(new OrderPaid($this->id, new \DateTimeImmutable()));150    }151 152    public function ship(ShipmentId $shipmentId): void153    {154        if ($this->status === OrderStatus::Shipped) {155            return;156        }157 158        if ($this->status !== OrderStatus::Paid) {159            throw InvalidOrderStateTransitionException::cannotTransition(160                $this->status->value,161                OrderStatus::Shipped->value,162            );163        }164 165        $this->status = OrderStatus::Shipped;166        $this->record(new OrderShipped($this->id, $shipmentId, new \DateTimeImmutable()));167    }168 169    // Čas přebírá parametr, ne new \DateTimeImmutable() uvnitř: kapitola170    // o autorizaci na něm staví storno lhůtu a testy potřebují zadat vlastní.171    public function lockForSaga(): void172    {173        $this->sagaInProgress = true;174    }175 176    public function releaseSagaLock(): void177    {178        $this->sagaInProgress = false;179    }180 181    public function cancel(string $reason, \DateTimeImmutable $when): void182    {183        // Zámek drží proces, ne uživatel. Bez téhle podmínky by storno184        // prošlo uprostřed ságy, ta by dál strhla platbu a vytvořila185        // zásilku k objednávce, která už neexistuje.186        if ($this->sagaInProgress) {187            throw new OrderLockedBySagaException($this->id);188        }189 190        // Storno je hrana grafu jako každá jiná: odeslanou ani doručenou191        // zásilku zpátky nevrátí, tam nastupuje kompenzace v ságe.192        // Výčet sedí s tabulkou přechodů OrderStatus z kapitoly 10.193        if (in_array($this->status, [OrderStatus::Shipped, OrderStatus::Delivered], true)) {194            throw InvalidOrderStateTransitionException::cannotTransition(195                $this->status->value,196                OrderStatus::Cancelled->value,197            );198        }199 200        // Opakované storno není chyba volajícího, jen už není co dělat.201        // Bez téhle větve by retry ságy shodil handler.202        if ($this->status === OrderStatus::Cancelled) {203            return;204        }205 206        $this->status = OrderStatus::Cancelled;207        $this->record(new OrderCancelled(208            $this->id,209            $this->customerId,210            $reason,211            $when,212        ));213    }214 215    // Vlastnictví patří agregátu, ne Voteru. Kapitola o autorizaci216    // na tom staví celé rozhodování o přístupu.217    public function isOwnedBy(CustomerId $customerId): bool218    {219        return $this->customerId->equals($customerId);220    }221 222    public function totalAmount(): Money223    {224        // Guard je nutný: kanonické place() prázdnou objednávku pustí.225        // Bez něj by součet vracel tichou nulu v natvrdo zvolené měně.226        if ($this->items->isEmpty()) {227            throw EmptyOrderException::cannotBePlaced();228        }229 230        $items = $this->items->toArray();231        $total = $items[0]->subtotal(); // měnu určuje první položka232 233        foreach (array_slice($items, 1) as $item) {234            $total = $total->add($item->subtotal());235        }236 237        return $total;238    }239}

Konstruktor je private: vznik agregátu řídí statické továrny. U placeWithFirstItem() vymáhá invariant „objednávka musí mít alespoň jednu položku“ už signatura. Bez první položky objednávka nevznikne. Kanonické place(OrderId, CustomerId) z Základních konceptů tuhle záruku nedává, proto ji totalAmount() a confirm() kontrolují za běhu. customerId je hodnotový objekt, ne reference na entitu. Stavové přechody markPaid(), ship() a cancel() jsou jediný způsob, jak změnit status; OrderStatus se nikdy nenastavuje setterem zvenčí. Volání record() ukládá událost do interní fronty bázové třídy AggregateRoot; vyzvednutí přes releaseEvents() po flushi popisuje lifecycle sekce v Základních konceptech.

Od PHP 8.4 podporuje zapouzdření stavu i jazyk sám, a to asymetrickou viditelností:

php src/Ordering/Domain/Model/Order.php (výřez)
1class Order extends AggregateRoot2{3    public private(set) OrderStatus $status;4 5    public function ship(ShipmentId $shipmentId): void6    {7        // ... kontrola stavu ...8        $this->status = OrderStatus::Shipped; // zápis jen uvnitř třídy9    }10}

Vlastnost public private(set) přečte kdokoli bez getteru ($order->status), zapsat ji smí jen kód uvnitř třídy. Getter status() tím odpadá a stavové přechody zůstávají jediným místem zápisu.

Stavové přechody tvoří uzavřený graf a ten musí být vymodelovaný celý. Každá doménová operace odpovídá hraně grafu; cesty, které v grafu chybí, nejsou jen „ještě neimplementované“ – jsou explicitně zakázané. Životní cyklus agregátu Order ilustruje následující diagram:

FIG. 07.7-A Stavový diagram agregátu Order

Události, které přechody nahrávají, mají jednotný tvar: identita agregátu jako hodnotový objekt a čas vzniku. Na occurredAt staví projekce i outbox, takže pole nese každá z nich a jmenuje se všude stejně.

php src/Ordering/Domain/Event/OrderItemAdded.php + OrderConfirmed.php + OrderPaid.php + OrderShipped.php + OrderCancelled.php
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Event;6 7use App\Ordering\Domain\ValueObject\CustomerId;8use App\Ordering\Domain\ValueObject\OrderId;9use App\Ordering\Domain\ValueObject\ProductId;10use App\Shipping\Domain\ValueObject\ShipmentId;11 12final readonly class OrderItemAdded13{14    public function __construct(15        public OrderId $orderId,16        public ProductId $productId,17        public int $quantity,18    ) {}19}20 21final readonly class OrderConfirmed22{23    public function __construct(24        public OrderId $orderId,25        public CustomerId $customerId,26        public \DateTimeImmutable $occurredAt,27    ) {}28}29 30final readonly class OrderPaid31{32    public function __construct(33        public OrderId $orderId,34        public \DateTimeImmutable $occurredAt,35    ) {}36}37 38final readonly class OrderShipped39{40    public function __construct(41        public OrderId $orderId,42        public ShipmentId $shipmentId,43        public \DateTimeImmutable $occurredAt,44    ) {}45}46 47final readonly class OrderCancelled48{49    public function __construct(50        public OrderId $orderId,51        public CustomerId $customerId,52        public string $reason,53        public \DateTimeImmutable $occurredAt,54    ) {}55}

Událost vydává každá hrana grafu: addItem(), confirm(), markPaid(), ship() i cancel(). Kdyby některá mlčela, projekce a ságy by o tom přechodu nevěděly – a testy v kapitole Testování DDD události očekávají. OrderPaid nese jen identitu a čas. Kdo a čím platil, ví sága z události platebního kontextu; agregát Order to nezajímá.

Tři výjimky, které agregát hází, kniha vypisuje později. InvalidOrderStateTransitionException a EmptyOrderException definuje kapitola Implementace v Symfony 8, OrderLockedBySagaException kapitola Ságy a Process Managery. Všechny dědí z \DomainException a mají pojmenované továrny, které výpis výše volá.

ShipmentId bydlí v kontextu Shipping a má stejnou stavbu jako OrderId:

php src/Shipping/Domain/ValueObject/ShipmentId.php
1<?php2 3declare(strict_types=1);4 5namespace App\Shipping\Domain\ValueObject;6 7use Symfony\Component\Uid\Uuid;8 9final readonly class ShipmentId10{11    // Konstruktor je veřejný stejně jako u OrderId. Serializer Messengeru12    // hodnotový objekt jinak nesestaví a událost se z fronty nevrátí.13    public function __construct(14        public string $value,15    ) {16        if (!Uuid::isValid($value)) {17            throw new \InvalidArgumentException("Neplatné ShipmentId: {$value}");18        }19    }20 21    public static function generate(): self22    {23        return new self((string) Uuid::v7());24    }25 26    public static function fromString(string $value): self27    {28        return new self($value);29    }30 31    public function __toString(): string32    {33        return $this->value;34    }35}

07.08 Mapování v Symfony 8 a Doctrine ORM 3#

Doctrine ORM je v Symfony projektech výchozí volba a právě jeho konfigurace nejčastěji rozhodne, jestli agregátní model zůstane čistý, nebo se rozplyne. Vernon v IDDD probírá agregát v kapitole 10 a jeho perzistenci v kapitole 12 „Repositories“. Šest pravidel pro Doctrine ORM 3, na která pak navazuje výčet toho, co za vás Doctrine nevymůže:

  • Asociace pouze uvnitř agregátu. OneToMany a ManyToOne používejte jen mezi entitami v hranici stejného agregátu. Reference na cizí agregát je vlastnost typu CustomerId, namapovaná jako custom Doctrine type.
  • Repository per agregát. Jeden repozitář na jeden agregát. Repozitář vrací pouze kořen, nikdy vnitřní entity. get(), save(), případně několik specializovaných metod, ne obecné findBy z EntityRepository.
  • Optimistický zámek na kořeni. #[ORM\Version] sloupec na kořeni agregátu. Souběžná změna kořene skončí výjimkou OptimisticLockException, kterou aplikační vrstva překládá na retry nebo na uživatelskou chybu. Změny vnitřních entit ale sám o sobě nepokryje (viz odstavec o verzování níže).
  • Doménové eventy přes outbox. Eventy publikované agregátem se ve stejné transakci ukládají do outbox tabulky. Samostatný worker je odesílá do Messenger transportu (kapitola Outbox).
  • Bez kaskádování přes hranici. cascade={"persist","remove"} mezi agregáty je skrytá transakce. Kaskáda je v pořádku jen uvnitř agregátu pro vlastní entity.
  • Embedded value objects. Hodnotové objekty s více poli (Money, Address) mapujte přes #[ORM\Embedded]. Žádné samostatné tabulky pro VO.
php src/Ordering/Infrastructure/Doctrine/Type/OrderIdType.php
1<?php2 3declare(strict_types=1);4 5// Typ patří kontextu, jehož identitu převádí. Ve SharedKernelu by6// znamenal závislost sdíleného jádra na Bounded Contextu.7namespace App\Ordering\Infrastructure\Doctrine\Type;8 9use App\Ordering\Domain\ValueObject\OrderId;10use Doctrine\DBAL\Platforms\AbstractPlatform;11use Doctrine\DBAL\Types\Exception\InvalidType;12use Doctrine\DBAL\Types\Type;13 14// CustomerIdType a ProductIdType vypadají stejně, jen s jiným VO a názvem typu.15final class OrderIdType extends Type16{17    public function getSQLDeclaration(array $column, AbstractPlatform $platform): string18    {19        // 36 znaků = RFC 4122 zápis UUID, který vrací OrderId::generate()20        return $platform->getStringTypeDeclarationSQL(['length' => 36, 'fixed' => true]);21    }22 23    public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?OrderId24    {25        return $value === null ? null : OrderId::fromString((string) $value);26    }27 28    public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string29    {30        if ($value === null || $value instanceof OrderId) {31            return $value?->value;32        }33 34        // Bez téhle větve by řetězec skončil jako NULL a dotaz by tiše35        // nenašel nic – nejhorší druh chyby, protože nikde nespadne.36        throw InvalidType::new($value, self::class, ['null', OrderId::class]);37    }38}

Třída záměrně nemá metodu getName(), protože ji DBAL 4 odstranil. Jméno typu (order_id) určuje výhradně klíč v konfiguraci doctrine.dbal.types níže a pod stejným jménem na typ odkazuje atribut #[ORM\Column(type: 'order_id')]. Spolu s getName() zmizela i metoda requiresSQLCommentHint(), takže DBAL už u vlastního typu nezanechá ve schématu komentář. Na porovnávání schématu to nemá vliv: AbstractPlatform::columnsEqual() srovnává vygenerovanou SQL deklaraci, ne PHP typ, a CHAR(36) z OrderIdType odpovídá introspektovanému sloupci. Prázdné migrace z toho nevznikají. Cenu za zmizelý komentář zaplatíte jinde: dva vlastní typy nad stejnou SQL deklarací už od sebe schema diff nerozezná, takže záměnu order_id za customer_id v mapování migrace neodhalí.

CustomerIdType a ProductIdType, které registruje doctrine.yaml níže, mají stejnou stavbu. Liší se jen názvem typu a hodnotovým objektem, který převádějí; kniha je proto nevypisuje.

php src/Ordering/Domain/Model/Order.php (mapování)
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Model;6 7use App\Ordering\Domain\ValueObject\CustomerId;8use App\Ordering\Domain\ValueObject\OrderId;9use App\Ordering\Domain\ValueObject\OrderStatus;10use App\SharedKernel\Domain\AggregateRoot;11use Doctrine\Common\Collections\ArrayCollection;12use Doctrine\Common\Collections\Collection;13use Doctrine\ORM\Mapping as ORM;14 15#[ORM\Entity]16#[ORM\Table(name: 'orders')]17class Order extends AggregateRoot18{19    #[ORM\Column(enumType: OrderStatus::class)]20    public private(set) OrderStatus $status;21 22    #[ORM\Column(type: 'datetime_immutable', nullable: true)]23    public private(set) ?\DateTimeImmutable $placedAt = null;24 25    #[ORM\Column(type: 'boolean')]26    private bool $sagaInProgress = false;27 28    // Mapování mění typ kolekce: místo pole list<OrderItem>29    // z čisté doménové varianty vyžaduje Doctrine Collection.30    /** @var Collection<int, OrderItem> */31    #[ORM\OneToMany(32        mappedBy: 'order',33        targetEntity: OrderItem::class,34        cascade: ['persist', 'remove'], // OK: kaskáda uvnitř agregátu35        orphanRemoval: true,36    )]37    private Collection $items;38 39    // POZOR: žádné ManyToOne na Customer – jen CustomerId.40    // Žádné ManyToOne na Product – jen ProductId v OrderItem.41 42    #[ORM\Version]43    #[ORM\Column(type: 'integer')]44    private int $version = 1;45 46    private function __construct(47        #[ORM\Id]48        #[ORM\Column(type: 'order_id')]49        public readonly OrderId $id,50 51        #[ORM\Column(type: 'customer_id')]52        public readonly CustomerId $customerId, // ID, ne ManyToOne na Customer53    ) {54        $this->status = OrderStatus::Draft;55        $this->items = new ArrayCollection();56    }57 58    // ... factory metody, doménové operace ...59}

Výpis nahrazuje deklarace vlastností z 07.07, nepřidává se k nim. Kdo oba bloky slepí za sebe, dostane Cannot redeclare Order::$status, a stejně tak u $placedAt a $items. Identita a vlastník zůstávají promované v konstruktoru; atributy Doctriny sedí přímo na parametrech, takže i pro ně platí jedna deklarace, ne dvě. Metody a továrny se naopak berou z 07.07, ten je tady vynechává jen kvůli délce.

Mapování výše odkazuje na dvě třídy, které bez vlastních atributů nefungují. Money je embeddable hodnotový objekt; OrderItem je plnohodnotná entita uvnitř hranice agregátu a potřebuje vlastní identitu i zpětnou referenci, jinak mappedBy: 'order' nemá protistranu a mapování skončí chybou. Třetí třída ve výpisu, ShippingAddress, ukazuje embeddable s více poli a vlastní validací. Kanonický Order ji jako vlastnost nenese – stejně jako verze ze Základních konceptů a zbytek knihy s dvouparametrovým place(). S objednávkou rozšířenou o shippingAddress pracují až specifikace v kapitole Méně známé vzory:

php src/SharedKernel/Domain/Money.php + Ordering/Domain/ValueObject/ShippingAddress.php + Model/OrderItem.php
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain;6 7use Doctrine\ORM\Mapping as ORM;8 9// Money je embeddable, jinak #[ORM\Embedded] v Order ani OrderItem neprojde.10// Doménová definice ze Základních konceptů zůstává, přibývají jen atributy.11#[ORM\Embeddable]12final readonly class Money13{14    public function __construct(15        #[ORM\Column]16        public int $amountInCents,17        #[ORM\Column(enumType: Currency::class)]18        public Currency $currency,19    ) {20        if ($amountInCents < 0) {21            throw new \InvalidArgumentException('Money cannot be negative');22        }23    }24 25    // add(), subtract(), multiply(), percentage(), equals() – viz 06.0426}27 28// --- src/Ordering/Domain/ValueObject/ShippingAddress.php ---29namespace App\Ordering\Domain\ValueObject;30 31#[ORM\Embeddable]32final readonly class ShippingAddress33{34    public function __construct(35        #[ORM\Column]36        public string $street,37        #[ORM\Column]38        public string $city,39        #[ORM\Column(length: 16)]40        public string $postalCode,41        #[ORM\Column(length: 2)]42        public string $countryCode,43    ) {44        if (strlen($countryCode) !== 2) {45            throw new \InvalidArgumentException('Country code must be ISO 3166-1 alpha-2');46        }47    }48}49 50// --- src/Ordering/Domain/Model/OrderItem.php ---51namespace App\Ordering\Domain\Model;52 53use App\Ordering\Domain\ValueObject\ProductId;54use App\SharedKernel\Domain\Money;55use Doctrine\ORM\Mapping as ORM;56 57#[ORM\Entity]58#[ORM\Table(name: 'order_items')]59final class OrderItem60{61    // Náhradní identita. Položka nemá doménové ID – zvenčí se na ni62    // neodkazuje, přistupuje se k ní výhradně přes kořen.63    #[ORM\Id]64    #[ORM\GeneratedValue]65    #[ORM\Column]66    private ?int $id = null;67 68    public function __construct(69        #[ORM\ManyToOne(inversedBy: 'items')]70        #[ORM\JoinColumn(nullable: false)]71        private Order $order,72        #[ORM\Column(type: 'product_id')]73        public readonly ProductId $productId,74        // private(set): zvenčí čitelné, měnit smí jen položka sama.75        // readonly by nešlo – increaseQuantity() hodnotu mění.76        #[ORM\Column]77        public private(set) int $quantity,78        #[ORM\Embedded(class: Money::class)]79        public readonly Money $unitPrice,80    ) {}81 82    public function increaseQuantity(int $by): void83    {84        if ($by < 1) {85            throw new \InvalidArgumentException('Quantity increment must be positive');86        }87 88        $this->quantity += $by;89    }90 91    public function subtotal(): Money92    {93        return $this->unitPrice->multiply($this->quantity);94    }95}

Embeddable nesmí být nullable. #[ORM\Embedded] nad ?ShippingAddress vypadá nevinně, ale Doctrine při načtení nikdy nevrátí null. Vrátí polovystavěnou instanci, na které $address->street skončí Error: Typed property must not be accessed before initialization. Test if ($address !== null) je vždycky pravdivý, takže chybu neodchytí. Hodnota, která nemusí existovat, proto patří buď do samostatné entity, nebo do skalárních nullable sloupců, ne do embeddable. Právě proto Order výše adresu nedrží: doručovací údaje vznikají až při checkoutu a patří k němu, ne ke vzniku objednávky.

Náhradní int identita je vědomé rozhodnutí, ne nedbalost. OrderItem nemá doménovou identitu, protože se na položku zvenčí agregátu nikdo neodkazuje. UUID by tu jen zabíralo místo. Kdyby se odkazoval, byl by to signál, že položka patří do vlastního agregátu.

php src/Ordering/Infrastructure/Repository/DoctrineOrderRepository.php
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Repository;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\ValueObject\OrderId;9// Definici výjimky vypisuje Implementace v Symfony 8 (10.12).10use App\Ordering\Domain\Exception\OrderNotFoundException;11use App\Ordering\Domain\Repository\OrderRepository;12use Doctrine\ORM\EntityManagerInterface;13 14final class DoctrineOrderRepository implements OrderRepository15{16    public function __construct(17        private readonly EntityManagerInterface $em,18    ) {}19 20    public function get(OrderId $id): Order21    {22        $order = $this->em->find(Order::class, $id);23 24        if ($order === null) {25            throw OrderNotFoundException::withId($id);26        }27 28        return $order;29    }30 31    public function save(Order $order): void32    {33        $this->em->persist($order);34        // flush a commit řídí doctrine_transaction middleware command busu.35        // Při flushi se uloží kořen + vnitřní entity (OrderItem) díky36        // cascade={"persist"}; Doctrine vyhodí OptimisticLockException,37        // pokud se @Version mezitím změnila.38    }39 40    // ŽÁDNÉ findAll(), findBy(), žádné metody pro čtení vnitřních entit.41    // Read modely jsou samostatné (CQRS, kapitola 12).42}
yaml config/packages/doctrine.yaml (cílový stav po kapitole 15)
1# config/packages/doctrine.yaml2doctrine:3    dbal:4        url: '%env(resolve:DATABASE_URL)%'5        types:6            order_id:    App\Ordering\Infrastructure\Doctrine\Type\OrderIdType7            customer_id: App\Ordering\Infrastructure\Doctrine\Type\CustomerIdType8            product_id:  App\Ordering\Infrastructure\Doctrine\Type\ProductIdType9            # Money se sem nepřidává – mapuje se přes #[ORM\Embedded] podle10            # pravidla výše. Jednosloupcový custom typ by zabil SUM() i ORDER BY.11 12    orm:13        # Bez underscore strategie vzniknou sloupce occurredAt, createdAt…14        # a rozejdou se s indexy i raw SQL, které kniha píše se snake_case.15        naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware16        identity_generation_preferences:17            Doctrine\DBAL\Platforms\PostgreSQLPlatform: identity18        mappings:19            Ordering:20                type: attribute21                dir: '%kernel.project_dir%/src/Ordering/Domain'22                prefix: 'App\Ordering\Domain'23                is_bundle: false24            # Embeddable hodnotové objekty sdíleného jádra (Money) musí být25            # v mapping chainu taky, jinak Doctrine hlásí26            # „class … was not found in the chain configured namespaces“.27            SharedKernel:28                type: attribute29                dir: '%kernel.project_dir%/src/SharedKernel/Domain'30                prefix: 'App\SharedKernel\Domain'31                is_bundle: false32            # Entity mimo doménové složky potřebují vlastní blok. Bez něj33            # Doctrine na schema:update mlčí („Nothing to update“) a tabulka34            # prostě nevznikne – proto je tu vypisujeme, ne jen zmiňujeme.35            #36            # POZOR: následující bloky mapují adresáře, které vzniknou až37            # v pozdějších kapitolách (ságy 14, outbox a inbox 15, identita 11).38            # Kdo čte popořadě, přidá si je až s příslušnou kapitolou; jinak39            # kontejner spadne na „Specified non-existing directory … as40            # Doctrine mapping source“.41            OrderingSaga:42                type: attribute43                dir: '%kernel.project_dir%/src/Ordering/Application/Saga'44                prefix: 'App\Ordering\Application\Saga'45                is_bundle: false46            Outbox:47                type: attribute48                dir: '%kernel.project_dir%/src/Outbox/Domain'49                prefix: 'App\Outbox\Domain'50                is_bundle: false51            Inbox:52                type: attribute53                dir: '%kernel.project_dir%/src/Inbox/Domain'54                prefix: 'App\Inbox\Domain'55                is_bundle: false56            UserManagement:57                type: attribute58                dir: '%kernel.project_dir%/src/UserManagement/Domain'59                prefix: 'App\UserManagement\Domain'60                is_bundle: false61            # SecurityUser z kapitoly o autorizaci sedí v infrastruktuře,62            # ne v doméně – bez vlastního bloku by tabulka nevznikla63            # a firewall by neměl kde brát uživatele.64            Identity:65                type: attribute66                dir: '%kernel.project_dir%/src/Identity/Infrastructure'67                prefix: 'App\Identity\Infrastructure'68                is_bundle: false

Co Doctrine nevymůže

Šest pravidel výše vypadá jako konfigurace. Ve skutečnosti jsou to konvence, které nikdo nekontroluje. Matthias Noback k tomu sepsal podrobný výčet třecích ploch mezi Doctrine a agregátem [12]. Dvě z pravidel se v provozu lámou tiše.

První je verzování. #[ORM\Version] zvýší verzi jen tehdy, když se změnilo pole na kořeni. Změna vnitřní entity, typicky OrderItem, se do verze Orderu nepromítne. Dva požadavky, z nichž každý upraví jinou položku téže objednávky, projdou oba a invariant „součet položek se rovná celkové ceně“ se rozpadne, aniž kdokoli dostane OptimisticLockException. Doctrine na to nemá ekvivalent JPA konstanty OPTIMISTIC_FORCE_INCREMENT; požadavek na ni je otevřený od roku 2013 [10]. Obejít to lze třemi způsoby. Doménová metoda kořene se při každé změně potomka dotkne vlastního pole (přepočtená totalAmount nebo updatedAt). To navíc dává doménový smysl. Druhá cesta je explicitní $em->lock($order, LockMode::OPTIMISTIC, $expectedVersion) s verzí, kterou drží klient. Třetí je pesimistický zámek, tedy LockMode::PESSIMISTIC_WRITE, za cenu propustnosti.

Druhá trhlina je hranice transakce. EntityManager::flush() commitne všechny špinavé entity ve své Unit of Work, ne jen agregát, který handler načetl. Repozitář z ukázky výše tedy pravidlo „jeden agregát na transakci“ nevynucuje. Pokud handler cestou sáhne na cizí agregát a jen mu změní vlastnost, Doctrine ho uloží zároveň – bez varování. Vynutí to jen kázeň, code review a architektonický test (kapitola Testování DDD). Kdo chce hranici doménového modelu oddělit od perzistentního úplně, sáhne po Persisted Object Pattern (Implementace v Symfony 8). Doménová třída zůstane bez ORM atributů a mapování obstará samostatná persistence třída s mapperem. Cenou je vrstva navíc, výhodou to, že Doctrine přestane ovlivňovat tvar agregátu.

07.09 Pokročilá témata: large collection, hot aggregate, snapshoty#

Large-collection problem

Klasický anti-vzor: agregát Project drží OneToMany kolekci úkolů. S desítkami úkolů je to v pořádku, s tisíci neúnosné. Každé načtení agregátu hydratuje celou kolekci a každé přidání položky způsobí flush všech úkolů. Nabízejí se tři východiska, seřazená od nejčistšího po nejvíc kompromisní:

  • Rozdělit agregát. Project a Task se stanou samostatnými agregáty a Task nese jen ProjectId jako referenci. Invariant „úkol patří k existujícímu projektu“ pak nevymáhá Doctrine asociace, ale command handler přes ProjectExistsSpecification, která před založením úkolu ověří, že projekt existuje.
  • Doctrine extra-lazy collection. fetch: 'EXTRA_LAZY' umožní $project->getTasks()->count() bez načtení kolekce, případně $project->getTasks()->matching($criteria). Použitelné, pokud agregát kolekci skutečně potřebuje pro invarianty (např. limit počtu úkolů na projekt). Jedna past: neinicializovaná kolekce kritérium přeloží do SQL, načtená ho vyhodnotí v paměti nad už zhydratovanými objekty. S backed enumem v kritériu vyjdou obě cesty stejně, se surovou databázovou hodnotou ('open' místo TaskStatus::Open) ne. Nad načtenou kolekcí porovnání s enumem neprojde a výsledek je prázdný. Do kritéria patří enum, ne řetězec.
  • Agregát jako hranice služby. Kolekci nahradí služba pracující s agregátem, která invariant ověří dotazem v repozitáři. Funguje, ale signalizuje špatnou hranici.

Hot aggregate

Hot aggregate je agregát, na který souběžně sahá mnoho uživatelů (nákupní košík během Black Friday, sportovní výsledek, hra v reálném čase). Optimistický zámek tu selhává. Většina transakcí spadne na OptimisticLockException, retry trvá a uživatelská zkušenost se hroutí.

Absolutní hranice v transakcích za sekundu neexistuje. Pravděpodobnost konfliktu určuje součin frekvence zápisů na jednu instanci a doby, po kterou transakce drží stav. Agregát s deseti zápisy za sekundu a transakcí o délce jedné milisekundy je klidný; tentýž agregát s transakcí trvající dvě stě milisekund konflikty vyrábí. Měří se tedy obojí a teprve z toho vychází rozhodnutí. Přístupy:

  • Rozdělit agregát na menší. Místo Stadium s tisícem sedaček vznikne Section s desítkami. Souběžné transakce se rozprostřou.
  • Přepnout na Event Sourcing. ES eliminuje race condition na update, protože každý event je append-only. Konflikty řeší stream version (kapitola Event Sourcing).
  • Single-writer pattern. Agregát existuje v paměti jediného procesu (actor model, Akka, Orleans). Symfony to nativně neumí; alternativou je Messenger se směrováním přes konzistentní hash a single consumer per aggregate ID.
  • Přijmout eventual consistency uvnitř. Například u čítačů (like count) je přesný stav nedůležitý, stačí zpožděná replikace s nepřesností v řádech sekund.

Snapshoty v Event Sourcingu

U Event-Sourced agregátů má rebuild stavu z eventů složitost O(N). Snapshot ukládá serializovaný stav agregátu po N eventech; při načtení se stav rekonstruuje od posledního snapshotu a navrch se aplikuje zbývající ocas streamu. Práh N se měří, neodhaduje: závisí na velikosti eventů i na tom, kolik replay reálně stojí. Dlouhý stream je navíc častěji příznakem hranice, která patří jinam, než skutečné potřeby snapshotu.

Pro návrh agregátu jsou důležité tři věci. Snapshot není autoritativní stav, jen optimalizace. Když se serializace nepovede, stav se sestaví znovu od začátku streamu. Jeho verzování musí být kompatibilní s verzováním eventů, takže změna schématu stavu znamená invalidaci starých snapshotů. A snapshot store zůstává oddělený od event store, plněný procesem na pozadí. Snapshot zapsaný přímo do event logu musí být vždy na poslední verzi, čímž si u vytížených agregátů vyrábí vlastní smyčku konfliktů. Implementaci rozebírá kapitola Event Sourcing.

Partitioning a multi-tenancy

Pro návrh agregátu má multi-tenancy jediný, zato tvrdý důsledek: tenantId je součást identity agregátu, ne filtr přilepený k dotazu. Repozitář přijímá dvojici (tenantId, aggregateId) a operace jednoho tenanta nikdy nesáhne na instanci jiného.

Volba databázové topologie na tomto závěru nic nemění. Doctrine SQLFilter s tenant_id = :current_tenant je nejlevnější a nativním SQL obejitelný, schema per tenant izoluje víc za cenu cross-schema reportů, databáze per tenant izoluje nejvíc a stojí nejvíc provozně. Rozhodnutí patří do infrastruktury; hranice agregátu zůstává ve všech třech případech stejná.

07.10 Strategie referencování napříč agregáty#

Reference přes ID je jasné pravidlo, ale typů ID je víc a každý má dopad na schéma a výkon.

  • UUID v4 (random). Náhodná, distribuovaně generovatelná, neuhodnutelná. Nevýhoda: insertion order není seřazený a u clustered indexů (MySQL/InnoDB) to zhoršuje I/O pattern.
  • UUID v7 (případně ULID). Časově řazené, generovatelné distribuovaně bez koordinace, řadí se podle času vzniku. Doporučená volba pro většinu nových projektů. Uuid::v7() i ULID (Symfony\Component\Uid\Ulid) nabízí balíček symfony/uid.
  • Sekvenční integer. Krátký, lidsky čitelný, rychlý. Nevýhody: vyžaduje centrální generátor (DB sekvence), prozrazuje řád a počet entit, špatně se merguje z více DB (microservices).
  • Composite ID. (tenantId, naturalId). Vhodné pro multi-tenancy. Nevýhoda: každá tabulka má dvousloupcový PK, JOIN podmínky jsou složitější.
  • Natural key. Hodnota z domény (ISBN, IČO, e-mail). Funguje, dokud doména hodnotu nezmění. Nedoporučujeme. Domény své „přirozené klíče“ mění častěji, než se zdá.

07.11 Postup návrhu krok za krokem#

Návrh agregátu je disciplinovaný proces, ne kreslení tříd v IDE. Následující sedmikrokový postup je autorský; kroky 4 a 5 přebírají Vernonovu metodu odhadu, zbytek vychází z praxe na Symfony projektech:

  1. Sepište invarianty. Z Event Stormingu, doménových workshopů nebo rozhovorů s experty vytáhněte všechna pravidla. Každé zformulujte jako větu „v každý okamžik musí platit, že …“. Pravidla, která neprojdou („eventuálně musí platit“), odložte. Budou to ságy.
  2. Seskupte invarianty. Pravidla, která sdílejí stejné entity, jsou kandidáti na společný agregát. Co spolu věcně nesouvisí, patří jinam.
  3. Identifikujte kořen. Pro každou skupinu invariantů vyberte jednu entitu, která je „vstupní branou“. Typicky ta s nejvyšší doménovou autoritou („Order“ vs. „OrderItem“).
  4. Odhadněte velikost tužkou na papíře. Vernon to předvádí na backlog itemu: dvanáctidenní sprint, dvanáct tasků na jeden backlog item a dvanáct záznamů o přeodhadu. Celkem nejvýš pětadvacet objektů, tedy malý agregát. Stejný výpočet pro vlastní doménu zabere půl hodiny, v horším případě hodinu, a nahradí dojem horním odhadem růstu.
  5. Odhadněte konkurenci. Kolik zápisů za sekundu dopadne v peaku na jednu instanci agregátu a jak dlouho drží transakce stav? Součin obou čísel rozhoduje o tom, zda máte hot aggregate a zda sáhnete po některé z technik z 07.09.
  6. Definujte commandy a eventy. Pro každý use case napište command (vstup), doménovou metodu na agregátu (chování) a event (výstup). Eventy nahrávejte explicitně metodou record(); celý cyklus record/release popisuje lifecycle sekce v Základních konceptech.
  7. Code review proti checklistu. Sekce 07.13 níže má checklist s 12 body. Pokud agregát na jakýkoli odpoví „ne“, návrh není hotový.

Reálný příklad postupu na agregátech Project a Task najdete v kapitole Případová studie. Konkrétně v sekcích, kde stejný postup aplikujeme na netriviální doménu správy projektů.

Aggregate Design Canvas

Pro workshopové prostředí existuje hotový formulář. Aggregate Design Canvas od skupiny ddd-crew [11] má devět polí: název, popis, stavové přechody, vymáhané invarianty, korektivní politiky, obsluhované commandy, vytvářené eventy, propustnost a velikost. Šíří se pod licencí CC BY 4.0, takže ho lze upravit pro vlastní tým.

Dvě pole nemá žádná z klasických knih. Corrective Policies popisují, co se stane, když hranici záměrně uvolníme. Kompenzace přestává být důsledkem selhání a stává se součástí návrhu. Throughput a Size nutí odhadnout frekvenci commandů, počet souběžných klientů, tempo růstu a životnost instance; je to Vernonova metoda z kroků 4 a 5 povýšená na standardní kolonku.

Canvas se plní přímo nad výstupem Event Stormingu: commandy, eventy a policy sticky se z něj přenášejí, hot spoty se stávají kandidáty na invarianty.

07.12 Typické chyby#

  • Velký agregát kvůli pohodlí ORM. „Když už máme OneToMany, dáme tam i objednávku.“ Asociace jsou nástroj mapování, ne vodítko pro hranici.
  • Sága tam, kde má být agregát. Pokud invariant musí platit okamžitě, sága ho neudržuje. Pravidlo „pojistka nikdy nesmí být zaplacena bez podepsané smlouvy“ nesnese několik sekund čekání – patří do agregátu.
  • Doménová logika v read modelu. Read model je projekce, ne místo, kde žijí invarianty. Pravidla patří do write modelu, projekce jen reaguje.
  • Domain Event jako notifikace mezi vrstvami. Event není mechanismus pro „když se agregát změní, smaž cache“. Eventy jsou doménová fakta, ne infrastrukturní signály. Cache invalidaci řešte v projekci, která event konzumuje.

Několik dalších chyb má společného jmenovatele: obcházení kořene. $order->getItems()->add(...) mění kolekci mimo agregát. Zvenčí má kolekce zůstat immutable a položka se přidává výhradně metodou na kořeni. Totéž porušení hranice předvádí OrderItemRepository::get(itemId): vnitřní entita se ven nepředává k uchování ani k modifikaci a její „samostatná“ identita patří do read modelu, ne do write modelu. Evans připouští, že reference na vnitřní člen ven vyjde, ale jen pro jedinou operaci, bez uložení do pole a bez zápisu skrz ni. Příbuzným vzorem je anemic aggregate s public settery. Pokud má agregát pro každou vlastnost get/set, je to data structure, ne agregát, a stavové přechody musí být metody vyjadřující doménový záměr (place(), ship(), cancel()).

Variantou téhož na vyšší úrovni je sdílený stav přes službu. Pomocná „OrderService“, která zasahuje do dvou agregátů, je skrytá transakce; pokud služba vykoná $em->flush(), jste v anti-vzoru.

07.13 Checklist návrhu agregátu#

  1. Sepsal jsem invarianty v ubiquitous language (slova z domény, ne z kódu).
  2. U každého invariantu vím, zda musí platit okamžitě, nebo eventuálně.
  3. Hranice agregátu obklopuje invarianty kategorie „okamžitě“.
  4. Na kořeni je optimistický zámek (#[ORM\Version] nebo ekvivalent) a každá doménová metoda, která mění vnitřní entitu, se dotkne i pole na kořeni.
  5. Reference na cizí agregát jsou identifikátorové (Value Object), ne objektové.
  6. Žádná Doctrine asociace nepřekračuje hranici agregátu.
  7. Repozitář vrací jen kořen; vnitřní entita se ven nepředává k uchování ani k modifikaci.
  8. Změny napříč agregáty řeší sága nebo process manager, ne sdílená transakce – a pokud přesto sdílená transakce zůstává, je pojmenovaný důvod proč.
  9. UI počítá s eventual consistency tam, kde ji doména používá.
  10. Kaskádové operace existují jen uvnitř agregátu.
  11. Stavové přechody jsou metody vyjadřující doménový záměr, ne settery.
  12. Identifikátor kořene je Value Object s validací (nikoli holý string/int).

07.14 Další četba#

  • Eric Evans, Domain-Driven Design: Tackling Complexity in the Heart of Software, kap. 6 „The Life Cycle of a Domain Object“ (sekce Aggregates) (Addison-Wesley, 2003) [1].
  • Vaughn Vernon, Effective Aggregate Design, Part I–III (2011) [2] – kanonický text o pravidlech návrhu agregátu, na který odkazuje téměř každá pozdější DDD kniha.
  • Vaughn Vernon, Implementing Domain-Driven Design, kap. 10 „Aggregates“ a kap. 12 „Repositories“ (Addison-Wesley, 2013) [3].
  • Vlad Khononov, Learning Domain-Driven Design, kap. 6 „Tackling Complex Business Logic“ a kap. 7 „Modeling the Dimension of Time“ (O'Reilly, 2021) [4].
  • Pat Helland, Life Beyond Distributed Transactions: an Apostate's Opinion, ACM Queue (2007, reprint 2016) [5].
  • Martin Fowler, DDD_Aggregate (bliki) [6].
  • Greg Young, CQRS Documents (2010) [7] – relevantní především kapitoly o Event Sourcingu a snapshotech.
  • Eric Evans, Domain-Driven Design Reference: Definitions and Pattern Summaries (2015) [8] – volně dostupné PDF s kondenzovanou definicí agregátu, včetně spojení hranice s distribucí.
  • Bertrand Meyer, Object-Oriented Software Construction, 2. vydání (Prentice Hall, 1997) [9] – původní formulace invariantu v Design by Contract.
  • doctrine/orm, issue #3620 „OPTIMISTIC_FORCE_INCREMENT“ (otevřeno 2013) [10] – doklad, že Doctrine verzi kořene při změně potomka nezvyšuje.
  • ddd-crew, Aggregate Design Canvas (CC BY 4.0) [11] – devítipolový formulář pro modelovací workshopy.
  • Matthias Noback, Doctrine ORM and DDD aggregates (2018) [12] – nejpodrobnější výčet třecích ploch mezi Doctrine a hranicí agregátu v PHP.
  • V této příručce navazují kapitoly CQRS, Event Sourcing, Ságy a Process Managery, Outbox Pattern a Případová studie, kde uvidíte aplikaci postupu na konkrétní doméně.

Časté otázky

Jak velký má být agregát?

Tak velký, aby obsahoval všechny invarianty, které musí platit okamžitě, a ne větší. Výchozí volba je agregát s jedním kořenovým objektem a několika hodnotovými objekty plus volitelně několika vnitřními entitami. Větší agregát potřebuje konkrétní obhajobu invariantem, ne pohodlí ORM. Vernon u projektu, který analyzoval, napočítal zhruba 70 % agregátů tvořených jen kořenem s hodnotovými objekty a 30 % se dvěma až třemi entitami. Velikost se odhaduje tužkou na papíře: kolik potomků instance nasbírá za dobu svého života. Kolekce, jejíž růst nic neomezuje, patří ven z agregátu, nebo alespoň na EXTRA_LAZY s filtrováním v repozitáři. Detail v sekci Velikost agregátu.

Proč nelze měnit dva agregáty v jedné transakci?

Technicky to lze a výchozí odpověď zní „nedělejte to“. Hranice agregátu je zároveň hranice konzistence a hranice škálování: zámek napříč agregáty snižuje propustnost, souběžné transakce plodí deadlocky a kód se později nedá rozdělit. Potřeba commitnout dva agregáty najednou je hlavně diagnóza. Nejspíš je špatně vedená hranice. Vernon ale jmenuje čtyři situace, kdy je porušení legitimní (dávkové zakládání z UI, chybějící messaging, vynucené globální transakce, výkon dotazů); rozebírá je sekce Kdy se vodítko poruší. Detail v sekci Transakční konzistence, alternativní řešení v sekci Eventual consistency.

Co je eventual consistency a kdy ji použít?

Eventual consistency znamená, že stav dvou agregátů je konzistentní se zpožděním, ne okamžitě. Jak dlouhé zpoždění je přijatelné, určí doménový expert. Vernon připomíná, že experti běžně připustí sekundy, minuty, hodiny i dny. Použijte ji všude, kde invariant nemusí platit v každý okamžik, například „po vystavení objednávky se zákazníkovi pošle e-mail“ nebo „při změně adresy v Customer agregátu se upraví doručovací adresa v rozpracovaných objednávkách“. Implementačně: agregát A publikuje doménový event, sága ho přijme a v separátní transakci modifikuje agregát B. Pravidla, která musí platit okamžitě (například „bilance debetů a kreditů je nulová“), patří do jednoho agregátu. Detail v sekci Eventual consistency.

Jak v Doctrine ORM 3 namapovat referenci na jiný agregát?

Jako jednoduchý sloupec s vlastním Doctrine typem (order_id, customer_id), který konvertuje mezi databázovou hodnotou a Value Objectem (OrderId, CustomerId). Žádná ManyToOne asociace mezi agregáty. Doctrine asociace ponechte jen pro entity uvnitř stejného agregátu (typicky OneToMany z kořene na vnitřní entity s cascade=["persist", "remove"] a orphanRemoval=true). Hodnotové objekty s více poli (Money, Address) mapujte přes #[ORM\\Embedded]. Detail v sekci Mapování v Doctrine ORM 3.

Co je hot aggregate a jak poznat, že ho mám?

Hot aggregate je agregát, na který se souběžně sahá z mnoha transakcí (nákupní košík během Black Friday, sportovní výsledek, hra v reálném čase, čítač lajků na virálním příspěvku). Příznak v provozu: většina commandů selže s OptimisticLockException, retry trvá sekundy, latence stoupá, uživatelská zkušenost se hroutí. Absolutní práh v transakcích za sekundu neexistuje: riziko konfliktu určuje součin frekvence zápisů na jednu instanci a doby, po kterou transakce drží stav. Měří se proto obojí. Detail příznaků a rozhodovací logika v sekci Hot aggregate.

Jak hot aggregate vyřešit?

Čtyři strategie podle povahy domény. Rozdělení na menší – místo Stadium s tisícem sedaček modelujte Section s desítkami; souběžné transakce se rozprostřou. Event Sourcing – append-only operace eliminují konflikt na update, konflikty řeší stream version (kapitola Event Sourcing). Single-writer pattern – agregát existuje v paměti jediného procesu, v Symfony přes Messenger se směrováním konzistentním hashem. Eventual consistency uvnitř – pro nekritické hodnoty (like count) periodicky replikujte. Volba závisí na povaze invariantu; vodítko v sekci Hot aggregate.

Jaký identifikátor zvolit pro nový agregát?

Pro nové Symfony projekty doporučujeme UUID v7 (Uuid::v7(), balíček symfony/uid). Časově řazená generace zlepšuje I/O pattern v MySQL/InnoDB oproti UUID v4, distribuované vytváření odstraňuje potřebu centrálního generátoru a formát je standardizovaný v RFC 9562. ULID (Symfony\\Component\\Uid\\Ulid) je alternativa se srovnatelnými vlastnostmi a kratším zápisem (26 znaků vs. 36). Sekvenční integery volte jen pro specifický důvod (lidsky čitelné číslo objednávky). Přirozené klíče (e-mail, IČO) nedoporučujeme. Domény mění své „přirozené klíče“ častěji, než se zdá. Srovnání všech pěti strategií v sekci Strategie referencování.

Jak rychle ověřit, že hranice agregátu je správně?

Tři rychlé kontroly. (1) Test invariantu: existuje pravidlo, které by se porušilo, kdybyste agregát rozdělili na dva? (2) Test velikosti: umíte spočítat horní mez počtu potomků, které instance za svůj život nasbírá? (3) Test reference: ven z agregátu se odkazujete jen přes ID, ne přes objektovou referenci? Pokud na všechny tři odpovídáte „ano“, hranice je nejspíš správná. Plný checklist s 12 body v sekci Checklist, sedmikrokový postup návrhu v sekci Postup návrhu.