Kapitola 10 · Základy · Implementace DDD v Symfony 8

Implementace DDD v Symfony 8

Praktický překlad DDD konceptů do Symfony 8: jak strukturovat projekt podle Bounded Contextů, jak persistovat agregáty přes Doctrine, jak konfigurovat Messenger a kdy sáhnout po Doctrine custom types pro hodnotové objekty.

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

10.01 Kde končí DDD a kde začíná Symfony

Následující diagram ukazuje hranici mezi čistým DDD kódem (zelená oblast) a Symfony infrastrukturou (oranžová oblast). Vše v zelené oblasti je čistý PHP bez závislosti na frameworku – testovatelné v izolaci, přenositelné mezi projekty. Symfony vrstva implementuje kontrakty definované doménou (repository interface, event dispatch) a zajišťuje HTTP, persistenci a messaging.

FIG. 11.1-A Hranice mezi DDD a Symfony

Směr závislostí je určující: Symfony závisí na DDD (implementuje jeho rozhraní), nikdy naopak. Doménová vrstva neimportuje žádný Symfony namespace. Díky tomu lze Doctrine nahradit jiným ORM nebo Messenger jiným bus systémem, aniž by se dotklo doménové logiky.

10.02 Struktura projektu

Vertikální slice architektura v Symfony 8 organizuje strukturu projektu podle Bounded Contexts (ohraničených kontextů). Každý kontext drží svou doménu, infrastrukturu i feature složky pohromadě. Příklad:

Struktura organizuje kód podle ohraničených kontextů (Bounded Contexts) a funkcí (features). Každý ohraničený kontext má vlastní doménovou vrstvu s modely, hodnotovými objekty, událostmi a repozitáři. Závislosti mezi kontexty procházejí přes Application vrstvu nebo události – nikdy přes přímý import doménových tříd cizího kontextu.

FIG. 11.2-A Struktura projektu s Bounded Contexts

10.03 Implementace entit

Vstupní bod do agregátu je kořen agregátu – třída final, dědí z bázové AggregateRoot, konstruktor je private a vznik probíhá přes pojmenovanou factory metodu (User::register(), Order::place()). To zaručuje, že nelze vytvořit agregát v nekonzistentním stavu. Definice entity je v kapitole Základní koncepty; tato sekce řeší její podobu v Symfony.

Detaily implementace:

  • final + extends AggregateRoot. AggregateRoot poskytuje record() a releaseDomainEvents() – sdílené chování pro všechny agregáty, ne duplicitní kopii v každé entitě. final zabraňuje dědění (entita s podtřídou nezachová invarianty kořene).
  • Privátní konstruktor + factory register(). Jediná legální cesta vytvoření. Kdyby přibyla další kategorie (importovaný uživatel z LDAP), přidá se další factory, ne přepínač uvnitř konstruktoru.
  • VO uloženy přímo, ne jako primitivy. UserId, Email, UserName a HashedPassword jsou typy vlastností. Doctrine je hydratuje přes custom typy (user_id, email_vo) nebo #[ORM\Embedded]. Žádné re-validace v getterech.
  • #[ORM\Version] pro optimistický zámek. Souběžné modifikace agregátu vyhází OptimisticLockException, kterou aplikační vrstva přeloží na retry.
  • Názvy metod z Ubiquitous Language. rename() místo setName(), changeEmail() místo updateEmail(). Doménový jazyk, ne CRUD slovník.

10.04 Implementace hodnotových objektů

V Symfony 8 se hodnotový objekt zapisuje jako final readonly PHP třída. Validace patří do konstruktoru, rovnost se počítá z hodnot, ne z identity. Detailní rozbor sémantiky VO je v kapitole Základní koncepty:

UserName ukazuje plnou cenu hodnotového objektu: invariant „jméno není prázdné a má rozumnou délku“ je vynucen typem. Volající kód nemá šanci vložit prázdný string – pokud by to zkusil, dostane výjimku v konstruktoru, ne až v repozitáři. #[ORM\Embeddable] říká Doctrine, že VO se ukládá jako sloupec ve stejné tabulce jako vlastník (žádná samostatná tabulka pro VO).

10.05 Implementace repozitářů

Repozitář se v Symfony 8 dělí na dvojici: rozhraní v doméně + Doctrine implementace v infrastruktuře. Doménový kód se opírá pouze o rozhraní, výměna persistence se odehraje v jediném souboru:

DoctrineUserRepository implementuje doménové rozhraní UserRepository přes Doctrine ORM. save() zapisuje agregát i jeho události uvnitř jedné transakce – stav a publikace události tak nemůžou divergovat. OutboxRecorder je tenká utilita, která serializuje event do tabulky outbox; samostatný worker ji čte a doručuje do Messenger transportu. Podrobnosti v kapitole Outbox Pattern.

10.06 Persisted Object Pattern – čistá DDD varianta

Pokud trváte na tom, že doménová vrstva nesmí obsahovat ani metadata o persistenci, korektní cesta není XML mapping (také „znečištěné“, jen jiným formátem), ale Persisted Object Pattern – varianta vzoru Data Mapper (Fowler, PoEAA, 2002), kterou v DDD kontextu rozebírá Vlad Khorikov v sérii blogpostů „Persistence model“ a Vaughn Vernon v IDDD, kap. 6.

Idea: doménová třída zůstane POPO bez atributů. Vedle ní v infrastrukturní vrstvě existuje samostatná persistence třída se všemi Doctrine atributy. Dva mappery (one-way každým směrem) překládají mezi nimi.

V dalších příkladech v tomto průvodci pokračujeme s atributy přímo na agregátech. Persisted Object Pattern dále nerozvíjíme – principy jsou identické, jen vyžadují explicitní mapper na každý agregát.

10.07 Doctrine custom types pro Value Objects

Alternativou k ukládání hodnotových objektů jako primitivních typů (viz implementace entit) je Doctrine custom type. Ten automaticky konvertuje mezi primitivním databázovým typem a doménovým hodnotovým objektem. Entita pak může mít vlastnosti přímo typu Email nebo UserId.

10.08 PHP 8.1+ Enums pro stavové typy

Od PHP 8.1 jsou k dispozici nativní výčtové typy (enums). V DDD se hodí pro stavové hodnotové objekty s konečnou množinou hodnot – například stav objednávky, stav úkolu nebo roli uživatele. Dříve se tyto stavy modelovaly jako konstanty ve třídách nebo jako plnohodnotné hodnotové objekty. Nativní enums nabízejí typovou bezpečnost přímo na úrovni jazyka.

10.09 Doménové služby (a kdy je nepoužít)

Doménová služba zapouzdřuje pravidlo, které přirozeně nepatří žádnému agregátu ani hodnotovému objektu – typicky operaci nad dvěma a více agregáty (MoneyTransferService mezi dvěma účty) nebo bezstavový výpočet vyžadující externí zdroj (kurzovní převod, kalkulace daně podle jurisdikce).

Než sáhnete po doménové službě, ptejte se nejdřív: nepatří to do agregátu? Pravidlo „lze platit jen confirmed objednávku“ je čistý invariant agregátu Order – jen Order zná svůj stav a jen on smí ten stav měnit. Domain service na to je anti-vzor, který oslabuje agregát a vede k anemickému modelu.

Order::recordPayment() zapouzdřuje pravidlo i přechod stavu uvnitř agregátu. Jediný způsob, jak vytvořit Payment pro danou objednávku, vede přes tuto metodu – což znamená, že invariant „platit lze jen confirmed objednávku“ je vynucen typovým systémem, ne nadějí, že někdo zavolá správnou službu. Aplikační handler pak má triviální koordinační roli:

10.10 Specification Pattern

Specification Pattern (Eric Evans, DDD, kap. 9) zapouzdřuje doménová pravidla a podmínky do samostatných, znovu použitelných objektů. Specifikace odpovídá na otázku „splňuje tento objekt dané kritérium?“ a lze ji použít pro validaci, filtrování i vyhledávání.

10.11 Implementace doménových událostí

Doménová událost je fakt minulého času: registrace proběhla, platba byla zaznamenána. Kód ji v Symfony 8 modeluje jako neměnnou PHP třídu, kterou agregát publikuje při změně stavu:

UserRegistered nese minimum potřebné pro obnovu kontextu: ID uživatele, e-mail a čas registrace. Listenery i externí konzumenti z těchto tří hodnot poskládají reakci, aniž by sahali zpět do UserRepository.

10.12 Strategie zpracování chyb v DDD

V DDD se výjimky liší podle vrstvy, ve které vznikají. Každá vrstva má jiné odpovědnosti a jiný typ chyb:

10.13 Implementace aplikačních služeb

Aplikační služba má v Symfony 8 podobu command nebo query handleru. Načte agregáty přes repozitář, zavolá doménovou metodu a zapíše výsledek – žádná doménová pravidla v ní nežijí:

RegisterUserHandler a GetUserProfileHandler jsou aplikační služby (command a query handlery). Koordinují use case a delegují doménovou logiku na entitu nebo doménovou službu.

10.14 Implementace kontrolerů

Kontroler je adapter mezi HTTP a aplikační vrstvou. Smí: validovat formát vstupu, transformovat ho na command/query, dispatchovat, přeložit doménovou výjimku na HTTP odpověď. Nesmí: nést doménová pravidla, volat repozitáře přímo, manipulovat s agregáty.

Symfony 7+ nabízí #[MapRequestPayload], který deserializuje a validuje JSON požadavek přímo do typového commandu. Pro klasické HTML formuláře pak existuje varianta #[MapRequestPayload(acceptFormat: 'form')] nebo Symfony Form.

MapRequestPayload převezme deserializaci, validaci přes Symfony Validator (atributy #[Assert\…] na commandu) i překlad chyby validace na HTTP 422. Kontroler tak má jen tři odpovědnosti: dispatch, mapování doménových výjimek na HTTP, návrat odpovědi.

Použití skutečného DB transportu (SQLite v testech, PostgreSQL v CI) garantuje, že unique constraint, transakční chování a outbox skutečně fungují. In-memory mock repozitáře tyto vlastnosti negarantuje.

10.15 Dependency Injection a autowiring

DI Container v Symfony 8 váže rozhraní z doménové vrstvy na konkrétní implementaci v infrastruktuře. Konfigurace určuje, kterou třídu autowiring injektuje, když handler typuje na UserRepository:

Alias zajistí, že Symfony DI Container injektuje stejnou instanci DoctrineUserRepository všude, kde závislost typuje na UserRepository. Doménové modely, hodnotové objekty a události z auto-registrace vylučujeme – nejsou to služby, ale data.

Autowiring s oddělenými Bounded Contexts

Ve větších projektech s více Bounded Contexts konfigurujte autowiring pro každý kontext samostatně. Každý kontext dostane vlastní blok v services.yaml, čímž ohraničíme kontext i na úrovni service containeru.

Časté otázky

Kam v Symfony projektu patří doménová vrstva a proč ji držet odděleně?

Doménová vrstva se umisťuje do samostatného adresáře – typicky src/Domain/ s podsložkami pro jednotlivé Bounded Contexty – odděleně od kontrolerů, Doctrine mapování a infrastruktury. Izolace umožňuje testovat a refaktorovat model bez závislosti na Symfony životním cyklu a dovoluje přenést doménu i do jiného technologického stacku. Viz sekci Struktura projektu.

Jak mapovat agregát v Doctrine bez toho, aby doména závisela na ORM?

V tomto průvodci používáme Doctrine atributy přímo na agregátu jako pragmatickou výchozí volbu – jsou to metadata, ne chování. Pokud trváte na čisté doméně bez stop ORM, korektní řešení je Persisted Object Pattern (Khononov, Learning DDD): doménová třída zůstane POPO, vedle ní v infrastruktuře existuje samostatná persistence třída s atributy a mapper mezi nimi. Detail v sekci Persisted Object Pattern – čistá DDD varianta.

Jak odlišit Aplikační službu od Doménové služby?

Doménová služba drží čistou doménovou logiku, která přirozeně nepatří žádnému agregátu ani hodnotovému objektu – je bezstavová a nekomunikuje s infrastrukturou. Aplikační služba naopak orchestruje use case: přijme vstup z kontroleru, načte agregáty přes repozitář, zavolá doménovou logiku a předá výsledek k persistenci. Aplikační služba nikdy neobsahuje doménová pravidla, pouze posloupnost kroků. Podrobný rozbor v sekci Aplikační služby a Doménové služby.

Mají doménové operace vyhazovat výjimky, nebo vracet Result typ?

V PHP a Symfony ekosystému jsou výjimky dominantní cestou. Při porušení invariantu agregát vyhodí konkrétní doménovou výjimku (například InsufficientFundsException). Aplikační vrstva ji přeloží na HTTP odpověď nebo zprávu uživateli. Result/Either typ je v PHP možný, ale přidává složitost bez odpovídajícího přínosu. Kontrolery zachytávají jen doménové podtypy, nikdy obecnou Exception. Rozbor variant v sekci Strategie zpracování chyb.

Kdy použít Doctrine Custom Type pro Value Object?

Doctrine Custom Type se hodí tam, kde se hodnotový objekt ukládá jako jednoduchá hodnota v jednom sloupci – peněžní částka, e-mail, URL, vlastní identifikátor. Custom Type přeloží hodnotový objekt při zápisu do primitivu a při čtení ho zpět rekonstruuje. Doménový kód pak pracuje vždy s typovým objektem. Pro hodnotové objekty složené z více sloupců je vhodnější embeddable mapování. Detailní rozbor v sekci Doctrine custom types pro Value Objects.