Kapitola 10 · Architektura · Implementace DDD v Symfony 8

Implementace DDD v Symfony 8

DDD koncepty přeložené do Symfony 8: struktura projektu podle Bounded Contextů, persistence agregátů přes Doctrine, konfigurace Messengeru a Doctrine custom types pro hodnotové objekty.

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

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

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 a přenositelné mezi projekty. Kniha doméně vědomě povoluje jen symfony/uid pro generování identit a z Doctrine mapovací atributy a kolekce (Doctrine\ORM\Mapping, Doctrine\Common\Collections). Symfony vrstva implementuje kontrakty definované doménou (repository interface, event dispatch) a zajišťuje HTTP, persistenci a messaging.

FIG. 10.1-A Hranice mezi DDD a Symfony

Směr závislostí je jednoznačný: Symfony závisí na doméně (implementuje její rozhraní), nikdy naopak. Doménová vrstva ze Symfony importuje jen Symfony\Component\Uid. Jedinou stopou infrastruktury v ní jsou mapovací atributy Doctrine (viz volba mappingu). Doctrine proto jde nahradit jiným ORM a Messenger jiným bus systémem bez zásahu do doménové logiky; přepíšou se jen metadata. Tento směr závislostí formalizuje hexagonální, onion i clean architektura. Jejich srovnání rozvádí kapitola o architektonických stylech.

10.02 Struktura projektu#

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:

Strom ukazuje obě organizace, které kniha používá. UserManagement řadí aplikační kód do feature složek, Ordering drží klasické vrstvy Domain/Application/Infrastructure. Kdy se vyplatí která, rozebírá Vertical Slice Architecture. Identifikátory nemají sdíleného předka: každý kontext má vlastní UserId nebo OrderId (důvod v Základních konceptech).

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. Výjimkou jsou jen identifikátory jako ShipmentId, které nenesou chování cizího modelu.

FIG. 10.2-A Struktura projektu s Bounded Contexts

10.03 Implementace entit#

Do agregátu se vstupuje přes 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()). Agregát tak nejde vytvořit v nekonzistentním stavu. Definice entity je v kapitole Základní koncepty DDD, zde jde o její podobu v Symfony.

Detaily implementace:

  • final class User extends AggregateRoot. Bázová třída poskytuje record() a releaseEvents(), tedy sdílené chování pro všechny agregáty místo kopie 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á povolená cesta, jak agregát vytvořit. 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. Rekonstituce uloženého agregátu žádnou událost vyvolat nesmí. Doctrine při hydrataci konstruktor obchází, vlastní mapper (Persisted Object Pattern) ho ale přes reconstitute() volá.
  • 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.

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 DDD:

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á do sloupců tabulky vlastníka (žádná samostatná tabulka pro VO).

Třetí typ hodnotového objektu je identita agregátu. Generuje se v aplikaci, ne v databázi, takže handler 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ší: po inicializaci zakazuje jakýkoli 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. U hodnotového objektu se to nevyplatí. Hook nejde kombinovat s readonly (PHP ohlásí „Hooked properties cannot be readonly“), takže by VO přišel o neměnnost. Při hydrataci Doctrine zapisuje surovou hodnotu přes ReflectionProperty::setRawValue() a hook obchází stejně jako konstruktor. Validace v hooku tedy nechrání nic, co by nechránil konstruktor.

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() 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. Doménové události publikuje aplikační vrstva samostatným krokem až po flush(), aby posluchači viděli zapsaný stav.

10.06 Persisted Object Pattern – oddělený persistence model#

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 mapper mezi nimi překládá oběma směry. 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. Navíc se ztrácí 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 návrhu agregátů nezávisle na schématu databáze. Sám s ním podle svých slov zkušenost nemá a projekty s oddělenými entitami, o kterých slyšel, takovou potřebu neměly.

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í.

Další příklady průvodce zůstávají u atributů přímo na agregátech a Persisted Object Pattern dál nerozvádějí. Principy jsou stejné, jen každý agregát potřebuje vlastní mapper.

10.07 Doctrine custom types pro Value Objects#

Sekce Implementace entit deklaruje vlastnosti přímo typu Email nebo UserId. Jejich hydrataci zajišťuje Doctrine custom type, konvertor mezi databázovým primitivem a hodnotovým objektem.

Proč oba typy nemají metodu getName(), vysvětluje Návrh agregátu. Konstanta NAME je jen pojmenovaná reference, kterou lze v atributu #[ORM\Column] použít místo řetězce. 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 zbývá vedle atributů jen XML a málo používané PHP mapování [8]. Nástroje kolem Doctrine obě varianty obsluhují hůř než atributy. Kdo chce striktní oddělení, najde řešení v sekci Persisted Object Pattern.

10.08 Enums pro stavové typy#

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.

transitionTo() je alternativa k pojmenovaným přechodům, ne jejich doplněk. Kanonický agregát z Návrhu agregátu používá markPaid(), ship() a cancel(), protože každá operace nese vlastní invariant a vlastní událost. OrderPaid říká víc než OrderStatusChanged(Confirmed, Paid). Kdo si nechá obojí, dostane dvě cesty do téhož stavu, které zaznamenají různé události: markPaid() vydá OrderPaid, transitionTo() vydá OrderStatusChanged. Projekce, která poslouchá jen jednu z nich, pak podle toho, kudy se šlo, změnu jednou vidí a jednou ne.

Obecný přechod se vyplatí tam, kde je stavů hodně, přechody jsou jednotvárné a odlišné události by nic nepřinesly: workflow tiketu, stavy dokumentu ve schvalování. Objednávka mezi takové případy nepatří.

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 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 potvrzenou 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.

Ukázka je ilustrativní varianta k markPaid() z Návrhu agregátu a stojí na čtyřech třídách, které kniha dál nerozvádí: Payment, PaymentId, PaymentMethod a PaymentRecorded. Kanonický model platbu jako samostatný agregát nemodeluje; má ji v kontextu Payment za hranicí, kam se posílá příkaz. Do projektu podle knihy proto patří markPaid(); tento výpis ukazuje jen alternativu, kde platba vzniká uvnitř objednávky. Invariant „platit lze jen potvrzenou objednávku“ tak vynucuje agregát sá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. Je to odchylka od vodítka jeden agregát na transakci a žádná z Vernonových výjimek ji nekryje. Rozpad na dvě transakce by sice připustil zaplacenou objednávku bez platby, jenže invariant sdílený dvěma agregáty je podle Khononova signál, že hranice mezi Order a Payment stojí jinde. Ukázka odchylku drží kvůli jednoduchosti; kanonický model proto platbu nechává v kontextu Payment a objednávce posílá jen markPaid(). Alternativou uvnitř jednoho kontextu je eventual consistency: Order zaznamená 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í.

10.10 Specification Pattern#

Specification Pattern (Eric Evans, DDD, kap. 9) zapouzdřuje doménové pravidlo do samostatného objektu s metodou isSatisfiedBy(). Pravidlo „objednávka je způsobilá k expedici“ pak existuje na jednom místě. Táž 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 napojení na Doctrine na Doctrine najdete v kapitole Specification Pattern.

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 zaznamená při změně stavu:

UserRegistered nese minimum potřebné pro obnovu kontextu: ID uživatele, e-mail a čas registrace. Na rozdíl od událostí objednávky, které nesou hodnotové objekty (viz Návrh agregátu), jsou to primitivy. Událost odebírá i kontext Identity (Autorizace v DDD), a proto má tvar integrační události. 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.

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#

Trojice command, handler a bus se zde objevuje poprvé, proto krátce k pojmům. 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í.

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.

Čtení zde vede přes repozitář agregátu, tedy přes write model. Pro čtyři pole to stačí a zbytečnou vrstvu to nepřidává. Jakmile profil začne zobrazovat počet objednávek nebo úroveň členství, přestane to vycházet: data leží v jiných kontextech a agregát je nemá odkud vzít. Tou hranicí se zabývá kapitola o CQRS, kde GetUserProfileHandler čte z vlastního read repozitáře a vrací bohatší UserProfileViewModel.

10.14 Implementace kontrolerů#

Kontroler je adaptér 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 pokrývá unit test s in-memory fake repozitářem. Unique constraint, jeho překlad na doménovou výjimku a transakční chování fake neověří; ty patří do integračního testu proti testovací databázi. 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ří.

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 se z auto-registrace vylučují; 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, takže se hranice promítne i do service containeru. Následující konfigurace nahrazuje blok App\: z předchozí ukázky, nedoplňuje ho. Rozdíl je citelný: souhrnný App\: zaregistruje i to, na co se zapomnělo, per-kontextový výčet nic takového nemá. Třída v adresáři, který ve výčtu chybí, prostě není službou. Pokud na ní nic nezávisí (handler, kontroler), chybu neohlásí lint:container, ale až běh.

Posledních osm řádků tvoří aliasy rozhraní na implementaci. Většinu z nich by autowiring vytvořil sám, protože rozhraní i jeho jediná implementace leží ve stejném bloku resource (viz kapitola o architektonických stylech). Explicitní řádek ale dokumentuje volbu výchozího adaptéru a vydrží i ve chvíli, kdy vedle InMemoryPaymentGateway přibude skutečná platební brána a automatický alias zanikne.

10.16 Další četba a citace#

  1. 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.
  2. PHP Manual – Lazy Objects (PHP 8.4). Nativní lazy ghost a lazy proxy bez generované podtřídy.
  3. Doctrine ORM – Basic Mapping. Podpora property hooks od ORM 3.4 a chování DQL nad hodnotou uloženou ve sloupci.
  4. Martin Fowler – Data Mapper, Patterns of Enterprise Application Architecture (2002). Rodičovský vzor odděleného persistence modelu.
  5. Vladimir Khorikov – Having the domain model separate from the persistence model (2016). Argumentace proti plnohodnotnému persistence modelu.
  6. Matthias Noback – DDD entities and ORM entities (2022). „80% decoupling“ jako alternativa k oddělenému modelu; navazuje na DDD and your database (2020).
  7. Symfony – Object Mapper. Deklarativní mapování objektu na objekt přes atribut #[Map].
  8. Doctrine ORM – UPGRADE.md. Odstranění annotation a YAML driveru v ORM 3 a stav nativních lazy objektů.
  9. Symfony – Messenger. Routing zpráv, atribut #[AsMessage] a přednost konfigurace framework.messenger.routing.
  10. Doctrine ORM – Events. Omezení onFlush a postFlush, kvůli kterým se lifecycle listener nehodí na publikaci doménových událostí.
  11. Matthias Noback – Does it belong in the application or domain layer? (2021). Heuristika pro dělbu mezi aplikační a doménovou vrstvou.
  12. Vladimir Khorikov – Domain events: simple and reliable solution (2017). Sběr událostí v agregátu a publikace až po commitu.
  13. V tomto průvodci navazují kapitoly Návrh agregátu, CQRS, Outbox Pattern a Testování DDD.

Časté otázky

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

Doménová vrstva patří do samostatného adresáře, odděleně od kontrolerů, Doctrine mapování a infrastruktury. V tomto průvodci je to src/<BoundedContext>/Domain/, například src/UserManagement/Domain/. 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 stojí 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.