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, dobré 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á vyslověně chyba.
Co to vlastně je agregát?
Eric Evans v knize Domain-Driven Design definuje agregát jako skupinu doménových objektů, které jsou konzistentní jako celek v rámci jedné transakce. Klíčové slovo je „konzistentní“ a ne „spolu související“ ani „logicky propojené“.
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. ORM řeší načítání dat, agregát řeší konzistenci domény – a to jsou dvě různé věci.
Druhé nedorozumění: „agregát“ neznamená „velký“. Agregát může být klidně jedna entita bez jediného value objectu uvnitř. Kritérium není velikost, ale konzistence.
Jak tedy hranice poznat? Držím se tří pravidel. Nejsou vysloveně moje, ale sepsal je Vaughn 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í „objednávka nesmí mít víc než 50 položek“. Tohle pravidlo se nedá vyhodnotit nad jedním řádkem – platí pro objednávku a všechny její řádky dohromady. Proto jsou Order a OrderLine v jednom agregátu a mění se společně v jedné transakci.
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.
Pro každou entitu uvnitř velkého agregátu si položte otázku: „existuje invariant, který ji váže k aggregate rootu?“ Pokud odpověď není hned jasná, entita tam nejspíš nepatří.
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. Konkurenci – 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.
Tohle pravidlo má přímý dopad na to, co se načte. Pokud Order drží objekt Customer a Doctrine má nastavený eager loading, s objednávkou se automaticky načte i zákazník. Pokud zákazník drží kolekci adres, načtou se taky. Najednou jste chtěli přečíst status objednávky a načetli jste půl databáze.
Reference přes ID tenhle problém fyzicky znemožňuje – přes ID Doctrine nic dalšího nenačte. Chcete zákazníka? Dotáhněte si ho explicitně přes jeho repozitář.
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. Podle toho, jak silnou konzistenci potřebujete, máte k dispozici tři přístupy.
| Přístup | Kdy | Jak |
|---|---|---|
| Strong consistency | uvnitř jednoho agregátu | databázová transakce, synchronní |
| Eventual consistency | mezi agregáty | domain events, asynchronní handler |
| Read consistency | pro reporting a read views | CQRS read model, materialized view |
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() přidá řádek a zároveň přepočítá totalAmount, proběhnou obě operace atomicky, nebo žádná.
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 consistency přes read modely řeší třetí situaci: reporty a agregace. Pokud potřebujete dashboard s celkovou hodnotou objednávek za měsíc, 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.
Jak to vypadá v Symfony a Doctrine?
V praxi vypadá Order aggregate root v Doctrine takto:
#[ORM\Entity]
class Order
{
#[ORM\Id, ORM\GeneratedValue, ORM\Column]
private int $id;
// Reference přes ID – ne přes Customer objekt
#[ORM\Column]
private int $customerId;
#[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]
private Collection $lines;
#[ORM\Column(type: 'decimal', precision: 10, scale: 2)]
private string $totalAmount = '0.00';
// Optimistické zamykání – Doctrine ohlídá souběžné zápisy
#[ORM\Version, ORM\Column]
private int $version;
public function addLine(int $productId, string $unitPrice, int $qty): void
{
// Invariant: objednávka nesmí mít víc než 50 položek
if ($this->lines->count() >= 50) {
throw new DomainException('Objednávka nemůže mít více než 50 položek.');
}
$this->lines[] = new OrderLine($this, $productId, $unitPrice, $qty);
// Přepočet celkové částky – invariant musí platit po každé změně
$this->recalculateTotal();
}
private function recalculateTotal(): void
{
$total = '0.00';
foreach ($this->lines as $line) {
$total = bcadd($total, $line->subtotal(), 2);
}
$this->totalAmount = $total;
}
}
Klíčový detail: recalculateTotal() je privátní a volá se uvnitř addLine(). To není náhoda. Kdyby mohl volající kód vytvořit OrderLine napřímo a obejít Order::addLine(), invariant se rozpadne – kdo přidá řádek mimo agregát, nikdy 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ř.
Dvě poznámky k ukázce. 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 atribut #[ORM\Version] je jednořádkové pojištění proti souběhu: když dvě transakce změní stejný agregát, druhá dostane OptimisticLockException, místo aby potichu přepsala první.
Nejčastější chyba? User s kolekcí Orders
Na závěr scénář, který vidím pravidelně, ve dvou variantách.
Špatně: User agregát drží kolekci objektů Order. Vypadá to přirozeně, protože uživatel přece má objednávky. Jenže v praxi pak každé uložení uživatele (změna e-mailu, hesla, preference notifikací) načte celou kolekci objednávek. Uživatel s tisíci objednávkami znamená tisíce načtených řádků při každé triviální operaci.
Dobře: User a Order jsou oddělené agregáty a Order drží ID uživatele jako prostou hodnotu. Dotaz „jaké objednávky má uživatel?“ neznamená, že je User musí vlastnit – stačí OrderRepository::findByUserId($userId).
Sdílejí User a Order nějaký invariant, který by musel platit pro oba zároveň? Nesdílejí. Proto patří do oddělených agregátů.
Agregáty nejsou složitá teorie – je to pár konkrétních pravidel pro to, kde nakreslit hranici mezi „tohle patří k sobě“ a „tohle jsou dvě různé věci“. Většina problémů, které jsem v DDD kódu viděl: tedy pomalé načítání, deadlocky a zamotaná logika měla stejný jádro problému: hranici agregátu nakreslenou podle databázového schématu místo podle doménových invariantů.
Takže začněte u invariantů, 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