Kapitola 21 · Praxe · Anti-vzory a typické chyby v DDD

Anti-vzory a typické chyby v DDD

Přehled nejčastějších anti-vzorů a typických chyb při implementaci Domain-Driven Design: anémický doménový model, Primitive Obsession, příliš velký agregát, sdílená databáze napříč Bounded Contexts, mutovatelné události a over-engineering.

Autor M. Katuščák
Doba čtení ≈ 38 min
Náročnost mírně pokročilá
Publikováno · Aktualizováno ·
Obsah kapitoly

21.01 Úvodem: Proč znát anti-vzory#

Tato kapitola je katalog kódových a modelovacích anti-vzorů v DDD. Pro provozní/infrastrukturní třenice (Doctrine, Messenger, ACL k externím API, Symfony Form vs. Command) viz DDD v praxi – kde to bolí. Pro rozhodovací rámec, jestli DDD vůbec použít, viz Kdy DDD nepoužívat.

DDD nabízí strukturu pro modelování domény, ale s tou strukturou přicházejí specifická úskalí. Týmy začínající s DDD opakovaně narážejí na stejné chyby, i když teorii rozumějí. Anti-vzory je proto potřeba znát stejně dobře jako vzory samotné. Definice termínů použitých v této kapitole (entita, hodnotový objekt, agregát, bounded context) najdete v kapitole Základní koncepty DDD.

Anti-vzor je přístup, ke kterému vývojáři přirozeně sklouznou. Vypadá správně, ale narušuje principy DDD a dlouhodobě podkopává udržovatelnost, testovatelnost i výkon. Každá sekce níže proto nese rozpoznávací znak – větu, podle které zjistíte, jestli se problém týká vašeho kódu – a hranici, za kterou už kritizovaný postup chybou není.

Nejznámější anti-vzor DDD zde nenajdete. Big Ball of Mud, tedy oblast bez rozeznatelných hranic, patří ke Context Mappingu, protože se dá vědomě ohraničit a nechat být; rozebírá jej sekce 03.12.

21.02 Anti-vzor: Anémický doménový model (Anemic Domain Model)#

Anémický model patří k nejčastějším anti-vzorům objektově orientovaného vývoje a v DDD zvlášť bolí. Termín popularizoval Martin Fowler v článku z roku 2003 [1]. Doménové třídy (entity, agregáty) v něm slouží pouze jako datové kontejnery. Obsahují výhradně gettery a settery a veškerá doménová logika je přesunuta do servisní vrstvy.

Fowler považoval argument „porušuje se zapouzdření“ za příliš slabý a připojil druhý, nákladový: anémický model nese veškeré náklady doménového modelu, aniž by přinášel jeho užitek. Zaplatíte mapování na databázi, obalování hodnot a rozpad kódu do vrstev, a dostanete datovou strukturu, kterou by obsloužil obyčejný SELECT. Účet bez protihodnoty je jádro problému, ne nedodržená poučka o OOP.

Rozpoznávací znak. Vaughn Vernon k tomu v Implementing Domain-Driven Design (2013) nabízí diagnostický test dvou otázek. Volně přeloženo: má vaše entita jen gettery a settery, a žije pravidlo, které s jejími daty pracuje, v cizí třídě? Dvě „ano“ znamenají anémii. Test je použitelnější než definice, protože ho pustíte na konkrétní soubor.

FIG. 21.2-A Anémický vs. bohatý doménový model – kde sedí logika

Anti-vzorem není servisní vrstva jako taková. Fowler v témže článku Service Layer výslovně obhajuje a odmítá jen to, aby v ní žila veškerá doménová logika. Rozlišit je proto potřeba tři věci, které se v projektech jmenují stejně:

  1. Aplikační služba orkestruje: načte agregát, zavolá na něm jednu metodu, uloží výsledek, odešle události. Vlastní doménové pravidlo neobsahuje a je legitimní.
  2. Doménová služba nese pravidlo, které nepatří jedinému agregátu – výpočet přes několik agregátů nebo politiku s externím vstupem. Je to řádný stavební blok, viz doménové služby.
  3. „God service“ drží pravidla patřící entitám, které samy nemají žádné chování. Teprve to je anémický model.

Rozdíl je v tom, že správná entita vystavuje doménově orientované metody (activate(), deactivate(), register()) místo generických setterů. Entita sama garantuje své invarianty – nikdo zvenčí ji nedostane do nekonzistentního stavu.

Kdy anémický model chyba není

Fowler sám připouští, že doménový model není vždy nejlepší nástroj, a odkazuje na Transaction Script [2] – řádný vzor z Patterns of Enterprise Application Architecture, který organizuje logiku po procedurách, jednu na požadavek. V doméně s pěti pravidly bývá procedura čitelnější než šest tříd okolo ní. Volba mezi vzory patří k rozhodnutí o typu subdomény, viz Kdy DDD nepoužívat.

Druhou výhradu přináší funkcionální škola. Mark Seemann ukazuje, že zapouzdření není totéž co metoda na objektu: stejnou garanci dá typ, který nelze zkonstruovat do neplatného stavu, plus modul funkcí nad ním [3]. Data od chování oddělit lze. Co oddělit nelze, je validace od dat – záznam s veřejnými poli, který kdokoli naplní čímkoli, je anémický v tom škodlivém smyslu, i kdyby funkce nad ním byly sebelépe napsané.

Hranice pravidla. Anémický model je chyba tehdy, když platíte cenu doménového modelu bez jeho přínosu. Rozhodli jste se pro doménový model? Pak v něm mají být pravidla. Rozhodli jste se pro Transaction Script? Pak žádnou anémii neřešíte, jen to rozhodnutí musíte umět pojmenovat a nevydávat adresář Domain/ za doménový model.

21.03 Anti-vzor: Primitive Obsession (posedlost primitivy)#

Primitive Obsession nastává, když vývojáři používají primitivní datové typy (string, int, float) tam, kam patří hodnotové objekty (Value Objects). Primitiva působí na první pohled přímočaře, ale vedou k závažným problémům.

Rozpoznávací znak. Najděte si validaci e-mailu ve svém projektu a spočítejte, na kolika místech stojí. Tři výskyty téhož filter_var() nad toutéž hodnotou znamenají, že hodnota chce vlastní typ.

Money odmítá zápornou částku záměrně: pro dobropisy a odpisy má kniha samostatný typ SignedMoney, aby se nemohla ztratit informace o směru pohybu. Plnou definici obou hodnotových objektů uvádí sekce 06.04; ukázka výše je zkrácená na to, co odlišuje hodnotový objekt od float a string.

Hodnotový objekt má i svou cenu. Vyplatí se tam, kde hodnota splní alespoň jednu ze tří podmínek: nese vlastní pravidla platnosti (Email, BirthNumber), má vlastní operace (Money::add()), nebo hrozí její záměna s jinou hodnotou téhož primitivního typu (OrderId proti UserId). Evans totéž říká obráceně – jako hodnotový objekt klasifikujte prvek modelu, u kterého vás zajímají pouze jeho atributy a logika [6].

Hranice pravidla. Pole note, description nebo internalComment žádnou z podmínek nesplňuje. Třída Note obalující string bez jediného pravidla je přesně ta ceremonie, před kterou varuje sekce 21.08. Kritériem tedy není počet primitiv v kódu, ale duplikovaná validace a riziko záměny.

21.04 Anti-vzor: Příliš velký agregát (God Aggregate)#

Agregát navrhujeme kolem transakční konzistence – tedy kolem nejmenší skupiny objektů, kterou je třeba měnit společně v jedné transakci. Příliš velký agregát (tzv. „God Aggregate“) sdružuje pod jeden kořen entity a logiku, které k sobě transakčně nepatří. Tím porušuje princip jedné odpovědnosti a způsobuje problémy popsané níže. Vernon pro tentýž jev používá střízlivější název large-cluster aggregate; komunita se drží dramatičtějšího „God“.

Rozpoznávací znak. Podívejte se na poslední přidání položky do kolekce uvnitř agregátu. Pokud kvůli jednomu novému řádku načítáte tisíc existujících, je hranice agregátu vedená podle asociací, ne podle invariantů.

FIG. 21.4-A God Aggregate vs. správně rozdělené agregáty propojené přes ID

Pravidlo pochází z Vernonovy série Effective Aggregate Design [7]: agregát drží kořen a nezbytné minimum atributů a hodnotových vlastností, nic víc. Vernon k němu dodává větu, kterou katalogy anti-vzorů obvykle vynechávají – agregáty jsou hranice konzistence, ne výsledek snahy navrhnout graf objektů. Pokud změna jednoho objektu nevyžaduje konzistentní změnu druhého ve stejné transakci, patří do různých agregátů.

Hranice pravidla. Zmenšovat lze i příliš. Vernon pojmenovává obě selhání: agregát složený pro pohodlí kompozice je moc velký, agregát rozebraný na jednotlivé entity zase přestane chránit skutečné invarianty. Druhá chyba se hledá hůř, protože se neprojeví na výkonu, ale až nekonzistentními daty.

V Doctrine bývá nejčastější příčinou velkého agregátu samotné mapování. Asociace OneToMany popisuje vztah v databázi, ne transakční hranici: z toho, že objednávka položky, neplyne, že zákazník má vlastnit svou historii objednávek. Vodítkem je invariant, který musí platit po každém commitu, nikoli tvar schématu.

21.05 Anti-vzor: Sdílená databáze napříč Bounded Contexts#

Sdílená databáze napříč Bounded Contexts patří mezi nejzávažnější strategické anti-vzory. Nastává, když různé kontexty sdílejí stejné databázové tabulky nebo přistupují přímo k datům jiného kontextu. Na počátku to vypadá pragmaticky, ale vede k těsnému provázání, které blokuje nezávislý vývoj a nasazení jednotlivých kontextů.

Rozpoznávací znak. Projděte migrace jednoho kontextu a hledejte tabulku, kterou vlastní jiný tým. Druhý příznak je provozní: nasazení kontextu A vyžaduje koordinaci s týmem kontextu B, přestože se jejich kód nikde nepotkává.

Hranice pravidla. Chybou není jedna databázová instance, ale sdílené schéma a dotaz vedený přes hranici. Modulární monolit běžně běží nad jednou databází s oddělenými schématy a vlastnictvím tabulek na úrovni modulu, a to je v pořádku. Legitimní zůstávají i další případy: Shared Kernel s explicitně dohodnutým vlastníkem, read-only replika pro reporting a analytický kontext, který čte data mimo doménový model. Anti-vzor začíná ve chvíli, kdy jeden kontext čte zápisový model druhého a spoléhá se na jeho tvar.

Synchronní HTTP adaptér z ukázky výše není jediná možnost a pro modulární monolit bývá tou nejdražší. V úvahu připadají tři cesty a každá má svou cenu.

  1. Volání přes rozhraní v procesu. Kontext B vystaví port, kontext A ho volá přímo, bez sítě. Hranice zůstane zachovaná, latence žádná nepřibude. Cenou je společné nasazení a disciplína, aby se z portu nestal průchod do cizího modelu.
  2. Synchronní HTTP nebo gRPC. Nutnost, jakmile kontexty běží odděleně. Zaplatíte latencí, nedostupností upstreamu ve chvíli vlastního provozu a nutností řešit timeouty i náhradní chování.
  3. Asynchronní replikace přes události. Billing naslouchá události CustomerBillingDataUpdated a drží si lokální kopii potřebných dat (read model projection). Synchronní závislost mizí a čtení má ze všech tří variant nejnižší latenci. Cenou je eventuální konzistence a kód pro doplnění dat konzumentovi, který se připojí později. Spolehlivé publikování řeší Outbox Pattern.

21.06 Anti-vzor: Mutovatelné doménové události#

Doménová událost popisuje fakt, který se v minulosti stal. Evans ji proto označuje za zpravidla neměnnou (immutable), protože jde o záznam něčeho minulého [6]. Ono „zpravidla“ je na místě: doplnit metadata při publikování zprávy je běžné, změnit částku v OrderPlaced je konceptuální rozpor. Událost, kterou lze po vytvoření přepsat, ztrácí hodnotu historického záznamu.

Mutovatelné události navíc způsobují praktické problémy při event sourcingu, auditních logách a při komunikaci mezi Bounded Contexts. Přijímající kontext totiž předpokládá, že obdrží konzistentní a neměnná data.

Rozpoznávací znak. Otevřete třídu události a hledejte setter nebo \DateTime bez Immutable. Obojí znamená, že minulost lze v tomto systému přepsat.

Praxe pracuje se dvěma časovými razítky. occurredAt říká, kdy se věc stala v doméně; recordedAt, kdy ji systém zapsal. U události vzniklé z uživatelské akce obě hodnoty splývají, u importu historických dat nebo u zpětného storna se rozejdou třeba o týdny. Jedno razítko generované v konstruktoru na takový případ nestačí.

Neměnnost instance přitom neřeší verzování schématu. Jakmile událost přežije nasazení, které jí přidá pole, potřebujete upcasting nebo verzovaný název typu; obojí rozebírá kapitola Event Sourcing.

Příbuzný anti-vzor: událost jako aplikační hook. Názvy CacheShouldBeInvalidated nebo EmailNeedsToBeSent nepopisují fakt, ale příkaz převlečený do minulého času. Verraes třídí zprávy na příkazy, dotazy a informace [8] a záměna kategorií je jádrem problému. Doménová událost říká, co se v doméně stalo, a nezajímá se, kdo na ni zareaguje. Jakmile její jméno obsahuje instrukci pro infrastrukturu, jde o příkaz, ne o událost.

21.07 Anti-vzor: Doménová logika v infrastrukturní vrstvě#

DDD odděluje doménovou vrstvu od infrastrukturní. Infrastrukturní vrstva (Doctrine repozitáře, Symfony Forms, kontrolery, event listenery) má být tenká a delegovat veškerou doménovou logiku do doménové vrstvy. Doménová pravidla v infrastrukturních třídách narušují hranice vrstev a vytvářejí skrytou, těžko testovatelnou logiku.

Rozpoznávací znak. Otevřete libovolnou třídu v adresáři Infrastructure/ a hledejte podmínku, která se ptá na doménový stav. Řádek if ($user->getStatus() !== 'pending') v repozitáři je pravidlo, ne persistence.

Handler odesílá doménové události rovnou na MessageBusInterface, a právě zde vede hranice, kterou lze přehlédnout. Doménová událost je vnitřní věc kontextu, integrační událost je veřejný kontrakt vůči okolí. Jakmile obojí sdílí jednu sběrnici, kdokoli si na doménovou událost pověsí handler a její tvar se tím stane veřejným API, které už nelze měnit. Oddělení obou vrstev i spolehlivé publikování ven rozebírá kapitola Outbox Pattern.

Hranice vrstev se navíc dají vynutit nástrojem, ne jen dohodou v code review. Nástroje deptrac [9] a PHPArkitect [10] čtou statickou strukturu kódu a v CI zastaví build, který ji poruší. Užitečné minimum je jediné pravidlo: App\*\Domain nesmí odkazovat na Doctrine\* ani Symfony\*. Jeden řádek konfigurace nahradí opakovanou diskusi u každého pull requestu. Pozor jen na název balíčku, původní qossmic/deptrac je opuštěný ve prospěch deptrac/deptrac.

21.08 Anti-vzor: Over-engineering u jednoduchých aplikací#

Anti-vzorem zde není samotné DDD, ale jeho ceremonie bez komplexní domény. Agregáty, Value Objects a doménové události obalují prosté řádky v databázi, pro které stačí formulář a tabulka. Typické příznaky: tým tráví více času architekturou než obchodní hodnotou a triviální změna prochází desítkami souborů napříč vrstvami.

Méně nákladná cesta začíná minimálním přístupem a přidává DDD prvky, až když se doménová složitost skutečně projeví. Celý rozhodovací rámec – sedm situací, kdy DDD vynechat, alternativy a rozhodovací strom – rozebírá kapitola Kdy DDD nepoužívat.

Hranice pravidla. Ceremonie sama o sobě chyba není. V jádrové subdoméně je to investice, která se vrátí při každé změně pravidel. Chybou je stejná ceremonie v podpůrné subdoméně, kde se za rok nezmění nic než sazba DPH.

21.09 Anti-vzor: Ignorování Ubiquitous Language#

Když selže Ubiquitous Language, tatáž doménová entita nese různé názvy na různých místech. Společný jazyk vývojářů a doménových expertů přestane platit a vývojář víc překládá mezi vrstvami, než modeluje doménu. Výsledkem jsou nedorozumění, chyby a ztráta doménového vhledu v kódu.

Rozpoznávací znak. Nechte doménového experta popsat jeden běžný případ a poznamenejte si každé slovo, které v kódu nenajdete nebo které tam znamená něco jiného. Délka seznamu je mírou driftu.

21.10 Shrnutí: anti-vzor, znak, alternativa#

Anti-vzor Podle čeho ho poznáte Realistická alternativa Víc v knize
Anémický model Entita má jen gettery a settery, pravidlo nad nimi žije v cizí třídě Pravidla do entity, nebo přiznaný Transaction Script v jednoduché subdoméně 22.09
Primitive Obsession Tatáž validace téže hodnoty na třech místech Hodnotový objekt tam, kde má hodnota pravidla, operace nebo hrozí záměna 06.04
Příliš velký agregát Kolekci načítáte jen kvůli přidání jedné položky Rozdělit podle invariantů, reference přes ID 07.04
Sdílená databáze Migrace jednoho kontextu sahá na tabulku jiného týmu Port a adaptér, replikace přes události, oddělená schémata v monolitu 03
Mutovatelná událost Událost má setter nebo mutovatelný DateTime readonly vlastnosti, occurredAt i recordedAt 15
Logika v infrastruktuře Podmínka nad doménovým stavem v adresáři Infrastructure/ Pravidlo do entity, orchestrace do handleru, hranice do CI 20.01
Over-engineering Triviální změna prochází desítkami souborů Míru DDD volit podle typu subdomény 22
Drift jazyka Expert použije slovo, které v kódu není Glosář v repozitáři, revize jmen u každé nové funkce 20.03

Tabulka má jedno společné čtení. Žádný z uvedených anti-vzorů nevzniká z neznalosti vzorů, ale z pohodlí: každý je krok, který v daném týdnu ušetří práci a účet za něj přijde o rok později. Proto je užitečnější znát rozpoznávací znak než definici.

Anémickému doménovému modelu se obšírně věnuje Vaughn Vernon v Implementing Domain-Driven Design (2013), odkud pochází i test dvou otázek z úvodu sekce 21.02. Další tituly uvádějí doporučené zdroje.

Časté otázky

Co je anémický doménový model a jak ho poznat?

Anémický model vypadá na první pohled jako DDD – obsahuje třídy s názvy agregátů, entit a hodnotových objektů. Veškerá logika je ale přesunutá do služeb. Typickým znakem jsou gettery a settery jako jediné metody a třídy bez jakéhokoli pravidla uvnitř. Doménová logika končí ve „Service“ třídách, které manipulují s daty zvenku. Výsledkem je procedurální kód balený do objektových fasád. Detailní rozbor v sekci Anémický doménový model.

Je anémický model vždy chyba?

Ne. Fowler sám v článku o anémickém modelu píše, že doménový model není vždy nejlepší nástroj, a odkazuje na Transaction Script. V doméně s několika málo pravidly je procedura na jeden případ užití čitelnější než vrstva tříd okolo ní. Anémický model je chyba tehdy, když platíte cenu doménového modelu – mapování, obalování hodnot, rozpad do vrstev – a nedostáváte za ni žádný přínos. Rozbor obou stran sporu v sekci Kdy anémický model chyba není.

Proč je Primitive Obsession problém?

Primitive Obsession znamená používání primitivních typů (string, int, float) tam, kde patří doménový pojem. Místo typu Email se předává string, místo Money dvojice float. Důsledkem je, že validace a pravidla se opakují v každém místě volání, nebo se zapomínají. Hodnotový objekt s jedním místem validace tyto duplicity odstraňuje a typ dává kontext, co daná hodnota reprezentuje. Rozbor a příklady v sekci Primitive Obsession.

Jak poznat, že je agregát příliš velký?

Typické příznaky God Aggregate jsou tři. Agregát obsahuje desítky vnitřních entit. Jeho načtení zabere stovky SQL dotazů. Nebo souběžné operace nad různými částmi narážejí na optimistické zamykání. Pokud dvě metody agregátu řeší vzájemně nezávislá pravidla a nesdílejí invariant, pravděpodobně jde o dva samostatné agregáty. Hranice agregátu má kopírovat hranice transakční konzistence – nic víc. Praktický příklad refaktoringu v sekci Příliš velký agregát.

Proč je sdílená databáze mezi Bounded Contexts problém?

Sdílená databáze formálně drží data pohromadě, ale fakticky ruší hranice mezi Bounded Contexts. Změna schématu v jednom kontextu může rozbít druhý, pojmy se mísí a model jednoho týmu začíná záviset na modelu druhého. Správné řešení je, aby každý Bounded Context vlastnil svá data a komunikace probíhala přes definované rozhraní (API, události), nikoli přes sdílenou tabulku. Podrobný rozbor v sekci Sdílená databáze napříč Bounded Contexts.

Musí být doménová událost neměnná?

Ano. Doménová událost popisuje něco, co se již stalo – OrderPlaced, PaymentReceived – a minulost nelze měnit. Událost bez setterů, s neměnnými atributy a časovým razítkem vytvořeným při konstrukci je bezpečné sdílet mezi handlery, persistovat v event store a použít pro zpětnou rekonstrukci stavu. Mutovatelná událost vede k race condition, nedeterministickému zpracování a nekonzistentnímu auditu. Viz sekci Mutovatelné doménové události.