Agregáty v DDD: Kde nakreslit hranici?

Agregát je slovo, které se v DDD skloňuje nejčastěji a rozumí se mu nejmíň. Tři pravidla pro kreslení hranic agregátů, konzistence mezi nimi a ukázka v Symfony/Doctrine.

// obsah 8
  1. 01 Co to vlastně je agregát?
  2. 02 Pravidlo 1: Invarianty určují hranice
  3. 03 Pravidlo 2: Držte agregáty malé
  4. 04 Pravidlo 3: Odkazujte přes ID, ne přes objekt
  5. 05 Jak na konzistenci mezi agregáty?
  6. 06 Kdy pravidla neplatí?
  7. 07 Jak to vypadá v Symfony a Doctrine?
  8. 08 Zdroje

Agregát je asi nejhůř pochopený pojem z celého DDD.

Před rokem a půl jsem dělal review kódu u jednoho projektu. V kódu byl OrderAggregate, dobře pojmenovaný, vlastní namespace, Doctrine mapování v pořádku – a uvnitř prakticky celé databázové schéma přemapované do objektů: Order, OrderLine, Customer, Address, Discount, ShippingMethod, atd… Prostě ERD diagram přepsaný do PHP.

V takové chvíli pokládám otázku: „Přes co změníte zákazníkovi jméno?“

Odpověď: „Načtu si Customer z repozitáře a změním ho.“

Jenže to je špatně (tedy v rámci DDD). Kdyby Customer byl opravdu součástí order agregátu, musela by změna jít přes aggregate root. Jenže přes kterou objednávku, když jich zákazník má padesát? Změna jména prostě k žádné objednávce nepatří. Tak vede dovnitř „agregátu“ zadní vchod (načtení objektu Customer bokem z repozitáře) a hranice existuje jen v názvu souboru a DDD tím ztrácí význam.

V produkci se to pak projeví na čtení. Endpoint na detail objednávky prožene serializerem celý graf: zákazníka, jeho adresy – a protože zákazník drží kolekci objednávek, klidně i všechny jeho ostatní objednávky. Nikdo to tak nenapsal schválně, serializer jen poslušně prošel vztahy, které mu ORM nabídlo. A z jednoho detailu objednávky jsou najednou desítky SQL dotazů. Přitom v kódu není žádná vyslovená chyba.

Co to vlastně je agregát?

Eric Evans v knize Domain-Driven Design definuje agregát jako skupinu souvisejících objektů, se kterou při změnách dat zacházíme jako s jedním celkem. Vaughn Vernon tuhle definici později zostřil na transakční hranici: agregát je to, co musí zůstat konzistentní po skončení jedné transakce. Souviset spolu může skoro cokoli. Rozhoduje až to, co se musí měnit společně.

Každý agregát má jeden aggregate root – entitu, přes kterou se k celému agregátu přistupuje. Kód zvenčí nevolá metody na vnitřních objektech agregátu přímo, všechno jde přes root.

Nejčastější nedorozumění: agregát není ORM entita se vztahy (hasMany, belongsTo). Doctrine vám s radostí namapuje celé databázové schéma do jednoho grafu objektů, ale to neznamená, že by to celé měl být jeden agregát. Mapování vám řekne, jak se data dostanou z databáze do paměti. O tom, co spolu musí zůstat konzistentní, neříká nic.

Druhé nedorozumění: „agregát“ neznamená „velký“. Agregát může být klidně jedna entita bez jediného value objectu uvnitř. Velikost o ničem nevypovídá.

Jak tedy hranice poznat? Držím se tří pravidel. Nejsou vysloveně moje (kéž by), sepsal je Vernon v sérii článků Effective Aggregate Design, ale z praxe je můžu jen potvrdit.

Pravidlo 1: Invarianty určují hranice

Invariant je podmínka, která musí platit vždy, bez výjimky. Pokud porušení invariantu znamená, že jsou data v nekonzistentním stavu, pak všechny objekty, které daný invariant sdílejí, patří do jednoho agregátu.

Klasický příklad: Order a jeho OrderLine objekty. Řekněme, že byznys pravidlo zní „součet řádků nesmí přesáhnout limit schválený pro tuhle objednávku“. Tohle pravidlo se nedá vyhodnotit nad jedním řádkem – potřebujete vidět limit z objednávky a všechny její řádky najednou.

A hlavně se na to nedá zeptat databáze jedním dotazem a mít hotovo. Když dvě transakce přidají řádek současně, obě si spočítají součet, obě projdou pod limitem a dohromady ho přesáhnou. Aby pravidlo platilo doopravdy, musí být objednávka i její řádky jedna transakční a zamykací jednotka. A přesně tomu se říká agregát.

Druhý příklad ze stejné dvojice: uložený totalAmount. Tady je fér přiznat, že jde o odvozenou hodnotu – kdybyste ji neukládali a počítali při každém čtení z řádků, není co hlídat. Jakmile se ji ale rozhodnete uložit (kvůli výkonu, reportům…), vzniká pravidlo sum(orderLines.price * qty) === order.totalAmount, které musí platit po každé změně. A ohlídat ho umí jedině objekt, který vidí objednávku i řádky najednou – aggregate root.

Naopak Order a Customer žádný takový invariant nesdílejí. Zákazník může změnit jméno, aniž by tím objednávka přestala být konzistentní. To jsou dva oddělené agregáty.

Praktická zkouška: vezměte dva objekty a zeptejte se: „existuje podmínka, která musí platit pro oba zároveň a jejíž porušení by znamenalo nekonzistentní data?“ Pokud ano, patří k sobě. Pokud ne, raději ne.

Tahle zkouška je přímočará, ale vyžaduje, abyste invarianty nejdřív znali. A to je doménová práce, ne technická – musíte pochopit byznys pravidla dřív, než začnete kreslit hranice. Zkratka „nakreslím agregát podle ERD diagramu“ vás dovede přesně k tomu OrderAggregate z úvodu.

Pravidlo 2: Držte agregáty malé

Když agregát narůstá – 5 entit, 8 entit, 15 entit – je to skoro vždy signál, že jste se nechali vést strukturou databáze nebo ORM vztahy, a ne doménovými invarianty.

Vernon radí jít na to z druhé strany: začněte agregátem o jedné entitě a přidávejte jen to, co si vynutí invariant. Opačný postup – nakreslit velký agregát a pak ho osekávat – v praxi málokdo dotáhne.

Vezměte ten OrderAggregate z úvodu a projděte jeho entity jednu po druhé otázkou na invariant. OrderLine zůstane, drží ho limit z prvního pravidla. Customer a Address odejdou do vlastního agregátu, protože zákazník se mění nezávisle na objednávkách. ShippingMethod je ve skutečnosti číselník, ne entita agregátu. A Discount nemá vlastní životní cyklus, takže z něj bude value object uvnitř řádku. Z balíku, do kterého se vešlo celé ERD, zbydou dvě entity.

Dobrá heuristika: pokud při psaní unit testu pro jednu operaci musíte sestavit osm objektů a šest z nich s testovanou operací nesouvisí, agregát je prostě moc velký.

Velké agregáty mají tři reálné problémy. Výkon – každá operace načte celý graf. Souběh – dvě transakce, které mění různé části agregátu, se navzájem blokují při pesimistickém zamykání, nebo způsobují konflikty při optimistickém. A testovatelnost – unit test musí sestavit celý objekt, i když testuje jednu malou věc.

Malý agregát se snáz pochopí, snáz testuje a snáz správně zamkne.

Pravidlo 3: Odkazujte přes ID, ne přes objekt

Když jeden agregát potřebuje odkazovat na jiný, nedrží referenci na objekt, ale jen identifikátor. Order nedrží instanci Customer, drží její ID. OrderLine nedrží objekt Product, drží jeho ID.

Důvod je transakční hranice. Zákazník má vlastní životní cyklus a mění se ve vlastní transakci. Kdybyste ho drželi jako objekt, máte ho v ruce – stačí na něm zavolat setter a Doctrine ho při flush() uloží spolu s objednávkou. Tedy přesně to, co pravidlo „jedna transakce mění jeden agregát“ zakazuje. Přes ID to prostě nejde. Nemůžete omylem změnit něco, co nedržíte.

Výkon je až důsledek a tady se to často říká nepřesně. Samotné držení objektu ještě nic nenačte – ManyToOne je v Doctrine ve výchozím stavu lazy, takže dostanete proxy, ne data. Problém přijde, až na tu proxy sáhnete: ve výpisu objednávek to znamená jeden dotaz navíc na každý řádek tabulky a v kódu ho nikde nevidíte.

Reference přes ID ten problém nezruší, jen ho zviditelní. Zákazníka si musíte dotáhnout sami přes jeho repozitář. Ve výpisu ho ale netahejte po jednom – buď si posbírejte ID a načtěte je jedním dotazem, nebo si na čtení rovnou postavte read model, o kterém je řeč hned v další sekci.

A ještě jedna věc, která z pravidla plyne a lidi překvapí. Mezi agregáty se data kopírují, nejen odkazují. approvedLimit v ukázce níž se přebírá z nastavení zákazníka, ale objednávka si ho drží u sebe. To není duplicita z lenosti, to je snapshot v čase vytvoření. Vždyť když zákazníkovi za půl roku limit zvednete, staré objednávky se tím zpětně měnit nesmí – a kdyby si ho tahaly přes ID, změnily by se.

A pozor na obrácený směr, tam se chybuje nejčastěji. To, že se ptáte „jaké objednávky má tenhle zákazník?“, přece ještě neznamená, že je musí vlastnit. Kolekce Order pověšená na zákazníkovi vám při každé změně e-mailu natáhne celou historii objednávek, i když s ní ta operace nemá vůbec nic společného. Na tuhle otázku odpovídá OrderRepository::findByCustomerId($customerId), ne vazba na agregátu.

Jak na konzistenci mezi agregáty?

K hranicím patří ještě jedno Vernonovo pravidlo, které se pamatuje samo: jedna transakce mění jeden agregát. Když potřebujete v jedné operaci sáhnout na dva, buď máte špatně nakreslené hranice, nebo má ta druhá změna proběhnout asynchronně.

Agregáty totiž nejsou izolované, potřebují spolu komunikovat. Konzistenci mezi nimi držím dvěma způsoby a k tomu mám jeden obchvat pro čtení.

Strong consistency uvnitř agregátu je výchozí stav. Všechno uvnitř agregátu se mění v jedné transakci – buď celé, nebo vůbec. To je vlastně celý smysl agregátu. Pokud Order::addLine() ověří limit, přidá řádek a přepočítá totalAmount, proběhne to všechno naráz, nebo nic z toho.

Eventual consistency mezi agregáty přichází na řadu, když operace zasahuje do více agregátů. Order se nemůže dotknout Inventory přímo – jsou to jiné agregáty, pravděpodobně i jiné bounded contexty. Místo toho Order po potvrzení objednávky vyemituje domain event OrderConfirmed a Inventory na něj asynchronně zareaguje odečtením zásob. Nejsou konzistentní okamžitě, ale nakonec budou. Pro spoustu doménových situací je to přijatelné a mnohem lépe to škáluje.

Read modely nejsou třetí druh konzistence, ale způsob, jak se té otázce vyhnout. Potřebujete dashboard s celkovou hodnotou objednávek za měsíc? Tak rozhodně nechcete načítat tisíce Order agregátů a sčítat je v PHP. Read model (denormalizovaná tabulka nebo materialized view) existuje čistě pro čtení a se zápisovou stranou se synchronizuje asynchronně. CQRS tenhle vzor formalizuje: příkazy jdou přes agregáty a domain events, dotazy přes read modely.

Kdy pravidla neplatí?

Dvě situace, ve kterých ta tři pravidla narazí. Je fér je přiznat, protože v praxi je potkáte dřív nebo později.

První je invariant, který se do jednoho agregátu schovat nedá. Klasika je „e-mail musí být unikátní přes všechny zákazníky“. Žádný Customer nevidí ostatní zákazníky, takže tohle pravidlo nemá kde bydlet. Řešení není nafouknout agregát na celou tabulku, ale vytáhnout kontrolu ven. Unikátní index v databázi, který to ohlídá tvrdě, případně doménová služba, která se před vytvořením zeptá repozitáře. A ne, není to obcházení DDD – databáze je tady legitimní součást modelu.

Druhá situace nastane, když eventual consistency doménově neprojde. Když vám byznys řekne, že zásoba nesmí jít do minusu ani na vteřinu, nespojíte Order a Inventory jenom událostí. Tady je poctivější udělat agregát větší, než byste chtěli, a zaplatit to zamykáním. Nebo změnit doménu tak, aby přechodný nesoulad snesla – typicky rezervací, která se teprve potvrzuje. Pravidlo „držte agregáty malé“ prohrává s pravidlem „data musí sedět“.

Jak to vypadá v Symfony a Doctrine?

V praxi vypadá Order aggregate root v Doctrine takto:

#[ORM\Entity]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    // Reference přes ID – ne přes Customer objekt
    #[ORM\Column]
    private int $customerId;

    #[ORM\OneToMany(
        mappedBy: 'order',
        targetEntity: OrderLine::class,
        cascade: ['persist'],
        orphanRemoval: true
    )]
    private Collection $lines;

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $totalAmount = '0.00';

    #[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
    private string $approvedLimit;

    #[ORM\Column(length: 20)]
    private string $status = 'draft';

    // Optimistické zamykání – Doctrine ohlídá souběžné zápisy
    #[ORM\Version, ORM\Column]
    private int $version = 1;

    public function __construct(int $customerId, string $approvedLimit)
    {
        $this->customerId = $customerId;
        $this->approvedLimit = $approvedLimit;
        $this->lines = new ArrayCollection();
    }

    public function addLine(int $productId, string $unitPrice, int $qty): void
    {
        // Konstruktor nezná objednávku, takže se řádek nikam nepřipojí
        $line = new OrderLine($productId, $unitPrice, $qty);
        $prospective = bcadd($this->calculateTotal(), $line->subtotal(), 2);

        // Invariant se ověřuje dřív, než se agregát změní
        if (bccomp($prospective, $this->approvedLimit, 2) === 1) {
            throw new DomainException('Objednávka by přesáhla schválený limit.');
        }

        $line->attachTo($this);
        $this->lines[] = $line;
        $this->totalAmount = $this->calculateTotal();
    }

    // Bere ID, ne objekt OrderLine – zvenčí se na vnitřní entity nesahá
    public function removeLine(int $lineId): void
    {
        foreach ($this->lines as $line) {
            if ($line->id() === $lineId) {
                $this->lines->removeElement($line);
                $this->totalAmount = $this->calculateTotal();

                return;
            }
        }

        throw new DomainException('Takový řádek na objednávce není.');
    }

    // Jen počítá, nic nepřiřazuje – totalAmount nesmí být vstupem sám sobě
    private function calculateTotal(): string
    {
        $total = '0.00';

        foreach ($this->lines as $line) {
            $total = bcadd($total, $line->subtotal(), 2);
        }

        return $total;
    }
}

Klíčový detail: jediná cesta k novému řádku vede přes Order::addLine(). Konstruktor OrderLine a jeho attachTo() jsou proto oba @internal, protože kdo je zavolá zvenčí, obejde root. PHP tohle bohužel nevynutí, package-private tady nemáme, takže je to dohoda a ne zámek. Ale kdo si řádek vyrobí a připojí bokem, nespustí přepočet a totalAmount prostě přestane sedět.

A to je celý smysl aggregate rootu: je jediným vstupním bodem pro změny a všechna logika, která chrání invarianty, žije uvnitř.

Co vám ale Doctrine neohlídá, je pravidlo „jedna transakce mění jeden agregát“. flush() je totiž globální. Zapíše všechno, co má Unit of Work zrovna pod palcem, ať to patří k jednomu agregátu nebo k pěti. Když si v jednom handleru načtete objednávku i zákazníka a oba změníte, Doctrine to bez mrknutí uloží společně a nic vám neřekne. Hranici tady drží jenom disciplína: jeden handler, jeden agregát, jeden flush(). A na $em->flush($order) se nespoléhejte, ten jednoentitní argument je od Doctrine ORM 2.7 deprecated a ve verzi 3 už neexistuje.

Pár poznámek k ukázce. Tabulku jsem pojmenoval orders, protože order je v SQL rezervované slovo a Doctrine by na tom při generování schématu prostě spadla. ID nechávám jako prostá čísla, ať je příklad krátký – v reálném projektu za ně dávám value objecty (CustomerId, ProductId) s vlastním Doctrine typem. A orphanRemoval: true tam není pro parádu. Bez něj by removeLine() řádek jen odpojil od kolekce a ten by v databázi zůstal viset osiřelý. A removeLine() schválně bere ID, ne objekt OrderLine. Kdyby bral objekt, musel by si ho volající nejdřív někde sehnat – a tím by obešel root, což je přesně to, čemu se bráníme.

Tři věci v ukázce vypadají jako detail a nejsou. Limit se ověřuje dřív, než se řádek připojí. Kdyby to bylo obráceně, stačilo by, aby volající tu výjimku spolkl, a objednávka by v paměti zůstala s řádkem, co tam nepatří. Konstruktor OrderLine zase nezná objednávku, takže se řádek nemůže do kolekce vloudit sám – rodiče mu nastaví až attachTo() po kontrole. A calculateTotal() jen počítá a nic nepřiřazuje, protože kdybych limit ověřoval proti uloženému totalAmount, opíral bych se o hodnotu, kterou mám zrovna hlídat.

Je to pořád dokola ta samá zásada. Agregát se nesmí spoléhat na nic mimo sebe – ani na transakci pod sebou, ani na to, co dělá cizí konstruktor.

A #[ORM\Version] je ta druhá půlka invariantu z prvního pravidla. Samotná kontrola limitu v addLine() souběh neuhlídá, tou projdou obě transakce. Teprve optimistické zamykání zařídí, že ta druhá dostane OptimisticLockException a nepřepíše první potichu.

Zbývá dotáhnout ty domain events, o kterých byla řeč u konzistence. Ve stejné třídě k tomu přibude jedno neukládané pole a dvě metody:

    // Do databáze se nemapuje, žije jen po dobu requestu
    private array $recordedEvents = [];

    public function confirm(): void
    {
        $this->status = 'confirmed';

        $this->recordedEvents[] = new OrderConfirmed($this->id);
    }

    /** @return object[] */
    public function releaseEvents(): array
    {
        $events = $this->recordedEvents;
        $this->recordedEvents = [];

        return $events;
    }

No, a je tady schovaná jedna past. OrderConfirmed nese $this->id, jenže ten je u neuložené objednávky pořád null. U confirm() to nevadí, potvrzuje se až uložená objednávka. Ale jakmile budete chtít vyemitovat událost už z konstruktoru, dostanete ji s prázdným ID.

Proto v reálných projektech generuju identifikátory v aplikaci. UUID, ze kterého se rovnou stane ten OrderId value object z předchozí poznámky – agregát pak zná svoje ID od první vteřiny a nečeká na databázi. A platí to i pro vnitřní entity. Dokud OrderLine dostane ID až od databáze, nemáte řádek přidaný ve stejné transakci jak adresovat a removeLine() na něj nedosáhne.

Agregát tím zůstává čistý. Nezná Messenger, nezná sběrnici, jenom si pamatuje, co se stalo. Handler po flush() projde releaseEvents() a předá je do MessageBusInterface. A bacha na jednu věc: mezi flush() a odesláním události je díra. Když v ní proces spadne, objednávka je potvrzená a sklad se to nedozví. Kdo tohle nesmí připustit, potřebuje outbox pattern, ale to už je téma na samostatný článek.


Ten projekt z úvodu jsme nakonec přepsali. OrderAggregate jsme rozpadli podle invariantů na menší agregáty, endpoint na detail objednávky přestal tahat půl databáze a bylo to v pohodě.

Pomalé načítání, deadlocky, zamotaná logika – většina problémů, které jsem v DDD kódu viděl, měla stejné jádro: hranici nakreslenou podle databázového schématu místo podle doménových invariantů. Takže začněte u nich, zbytek se z toho odvodí.

Zdroje

  • Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003)
  • Vaughn Vernon – Effective Aggregate Design – série tří esejí, ze kterých vycházejí pravidla v tomhle článku
  • Průvodce DDD v Symfony – můj delší materiál o tom, jak agregáty zapadají do celku
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.