DÍL 06a KAPITOLA 06 · SEKCE 06.01–06.04

Entity a Value Objects v PHP 8.4: identita a hodnota

Entita a hodnotový objekt v PHP 8.4 a Symfony 8: proč se entity porovnávají přes ID, odkud se ID bere, proč má každé vlastní třídu a jak vypadají Email a Money, které jsou platné už od konstruktoru.

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

Tentýž uživatel, dvě instance

Tentýž uživatel existuje v paměti dvakrát, třeba jednou z databáze a podruhé ze zprávy. Jedna z těch instancí dostane nový e-mail.

Operátor dvě rovnítka pak tvrdí, že jde o dva různé uživatele. Přitom ID je pořád stejné.

Správnou odpověď dá až metoda equals, která porovná jen identitu.

Entita a rovnost entit

Nejdřív krátce z dílů jedna a tři. Stavební bloky žijí uvnitř Bounded Contextu, tedy hranice, ve které platí jeden model a jeden slovník.

A ten slovník platí i v kódu. Když kód mluví o zákazníkovi a produktový tým o uživateli, začíná každý rozhovor nad zadáním překladem.

Dva uživatele se stejným jménem a e-mailem od sebe odliší jen identita. Entita je objekt, který nese vlastní identifikátor a drží si ho celý život.

Evans v DDD Reference píše, že entitu definuje nepřerušená kontinuita a identita. Atributy o ní nerozhodují. Jméno i e-mail se klidně změní a identita zůstává.

Z toho plyne pravidlo pro rovnost. Dvě entity jsou totožné právě tehdy, když mají stejné ID. Metoda equals proto porovnává jen identifikátory.

Operátor dvě rovnítka se nehodí. Srovná všechny vlastnosti, takže stačí změnit e-mail a z téže entity je rázem jiná.

A tři rovnítka? Zkuste odhadnout, co vrátí pro tentýž objekt, který jednou přišel ze zprávy a podruhé z databáze.

Vrátí false. Tři rovnítka porovnávají instance v paměti a tady jsou instance dvě, i když jde o tutéž doménovou entitu.

Spolehlivě odpoví jen equals nad identifikátorem.

Vznik identity a typovaná ID

Třída User dostala ID konstruktorem a neřešila, odkud se vzalo. Vaughn Vernon popisuje čtyři cesty. ID zadá uživatel, vygeneruje ho aplikace nebo persistence, nebo ho přidělí jiný Bounded Context.

Kniha volí generování v aplikaci. Entita, která ID dostane až od databáze, ho při vzniku nemá. Nemůže tedy ohlásit událost o svém vzniku a nikdo jiný se na ni nemůže odkázat.

Ke stejnému závěru došel Matthias Noback. ID se má vytvořit dřív než objekt.

V knize proto ID vzniká přes UUID verze sedm z balíčku symfony uid. Dokumentace Symfony tuhle verzi doporučuje místo verzí jedna a šest. Má lepší entropii a přísnější řazení podle času.

Konstruktor hlídá formát. A metoda toString tu není kosmetika. Doctrine ji potřebuje při každém persist a bez ní uložení spadne.

Stejnou třídu má kniha ještě pro CustomerId a ProductId. Liší se jen jménem a chybovou hláškou, takže svádí vytáhnout společného předka.

Jenže equals s parametrem self zděděná z předka přijme kteréhokoli potomka. Zákazníka tak porovnáte s produktem a PHP nic nenamítne. Přesně tomu měly typované identifikátory zabránit.

Proto má každé ID vlastní třídu a stejné volání pak skončí chybou typu. Opakování je tu cena za kontrolu.

A co přirozený klíč, třeba IČO nebo ISBN? Evans ho jako identitu připouští. Jenže ani takové hodnoty nejsou tak stálé, jak vypadají. Mění se a bývají přidělené chybně nebo duplicitně.

Kniha proto drží umělé ID a přirozený klíč vede jako běžný atribut s unikátním indexem.

Hodnotové objekty: Email

Když mají dva e-maily stejný text, jde o tutéž adresu. Hodnotový objekt totiž nemá identitu a rozhoduje jen to, co obsahuje. Evans ho proto doporučuje držet neměnný.

Bez hodnotového objektu drží User e-mail jako obyčejný string. Do stringu ale projde cokoli. Fowler takovým rozsypaným primitivům říká Primitive Obsession.

Oprava je na jeden řádek. Místo stringu přijde typ Email a s ním jistota, že uvnitř je platná adresa.

Samotná třída Email je final readonly. Nikdo ji nezdědí a po vytvoření se už nezmění. Hodnotu nese veřejná readonly vlastnost value, getter by jen přidal šum.

Všimněte si rozdělení práce. Konstruktor jen validuje formát. Oříznutí mezer a převod na malá písmena, tedy normalizace vstupu z formuláře, patří do pojmenované továrny fromUserInput.

A metoda equals porovnává hodnotu. Dvě instance se stejnou adresou jsou si rovné.

Readonly má i svou cenu. U objektu s pěti poli musí každá metoda typu withCurrency vypsat new self se všemi poli. Pohodlnější clone with přineslo až PHP 8.5 a kniha cílí na 8.4.

A property hooks s readonly zkombinovat nejdou, manuál PHP to říká přímo. Kniha proto volí readonly a validaci nechává v konstruktoru.

Money, Currency a validace

Druhý hodnotový objekt, ke kterému se kniha pořád vrací, skládá víc hodnot. Bez něj by metoda pro přidání položky do objednávky brala cenu a měnu jako dva volné parametry.

Money je spojí do jednoho pojmu, který nejde rozpojit.

Uvnitř je částka jako celé číslo v nejmenších jednotkách měny, tedy v haléřích nebo centech. Float by do peněz vnesl chyby zaokrouhlení a ty se projeví až na faktuře.

Měnu drží enum s hodnotami typu string. Záměna kódu měny psaného malými písmeny za velká tak nehrozí.

Záporná částka neprojde už konstruktorem.

A sčítání dvou různých měn skončí výjimkou. Je to doménové pravidlo, žádná chyba volajícího.

Holá DomainException je tu vědomá zkratka. Money totiž používá Ordering, Billing i Pricing, takže leží ve Shared Kernelu. Pojmenovaná výjimka by tam byla další sdílený typ.

Zbývá rozhodnout, kdo hlásí jakou chybu. Kniha rozlišuje dvě úrovně.

Když nesedí formát, třeba neplatný e-mail, záporná částka nebo řetězec, který není UUID, ozve se konstruktor hodnotového objektu. Hází InvalidArgumentException.

Porušení byznys pravidla hlásí agregát, tedy objekt, který hlídá pravidla celé objednávky. Třeba když někdo potvrzuje prázdnou objednávku. Hází pojmenovanou výjimku odvozenou z DomainException.

Obojí stojí na jednom předpokladu. Objekt se nesmí ocitnout v nevalidním stavu ani na okamžik. Vladimir Khorikov tomu říká always-valid domain model.

Je to volba. Jde to i obráceně: validovat jen ve vstupní vrstvě a doménové objekty nechat hloupé. Kniha drží první variantu, protože jen tak je konstruktor zárukou platnosti.

Otázky a shrnutí

Krátká otázka na rozmyšlenou. Záporná částka v Money a platba nepotvrzené objednávky. Která výjimka patří ke které chybě?

Záporná částka je chyba formátu, takže ji odmítne už konstruktor Money výjimkou InvalidArgumentException. Platba nepotvrzené objednávky porušuje byznys pravidlo a hlásí ji agregát pojmenovanou doménovou výjimkou.

A jedna ze třetího dílu. Money leží ve Shared Kernelu. Co musí proběhnout, než ho jeden tým změní?

Souhlas všech týmů, které na kernelu stojí. Jednostranně ho nesmí změnit nikdo, a proto má kernel zůstat malý.

Takže v kostce. Entitu drží pohromadě identita a rovnost ověřuje metoda equals nad ID.

ID vzniká v aplikaci přes UUID verze sedm a každé má vlastní třídu. Hodnotový objekt je neměnný a platný už od konstruktoru. Money navíc nese částku v haléřích a měnu jako enum.

Příští díl na těchhle hodnotových objektech postaví agregát, repozitář, doménovou službu a událost. Celou kapitolu najdete v knize na ddd-v-symfony.katuscak.cz.

← Všechny díly videokurzu