Doctrine a Value Objects: agregát bez stringů
Agregát User v Symfony 8 a Doctrine ORM 3: co smí doména importovat, struktura projektu podle Bounded Contextů, privátní konstruktor s továrnou, hodnotové objekty přímo v entitě díky custom typům a embeddables. A dvě volby, které vypadají čistěji, než jsou: oddělený persistence model a obecné transitionTo().
Přehrávač YouTube se načte až po kliknutí · otevřít na YouTube (nové okno) ↗
Přepis dílu komentář tak, jak zazní ve videu
String v entitě a TypeError
Modelová situace. Entita User drží identifikátor jako obyčejný string a getter z něj pokaždé skládá nový UserId. Vypadá to jako zbytečná oklika.
Důvod má ale skutečný. Doctrine při načítání konstruktor nevolá a vlastnosti plní napřímo. Kdyby vlastnost měla typ UserId a pod ní ležel obyčejný sloupec, skončí načtení na TypeError.
Obejde se to i bez okliky. Doctrine umí hodnotový objekt vrátit rovnou jako objekt.
Hranice a struktura projektu
Nejdřív hranice. Kapitola kreslí doménu a kolem ní Symfony. Symfony na doméně závisí a implementuje její rozhraní, třeba repozitář uživatelů.
Opačným směrem závislost nevede nikdy. Agregát, který by sám sahal do Messengeru, by hranici prorazil.
Úplně sterilní doména ale není. Kniha jí vědomě povoluje tři importy. Komponentu symfony uid pro generování identit a z Doctrine mapovací atributy a kolekce.
Atributy jsou metadata, žádné chování. Třída se s nimi chová stejně, jen nese popisek pro mapper. A Symfony Best Practices je doporučují výslovně.
XML mapping doménu od ORM neoddělí, metadata jen přestěhuje do jiného souboru. Skutečné oddělení dá až samostatný persistence model z poslední části.
Druhé rozhodnutí je struktura. Výchozí Symfony řadí soubory podle technické role, do složek Controller, Entity a Repository.
Kniha je řadí podle Bounded Contextu. Best Practices tu odchylku připouštějí, pokud projekt jde cestou, která si vlastní strukturu vynucuje.
Strom v kapitole ukazuje obě organizace z minulého dílu vedle sebe. UserManagement dělí aplikační kód do feature složek Registration a Profile, Ordering drží klasické vrstvy.
Identifikátory přitom nemají společného předka. Každý kontext má vlastní UserId nebo OrderId. Do Shared Kernelu jde jen to, co opravdu sdílejí všichni, třeba Money nebo bázová třída AggregateRoot.
Krátký odhad. Objednávka potřebuje vědět, ke které zásilce patří. Smí Ordering importovat třídu ShipmentId z cizího kontextu?
Smí. Identifikátor nenese žádné chování cizího modelu, je to jen odkaz. Import cizího agregátu, jeho entit nebo doménové služby je naproti tomu vždycky podezřelý.
Kořen agregátu User
Příkladem kapitoly je User v kontextu UserManagement. Příští díl s ním projde celou registraci.
Naivní verze vypadá takhle. Veřejný konstruktor, který rovnou zaznamená UserRegistered, a k tomu setter na jméno.
Proč record do konstruktoru nepatří, víte z dílu šest bé. Doctrine konstruktor při načítání obchází, ale oddělený persistence model z poslední části skládá agregát metodou reconstitute. A ta konstruktor volá.
Kniha proto konstruktor zavírá. Uživatel vzniká jedině továrnou register a událost se zaznamená v ní.
Krátký odhad. Přibude uživatel importovaný z LDAP. Dostane konstruktor nový parametr s druhem registrace?
Nedostane. Přibude druhá pojmenovaná továrna a konstruktor zůstane bez přepínače. Každá cesta vzniku má vlastní jméno.
A místo setteru mluví metody jazykem domény. Jméno mění rename a e-mail changeEmail. Když je nová hodnota stejná jako stará, metoda nic neudělá.
Slovo final a sloupec verze znáte z dílu sedm bé. Souběžná změna skončí výjimkou OptimisticLockException a aplikační vrstva ji zopakuje.
Hodnotové objekty a custom typy
Zpátky k úvodu. Kvůli hydrataci se nabízí držet v entitě string a hodnotový objekt skládat až v getteru.
Kniha to připouští pro prototyp. Každé čtení ale objekt znovu sestaví a zvaliduje. S přibývajícími hodnotovými objekty se to přestane vyplácet.
Vlastnost proto dostane rovnou typ Email a sloupec odkáže na vlastní typ Doctrine. Getter pak vrací, co v entitě leží.
Ten typ je malá třída. Při načtení z řetězce vyrobí Email, takže vlastnost dostane objekt.
Opačný směr je tolerantní. Když místo objektu přijde řetězec, typ ho prostě uloží.
Krátký odhad. Typ pro identifikátor by se mohl chovat stejně tolerantně a na neznámý vstup vrátit null. Co by pak udělal dotaz na uživatele?
Nenašel by nic a nikdo by nevěděl proč. UserIdType proto radši spadne. Na cokoli jiného než UserId hodí InvalidType. Stejný vzor znáte z typu OrderIdType v dílu sedm bé.
Oba typy se registrují v konfiguraci Doctrine pod klíčem types.
Nové klíče se jen přidávají k typům z Orderingu. Kdo blok přepíše, přijde o půlku typů.
Druhou cestou je embeddable. Custom typ se hodí na hodnotu v jednom sloupci. Embeddable ukládá hodnotový objekt do sloupců tabulky vlastníka, třeba jméno uživatele nebo hash hesla.
Na hashi hesla je zajímavý konstruktor. Je privátní a instance vznikají dvěma továrnami. FromPlainText heslo ověří a zahashuje, fromHash převezme hash, který už existuje.
Veřejný konstruktor by dovolil předat heslo v otevřené podobě a nic by se nestalo. Dvě pojmenované továrny tenhle omyl vylučují.
A proč validaci nepřesunout do property hooks? S readonly se nesnesou, to víte z dílu šest a. Kapitola přidává druhý důvod.
Doctrine při hydrataci zapisuje surovou hodnotu vlastnosti a hook obchází stejně jako konstruktor. Validace v hooku by tedy nechránila nic navíc.
Persisted Object a obecné transitionTo()
Atributy Doctrine v doméně někomu vadí. Doménu úplně bez nich dá jen druhý objektový model. Kniha mu říká Persisted Object Pattern a rodičovským vzorem je Fowlerův Data Mapper.
Doménový User je obyčejná PHP třída bez jediného atributu. Vedle ní v infrastruktuře leží UserPersistenceModel se všemi atributy a UserMapper překládá oběma směry.
Při načtení volá mapper metodu reconstitute. To je ta továrna z druhé části, kvůli které nesmí být událost v konstruktoru.
Kapitola cenu spočítá poctivě. Kód je dvakrát a hodnotové objekty mapuje ruční kód, protože custom typy tu nepoužijete.
Horší je drift. Když přibude pole v doméně, musí přibýt i v persistence modelu a v mapperu. Překladač to nehlídá.
A update potřebuje najít a přepsat. Mapper vyrábí novou instanci s verzí jedna, což pro vložení stačí. Při změně by ale kolidovala s primárním klíčem a vynulovala optimistický zámek.
Proti jsou dva autoři, kteří čistou doménu jinak hájí. Vladimir Khorikov považuje plný persistence model za příliš drahý. Složitost roste s každou kolekcí a ztrácí se change tracking, o který se opírají spolehlivé doménové události.
Matthias Noback označuje oddělené entity za drahé a zbytečné a doporučuje obohatit ORM entitu o doménové metody. Jediný důvod pro oddělení vidí ve vývoji agregátů bez změn schématu. Sám s tím podle svých slov zkušenost nemá.
Ruční mapper zkrátí komponenta ObjectMapper, od Symfony sedm celá čtyři stabilní. Drift ale nezmizí. Přemapuje, co jí popíšete, a chybějící pole nenahlásí.
Krátký odhad. Kdy se Persisted Object vyplatí?
Jen tam, kde na oddělení opravdu záleží. Core Domain s vysokou hodnotou, dlouhá údržba nebo plán vyměnit persistenci. Jinde kniha drží atributy jako přijatelný kompromis.
Druhá lákavá zkratka se týká stavů. OrderStatus je enum s hodnotami typu string a sám ví, kam z kterého stavu smí přejít.
Nabízí se proto jediná obecná metoda transitionTo. Přechod ověří a zaznamená OrderStatusChanged.
Kniha ji bere jako alternativu k pojmenovaným přechodům markPaid, ship a cancel, rozhodně ne jako doplněk. Kdo si nechá obojí, má do stavu Paid dvě cesty a každá vydá jinou událost.
Projekce, která poslouchá jen OrderPaid, pak zaplacení jednou uvidí a podruhé ne. Záleží na tom, kudy se šlo.
Obecný přechod se vyplatí u mnoha jednotvárných stavů, třeba u workflow tiketu. Objednávka mezi ně nepatří.
Otázky a shrnutí
Otázka na rozmyšlenou. Přibude hodnotový objekt DateRange se dvěma daty. Custom typ, nebo embeddable?
Embeddable. Custom typ převádí objekt na jeden sloupec, třeba e-mail nebo identifikátor. Hodnota ze dvou sloupců patří do tabulky vlastníka jako embeddable.
A jedna z dílu o specifikaci. Pravidlo existuje dvakrát, v PHP a jako dotaz Criteria. Co hlídá, aby se ty dvě podoby nerozešly?
Kontraktní test. Nad stejnými daty porovná isSatisfiedBy s výsledkem match. Bez něj se rozejdou potichu, podobně jako persistence model s doménou.
Takže v kostce. Z infrastruktury zná doména jen mapovací atributy, kolekce a symfony uid. Kontexty mají vlastní modely i identifikátory.
Agregát vzniká pojmenovanou továrnou a událost se zaznamená právě v ní. Hodnotové objekty leží v entitě jako objekty díky custom typům a embeddables.
Oddělený persistence model a obecné transitionTo vypadají čistěji, obojí ale stojí víc, než přináší.
Příští díl projde registraci uživatele od HTTP požadavku až po doménovou událost. Celou kapitolu o implementaci v Symfony najdete v knize na ddd-v-symfony.katuscak.cz.