Kapitola 10·Architektura·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.
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. 10.1-AHranice 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.
Tento směr závislostí formalizuje hexagonální, onion i clean architektura – jejich
srovnání rozvádí kapitola o architektonických stylech.
Výchozí struktura Symfony (src/Controller, src/Entity, src/Repository) řadí soubory podle technické role. Vertikální slice je řadí podle Bounded Contextu: každý kontext drží svou doménu, infrastrukturu i feature složky pohromadě. Odchylku připouštějí i Symfony Best Practices – doporučení výchozí struktury má dovětek „Unless your project follows a development practice that imposes a certain directory structure“ [1]. Příklad:
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.
Vstupní bod do agregátu je kořen agregátu – třída 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 class User extends AggregateRoot. Bázová třída poskytuje record()
a releaseEvents() – sdílené chování pro všechny agregáty, ne duplicitní kopii
v každé entitě. final mapované entitě nevadí: nativní lazy objekty z PHP 8.4
pracují nad původní třídou a žádnou podtřídu nevytvářejí
[2], a DoctrineBundle 3
je už neumožňuje vypnout (rozbor v kapitole Návrh agregátu).
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. Událost UserRegistered se nahrává
ve factory, ne v konstruktoru – konstruktorem prochází i rekonstituce
uloženého agregátu a ta žádnou událost vyvolat nesmí.
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
skončí výjimkou 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.
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, co hodnotový objekt přináší: invariant „jméno není prázdné
a má rozumnou délku“ vynucuje typ. 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).
Třetí typ hodnotového objektu je identita agregátu. Generuje se v aplikaci,
ne v databázi – handler tak zná ID ještě před uložením:
Uuid::v7() z balíčku symfony/uid vrací časově řaditelné UUID, vhodné
jako primární klíč (sekvenční zápisy nedrobí B-tree index). Stejný vzor platí
pro OrderId nebo PaymentId; hodnotu vždy zpřístupňuje public readonly
property $value, žádná metoda value().
Property hooks a asymetrická viditelnost
Symfony 8 vyžaduje PHP 8.4, takže hodnotový objekt lze zapsat i jinak než přes
readonly. Průvodce u něj přesto zůstává, a to ze dvou důvodů.
Asymetrická viditelnost (public private(set) string $value) nahrazuje dvojici
privátní vlastnost plus getter. Hodí se objektům s wither metodami, které si
uvnitř třídy potřebují hodnotu po clone přepsat. Pro hodnotový objekt je
readonly přísnější: zakazuje zápis i zevnitř třídy, takže neměnnost nestojí
na disciplíně autora. Proto VO v celém průvodci zůstávají final readonly.
Property hooks svádějí přesunout validaci z konstruktoru do set hooku.
V doménovém modelu je to past. Hook se spustí při každém zápisu včetně hydratace
z databáze, kdežto konstruktor Doctrine obchází. Validace by se tedy spouštěla
právě tam, kde je zbytečná, a mlčela tam, kde na ní záleží.
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() jen zařadí agregát k uložení přes persist(); flush a commit provede
transakční middleware na command busu, takže jeden use case odpovídá jedné transakci.
Publikace doménových událostí je samostatný krok aplikační vrstvy – proběhne až po
commitu, aby příjemci viděli uložený stav.
Doménová vrstva bez jediného kusu metadat o persistenci vyžaduje druhý objektový
model.
Doménová třída zůstane POPO bez atributů, vedle ní v infrastrukturní vrstvě žije
samostatná persistence třída se všemi Doctrine atributy a dva jednosměrné mappery
překládají mezi nimi. Průvodce pro tuto konstrukci používá název Persisted
Object Pattern; v literatuře se pro ni vžilo označení persistence model
a obecným rodičovským vzorem je Fowlerův Data Mapper (PoEAA, 2002)
[4].
Dva nejčastěji citovaní obhájci čisté domény jsou přitom proti. Vladimir Khorikov
označuje plnohodnotný persistence model za příliš drahý: složitost roste s každou
asociací typu one-to-many a many-to-many a ztrácí se ORM change tracking, o který
se opírají spolehlivé doménové události
[5].
Matthias Noback nazývá oddělené entity „expensive and unnecessary form of
decoupling“ a doporučuje místo nich ORM entitu obohacenou o doménové metody,
invarianty a vlastní výjimky
[6]. Jediný
scénář, který uznává, je vývoj hranic agregátu nezávisle na schématu databáze –
a dodává, že většina projektů, které se toho dovolávají, to nepotřebuje.
Sekce v průvodci zůstává proto, že ukazuje, co taková separace stojí. Ukázka je
úplná, ať si tu cenu můžete spočítat sami.
Ruční mapper je největší nákladová položka vzoru a od Symfony 7.4 na něj existuje
stabilní komponenta symfony/object-mapper[7]. Atribut
#[Map(target: UserPersistenceModel::class)] popíše převod deklarativně,
ObjectMapperInterface::map() ho provede. Ruční kód pak zbude jen tam, kde se
tvar obou modelů rozchází – typicky u hodnotových objektů složených z více
sloupců. Riziko driftu tím ale nezmizí: komponenta přemapuje to, co jí popíšete,
a chybějící pole nenahlásí.
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.
Sekce Implementace entit deklaruje vlastnosti přímo typu Email
nebo UserId. Tuto hydrataci zajišťuje Doctrine custom type – konvertor
mezi databázovým primitivem a hodnotovým objektem. Zde je jeho implementace
a registrace.
Třída záměrně nemá metodu getName() – DBAL 4 ji odstranil. Jméno typu určuje
výhradně klíč v doctrine.dbal.types; konstanta NAME slouží jen jako
pojmenovaná reference pro atributy #[ORM\Column]. Bez getSQLDeclaration() by
délku sloupce diktoval rodičovský StringType.
XML mapping (User.orm.xml) dokáže totéž bez atributů ve třídě, doménu od ORM
ale neoddělí – jen přesune metadata do jiného formátu. Po odstranění annotation
a YAML driveru v ORM 3 je navíc jedinou neatributovou variantou
[8] a nástroje kolem
Doctrine ji obsluhují hůř než atributy. Kdo chce striktní oddělení, najde řešení
v sekci Persisted Object Pattern.
Stav objednávky, role uživatele, priorita úkolu – konečné množiny hodnot, které se dřív modelovaly konstantami ve třídě,
mají nativní typ: enum. Překlep v názvu case odhalí statická analýza, neznámou hodnotu odmítne typová kontrola za běhu.
Doménová služba zapouzdřuje pravidlo, které přirozeně nepatří žádnému agregátu
ani hodnotovému objektu. Typicky jde o operaci nad dvěma a více agregáty
(MoneyTransferService mezi dvěma účty) nebo o bezstavový výpočet s externím
vstupem – kurzovní převod, kalkulace daně podle jurisdikce.
Před sáhnutím po doménové službě stojí vždy jedna otázka: 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 ho smí měnit. Doménová služba na tomtéž místě
je anti-vzor: 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 pro objednávku vytvořit Payment, vede přes tuto metodu.
Invariant „platit lze jen confirmed objednávku“ tedy vynucuje typový systém,
ne naděje, že někdo zavolá správnou službu. Aplikační handler pak má jen
koordinační roli. Pojmy command a handler vysvětluje
sekce o aplikačních službách, podrobně kapitola CQRS:
Handler zapisuje dva agregáty v jedné transakci, což je odchylka od pravidla
jeden agregát na transakci. Držíme ji
vědomě: přechod Order do stavu Paid a vznik odpovídajícího Payment tvoří
jediný invariant a rozpad na dvě transakce by připustil zaplacenou objednávku
bez platby. Alternativou je eventual consistency – Order publikuje
PaymentRecorded a Payment vzniká až v reakci na událost. Cena je okno, kdy
platba ještě neexistuje, a nutnost kompenzace při selhání.
Specification Pattern (Eric Evans, DDD, kap. 9) zapouzdřuje doménové pravidlo
do samostatného objektu s jedinou metodou isSatisfiedBy(). Pravidlo „objednávka
je způsobilá k expedici“ pak existuje na jednom místě – stejná specifikace slouží
validaci v agregátu, filtrování kolekcí i výběru v repozitáři. Malá pravidla se
skládají kombinátory and(), or() a not() do složitějších, bez kopírování
podmínek po kódu.
Plný výklad včetně implementace v PHP, kombinátorů a double-dispatch napojení
na Doctrine najdete v kapitole
Specification Pattern.
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.
Konstruktor odpovídá volání record(new UserRegistered(...)) ve factory
User::register() v sekci Implementace entit.
Tato sekce poprvé skládá dohromady trojici command – handler – bus, proto
krátké vysvětlení pojmů. Command je neměnný objekt popisující záměr:
„zaregistruj uživatele s tímto jménem a e-mailem“. Nemá chování, nese jen data
use case. Handler je třída, která command vykoná – načte agregáty, zavolá
doménovou metodu, uloží výsledek.
Command bus oba spojuje. Volající předá command busu (MessageBusInterface
ze Symfony Messenger) a ten najde příslušný handler podle typu zprávy. Mezi
dispatch a handler se navíc vkládají middleware: validation spustí Symfony
Validator nad commandem, doctrine_transaction obalí handler databázovou
transakcí (podrobně v kapitole CQRS).
Průvodce používá bus už zde, protože je to idiomatická Symfony cesta: kontroler
nezná handler, jen popis záměru. Stejný command lze později zpracovat asynchronně
bez zásahu do volajícího kódu. Plný výklad včetně oddělených busů pro commandy
a queries přináší kapitola CQRS.
Aplikační služba má tedy 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.
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 k tomu nabízí #[MapRequestPayload]: deserializuje a validuje JSON
požadavek přímo do typového commandu. Pro klasické HTML formuláře 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.
Kontroler je tenký, takže těžiště testů leží pod ním. Agregáty se testují jako
čistý PHP bez kernelu. Aplikační handlery, které se opírají o repozitář,
pokrývá kernel test s testovací databází – jen reálná DB ověří unique
constraint a transakční chování, in-memory mock je negarantuje. Konkrétní
testy po vrstvách rozebírá kapitola Testování DDD.
Mimo kontroler zůstává i autorizace; má vlastní kapitolu
Autorizace v DDD. Stručně:
otázku „smí tento uživatel vykonat tento use case na tomto objektu“ řeší
use-case vrstva přes Symfony Voter, zatímco doménové invarianty zůstávají
v agregátu. Kapitola zavádí čtyřvrstvý rámec od HTTP firewallu po pravidla
na úrovni polí a ukazuje, proč doménová pravidla do Voteru nepatří.
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. Kdo nechce
udržovat glob vzory v services.yaml, označí takovou třídu atributem
#[Exclude] ze Symfony\Component\DependencyInjection\Attribute; kontejner ji
pak přeskočí bez ohledu na resource:.
Autowiring s oddělenými Bounded Contexts
Ve větších projektech s více Bounded Contexts se autowiring konfiguruje pro každý kontext samostatně.
Každý kontext dostane vlastní blok v services.yaml – hranice se tak promítne i do service containeru.
Symfony – The Symfony Framework Best Practices. Doporučení atributového mapování Doctrine a dovětek, který připouští odchylku od výchozí adresářové struktury.
PHP Manual – Lazy Objects (PHP 8.4). Nativní lazy ghost a lazy proxy bez generované podtřídy.
Doctrine ORM – Basic Mapping. Podpora property hooks od ORM 3.4 a chování DQL nad hodnotou uloženou ve sloupci.
Martin Fowler – Data Mapper, Patterns of Enterprise Application Architecture (2002). Rodičovský vzor odděleného persistence modelu.
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 – v tomto průvodci src/<BoundedContext>/Domain/, například src/UserManagement/Domain/ – 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í. Doménu bez jediné stopy ORM zajistí oddělený persistence model, v tomto průvodci nazývaný Persisted Object Pattern: doménová třída zůstane POPO, vedle ní v infrastruktuře existuje samostatná persistence třída s atributy a mapper mezi nimi. Rodičovským vzorem je Fowlerův Data Mapper. Khorikov i Noback tuto separaci označují za drahou a ve většině projektů zbytečnou, takže jde o menšinovou volbu, ne o výchozí doporučení. Detail v sekci Persisted Object Pattern.
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.