Základní koncepty DDD
Entity, Value Objects, agregáty, repozitáře a doménové události. U každého stavebního bloku kapitola ukazuje, jaký problém řeší a jak vypadá v PHP 8.4 a Symfony 8.
Obsah kapitoly
Kapitola ve videokurzu
Celý videokurzPřehrávač YouTube se načte až po kliknutí.
06.01 Ohraničené kontexty (Bounded Contexts)#
Slovo „zákazník“ znamená v marketingu něco jiného než ve fakturaci. Tým, který oba významy spojí do jedné třídy, skončí u modelu plného polí, z nichž polovina v daném použití nemá význam. Bounded Context je explicitně vymezená oblast, uvnitř které platí jeden konzistentní model a jeden slovník [1]. Různé kontexty proto mají záměrně různé modely. Téma je strategické: celkový rámec podává kapitola Co je DDD, vztahy a integraci mezi kontexty rozebírá Context Mapping. Rozdělení reálného systému do pěti kontextů ukazuje Případová studie. Zde slouží kontext jen jako hranice, uvnitř které žijí taktické stavební bloky.
06.02 Všudypřítomný jazyk (Ubiquitous Language)#
Když kód mluví o Customer a produktový tým o „uživateli“, každý rozhovor nad
zadáním začíná překladem. V překladu se ztrácejí významy. Všudypřítomný
jazyk je jednotný slovník, na kterém se vývojáři domluví s doménovými experty
a který pak důsledně platí v kódu, dokumentaci i běžné konverzaci
[2].
Proč jazyk vzniká a jak se buduje, popisuje kapitola Co je DDD; kde jeden
jazyk končí a začíná druhý, určuje hranice kontextu z Context Mappingu.
06.03 Entity#
Dva uživatele se stejným jménem a e-mailem odlišuje identita. Entita je doménový objekt, který nese vlastní identifikátor a zachovává si ho po celý život. Evans v DDD Reference píše, že takový objekt definuje nepřerušená kontinuita a identita, ne jeho atributy [3]. Jméno, adresa i e-mail se mohou měnit – identita zůstává.
1<?php2 3declare(strict_types=1);4 5namespace App\UserManagement\Domain\Model;6 7use App\UserManagement\Domain\ValueObject\Email;8use App\UserManagement\Domain\ValueObject\UserId;9 10class User11{12 public readonly \DateTimeImmutable $createdAt;13 14 public function __construct(15 // Identita je veřejná readonly vlastnost, stejně jako v kanonickém16 // User z kapitoly Implementace v Symfony. Zbytek knihy ji čte17 // jako `$user->id`, ne přes getter.18 public readonly UserId $id,19 private string $name,20 private Email $email,21 ) {22 $this->createdAt = new \DateTimeImmutable();23 }24 25 public function name(): string26 {27 return $this->name;28 }29 30 public function email(): Email31 {32 return $this->email;33 }34 35 public function changeName(string $name): void36 {37 $this->name = $name;38 }39 40 public function changeEmail(Email $email): void41 {42 $this->email = $email;43 }44 45 public function equals(self $other): bool46 {47 return $this->id->equals($other->id);48 }49}
Rovnost entit
Dvě entity jsou totožné právě tehdy, když mají stejné ID. Proto equals()
porovnává výhradně identifikátory. Operátor == se nehodí, protože srovnává všechny
vlastnosti najednou. Tentýž uživatel načtený dvakrát z databáze sice projde, ale
jakmile jedna z instancí změní e-mail, == ji označí za jinou entitu, přestože
identita zůstala. Operátor === zase porovnává instance v paměti. V jednom
EntityManageru vrací Doctrine pro stejné ID díky Identity Map tutéž instanci. Entita
získaná dvakrát nezávisle na sobě (jednou deserializací ze zprávy, podruhé z databáze,
nebo znovu načtená po $em->clear()) ale existuje jako dvě instance a === vrátí
false, i když jde o tutéž doménovou entitu.
Vznik identity
Ukázka User dostane UserId konstruktorem a neřeší, odkud se vzal. Vernon
v Implementing Domain-Driven Design (2013) vypisuje čtyři cesty vzniku identity.
Hodnotu dodá uživatel (User Provides Identity), vygeneruje ji aplikace
(Application Generates Identity), vygeneruje ji persistence (Persistence Mechanism
Generates Identity), nebo ji přiřadí jiný Bounded Context (Another Bounded Context
Assigns Identity) [4].
Kniha volí druhou z nich. Agregát, který identifikátor dostane až od databáze, ho při
vzniku nemá. Nemůže tedy zaznamenat událost o svém vzniku a jiný agregát se na něj
nemůže odkázat. Matthias Noback dochází ke stejnému závěru a doporučuje ID vytvořit
dřív, než objekt vznikne
[5].
Identifikátory v této knize proto vznikají přes Uuid::v7() z balíčku symfony/uid.
Dokumentace tuto verzi doporučuje místo verzí 1 a 6 kvůli lepší entropii a přísnějšímu
chronologickému řazení [6].
1<?php2 3declare(strict_types=1);4 5namespace App\UserManagement\Domain\ValueObject;6 7use Symfony\Component\Uid\Uuid;8 9final readonly class UserId10{11 public function __construct(12 public string $value,13 ) {14 if (!Uuid::isValid($value)) {15 throw new \InvalidArgumentException('UserId must be a valid UUID');16 }17 }18 19 public static function generate(): self20 {21 return new self((string) Uuid::v7());22 }23 24 public static function fromString(string $value): self25 {26 return new self($value);27 }28 29 // Doctrine převádí identitu na řetězec při každém persist().30 // Bez __toString() spadne už uložení – viz kapitola o implementaci.31 public function __toString(): string32 {33 return $this->value;34 }35 36 public function equals(self $other): bool37 {38 return $this->value === $other->value;39 }40}
Ostatní identifikátory v knize mají stejný tvar a liší se jen jménem a chybovou hláškou. Protože se objevují průběžně, jsou zde pohromadě:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\ValueObject;6 7use Symfony\Component\Uid\Uuid;8 9// Identita kanonického agregátu Order. Doctrine ji při každém persist()10// převádí na řetězec, takže __toString() tu není kosmetika - bez něj11// skončí uložení hláškou "could not be converted to string".12final readonly class OrderId13{14 public function __construct(public string $value)15 {16 if (!Uuid::isValid($value)) {17 throw new \InvalidArgumentException('OrderId must be a valid UUID');18 }19 }20 21 public static function generate(): self { return new self((string) Uuid::v7()); }22 23 public static function fromString(string $value): self { return new self($value); }24 25 public function equals(self $other): bool { return $this->value === $other->value; }26 27 public function __toString(): string { return $this->value; }28}29 30final readonly class CustomerId31{32 public function __construct(public string $value)33 {34 if (!Uuid::isValid($value)) {35 throw new \InvalidArgumentException('CustomerId must be a valid UUID');36 }37 }38 39 public static function generate(): self { return new self((string) Uuid::v7()); }40 41 public static function fromString(string $value): self { return new self($value); }42 43 public function equals(self $other): bool { return $this->value === $other->value; }44 45 public function __toString(): string { return $this->value; }46}47 48final readonly class ProductId49{50 public function __construct(public string $value)51 {52 if (!Uuid::isValid($value)) {53 throw new \InvalidArgumentException('ProductId must be a valid UUID');54 }55 }56 57 public static function generate(): self { return new self((string) Uuid::v7()); }58 59 public static function fromString(string $value): self { return new self($value); }60 61 public function equals(self $other): bool { return $this->value === $other->value; }62 63 public function __toString(): string { return $this->value; }64}
Opakování je záměrné. Sdílený předek by ušetřil řádky, ale oslabil by typovou kontrolu.
Metoda equals(self $other) zděděná z předka přijme kteréhokoli potomka, takže porovnání
CustomerId s ProductId projde bez chyby. Totéž platí pro každou signaturu, která
přijímá předka. Právě tomu mají typované identifikátory zabránit. OrderId v rozepsané
podobě ukazuje i kapitola Návrh agregátu.
Přirozený identifikátor je také přípustný. Evans obě možnosti výslovně připouští: identita může přijít zvenčí, nebo jde o umělou hodnotu vytvořenou systémem pro systém [3]. Ani rodné číslo, ISBN nebo IČO ale nejsou tak stálé, jak vypadají: mění se, bývají přidělené chybně nebo duplicitně. Kdo je použije jako primární identitu agregátu, zdědí všechny jejich výjimky. Bezpečnější je držet umělé ID a přirozený klíč vést jako běžný atribut s unikátním indexem.
06.04 Hodnotové objekty (Value Objects)#
Dva e-maily se stejným textem nejsou „dvě adresy“, ale jedna hodnota.
Hodnotový objekt nemá identitu, záleží jen na jeho atributech. Evans ho proto doporučuje
držet neměnný (immutable)
[3].
Porovnává se po hodnotě, ne po referenci.
Pro hodnotové objekty mluví i praktický důvod. Rozsypaná primitiva string $email,
int $priceInCents a string $currency jsou code smell, který Fowler pojmenoval
Primitive Obsession
[7]; ukázky před opravou a po ní má
kapitola Anti-vzory a typické chyby.
1<?php2 3declare(strict_types=1);4 5namespace App\UserManagement\Domain\ValueObject;6 7final readonly class Email8{9 public function __construct(10 public string $value,11 ) {12 if (!filter_var($value, FILTER_VALIDATE_EMAIL)) {13 throw new \InvalidArgumentException('Invalid email address');14 }15 }16 17 public static function fromUserInput(string $raw): self18 {19 // Normalizace vstupu (lowercase, trim) patří sem, ne do konstruktoru.20 return new self(mb_strtolower(trim($raw)));21 }22 23 public function equals(self $other): bool24 {25 return $this->value === $other->value;26 }27 28 public function __toString(): string29 {30 return $this->value;31 }32}
Email drží jediný řetězec jako public readonly vlastnost; getter by jen přidával
šum. Formát hlídá konstruktor, normalizaci vstupu z formulářů pojmenovaná factory
fromUserInput(). Třída nemá ID ani settery a dva e-maily se shodují právě tehdy, když
mají stejnou hodnotu. Třída je final readonly, takže ji nikdo nezdědí ani po
vytvoření nezmění.
Money a Currency
Druhý hodnotový objekt, ke kterému se kniha opakovaně vrací, skládá víc hodnot.
Money spojuje částku a měnu do pojmu, který nejde rozpojit.
1<?php2 3declare(strict_types=1);4 5// Peníze používá Ordering, Billing i Pricing – proto Shared Kernel,6// stejně jako AggregateRoot, ne doménová složka jednoho kontextu.7namespace App\SharedKernel\Domain;8 9enum Currency: string10{11 case CZK = 'CZK';12 case EUR = 'EUR';13 case USD = 'USD';14}15 16final readonly class Money17{18 public function __construct(19 public int $amountInCents,20 public Currency $currency,21 ) {22 if ($amountInCents < 0) {23 throw new \InvalidArgumentException('Money cannot be negative');24 }25 }26 27 public static function zero(Currency $currency): self28 {29 return new self(0, $currency);30 }31 32 public function add(self $other): self33 {34 if ($this->currency !== $other->currency) {35 // Vědomá zkratka: v Shared Kernelu by pojmenovaná výjimka znamenala36 // další sdílený typ. Uvnitř kontextu patří pojmenovaná třída.37 throw new \DomainException(38 "Cannot add {$this->currency->value} and {$other->currency->value}"39 );40 }41 42 return new self($this->amountInCents + $other->amountInCents, $this->currency);43 }44 45 public function subtract(self $other): self46 {47 if ($this->currency !== $other->currency) {48 // Stejná zkratka jako v add().49 throw new \DomainException(50 "Cannot subtract {$other->currency->value} from {$this->currency->value}"51 );52 }53 54 return new self($this->amountInCents - $other->amountInCents, $this->currency);55 }56 57 public function multiply(int $factor): self58 {59 return new self($this->amountInCents * $factor, $this->currency);60 }61 62 /** Procentní podíl. Sazby jsou celá procenta, dělení zaokrouhluje nahoru. */63 public function percentage(int $percent): self64 {65 return new self(intdiv($this->amountInCents * $percent + 99, 100), $this->currency);66 }67 68 public function equals(self $other): bool69 {70 return $this->amountInCents === $other->amountInCents71 && $this->currency === $other->currency;72 }73}
Částka je celé číslo v nejmenších jednotkách měny (haléřích, centech). float by do
peněz vnesl chyby zaokrouhlení, které se projeví až na faktuře. Měnu drží string-backed
enum, takže záměna 'czk' za 'CZK' nepřipadá v úvahu. Sčítání dvou různých měn skončí výjimkou. Je to doménové pravidlo,
ne chyba volajícího. Jakmile tentýž pojem potřebuje víc kontextů, patří Money do
Shared Kernelu (tuto variantu ukazuje Context Mapping).
Validace: kde jaká výjimka
Kniha rozlišuje dvě úrovně validace. Porušení formátu hodnoty
(neplatný e-mail, záporná částka, řetězec, který není UUID) hlásí konstruktor
hodnotového objektu výjimkou \InvalidArgumentException. Takové porušení je
programátorská chyba nebo nevalidní vstup, který měla zachytit už vstupní vrstva.
Porušení byznys pravidla (potvrzení prázdné objednávky, platba nepotvrzené
objednávky) hlásí agregát doménovou výjimkou dědící z \DomainException,
typicky pojmenovanou třídou jako InvalidOrderStateTransitionException.
Hierarchii výjimek po vrstvách rozebírá kapitola
Implementace v Symfony 8.
Obě pravidla vycházejí z jednoho předpokladu: objekt se nesmí ocitnout v nevalidním stavu ani na okamžik. Vladimir Khorikov tomu říká always-valid domain model [10]. Jde o volbu. Opačný přístup posouvá validaci do vstupní vrstvy a doménový objekt nechává „hloupý“. Kniha drží první variantu, protože jen tak je konstruktor zárukou platnosti.
06.05 Agregáty (Aggregates)#
Objednávka má položky, dodací adresu, stav a celkovou částku. Změna položky vyžaduje přepočet částky, storno kontrolu stavu. Když tato pravidla nehlídá jedno místo, rozptýlí se po službách. Agregát je skupina objektů, které se mění jako jeden celek a sdílejí jednu hranici invariantů [3]. Vstup do agregátu vede výhradně přes kořen (Aggregate Root). Ztotožnění této hranice s hranicí transakce je Evansovo doporučení, ne součást definice. Uvnitř agregátu se pravidla vynucují synchronně, přes hranici se změny šíří asynchronně. Vodítko „jeden agregát na transakci“ z toho odvozuje kapitola Návrh agregátu. Špatně zvolená velikost patří mezi nejčastější chyby v DDD; přerostlé „God Aggregates“ rozebírá kapitola Anti-vzory a typické chyby.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Model;6 7// Obě výjimky vypisuje kapitola Implementace v Symfony 8 (10.12);8// zde stačí jejich jména a pojmenované továrny.9use App\Ordering\Domain\Exception\EmptyOrderException;10use App\Ordering\Domain\Exception\InvalidOrderStateTransitionException;11use App\Ordering\Domain\ValueObject\CustomerId;12use App\SharedKernel\Domain\Money;13use App\Ordering\Domain\ValueObject\OrderId;14use App\Ordering\Domain\ValueObject\ProductId;15use App\Ordering\Domain\ValueObject\OrderStatus;16 17final class Order18{19 /** @var list<OrderItem> */20 private array $items = [];21 22 private OrderStatus $status;23 private readonly \DateTimeImmutable $createdAt;24 25 private function __construct(26 // Identita i vlastník jsou veřejné readonly vlastnosti, stejně jako27 // v kanonickém agregátu z kapitoly Návrh agregátu. Zbytek knihy28 // je čte jako `$order->id`, ne přes getter.29 public readonly OrderId $id,30 public readonly CustomerId $customerId,31 ) {32 $this->status = OrderStatus::Draft;33 $this->createdAt = new \DateTimeImmutable();34 }35 36 public static function place(OrderId $id, CustomerId $customerId): self37 {38 return new self($id, $customerId);39 }40 41 public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void42 {43 if ($this->status !== OrderStatus::Draft) {44 throw InvalidOrderStateTransitionException::notAllowedInState(45 'addItem',46 $this->status->value,47 );48 }49 50 $this->items[] = new OrderItem($productId, $quantity, $unitPrice);51 }52 53 public function removeItem(ProductId $productId): void54 {55 if ($this->status !== OrderStatus::Draft) {56 throw InvalidOrderStateTransitionException::notAllowedInState(57 'removeItem',58 $this->status->value,59 );60 }61 62 $this->items = array_values(array_filter(63 $this->items,64 static fn (OrderItem $item): bool => !$item->productId->equals($productId),65 ));66 }67 68 public function confirm(): void69 {70 if ($this->status !== OrderStatus::Draft) {71 throw InvalidOrderStateTransitionException::cannotTransition(72 $this->status->value,73 OrderStatus::Confirmed->value,74 );75 }76 77 if ($this->items === []) {78 throw EmptyOrderException::cannotConfirm();79 }80 81 $this->status = OrderStatus::Confirmed;82 }83 84 // Důvod a čas storna nese událost OrderCancelled (viz Návrh agregátu);85 // tato podoba bez událostí je jen přijímá.86 public function cancel(string $reason, \DateTimeImmutable $when): void87 {88 // Odeslanou ani doručenou zásilku storno nevrátí, zaplacenou objednávku ano.89 if (in_array($this->status, [OrderStatus::Shipped, OrderStatus::Delivered], true)) {90 throw InvalidOrderStateTransitionException::cannotTransition(91 $this->status->value,92 OrderStatus::Cancelled->value,93 );94 }95 96 $this->status = OrderStatus::Cancelled;97 }98 99 public function totalAmount(): Money100 {101 if ($this->items === []) {102 throw EmptyOrderException::cannotBePlaced();103 }104 105 $total = $this->items[0]->unitPrice->multiply($this->items[0]->quantity);106 107 foreach (array_slice($this->items, 1) as $item) {108 $total = $total->add($item->unitPrice->multiply($item->quantity));109 }110 111 return $total;112 }113 114 /** @return list<OrderItem> */115 public function items(): array116 {117 return $this->items;118 }119 120 public function itemCount(): int121 {122 return count($this->items);123 }124 125 // Základní podoba s getterem. Od PHP 8.4 ho nahradí asymetrická126 // viditelnost public private(set), viz kapitola Návrh agregátu.127 public function status(): OrderStatus128 {129 return $this->status;130 }131 132 public function isConfirmed(): bool133 {134 return $this->status === OrderStatus::Confirmed;135 }136 137 public function createdAt(): \DateTimeImmutable138 {139 return $this->createdAt;140 }141}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Model;6 7use App\SharedKernel\Domain\Money;8use App\Ordering\Domain\ValueObject\ProductId;9 10class OrderItem11{12 // Veřejné readonly vlastnosti, ne gettery - stejně jako kanonický13 // OrderItem v kapitole Návrh agregátu. Zbytek knihy je čte přímo.14 public function __construct(15 public readonly ProductId $productId,16 public readonly int $quantity,17 public readonly Money $unitPrice,18 ) {19 if ($quantity <= 0) {20 throw new \InvalidArgumentException('Quantity must be positive');21 }22 }23}
Order je kořen agregátu a drží kolekci OrderItem. Konstruktor je privátní
a instance vzniká pojmenovanou factory Order::place(), takže nikdo nevyrobí
objednávku bez zákazníka a bez počátečního stavu. Vnější volání jdou výhradně přes
metody na Order; OrderItem zvenku nikdo neinstancuje ani nemění.
Každé porušené pravidlo hlásí pojmenovaná výjimka – InvalidOrderStateTransitionException
pro nepovolený přechod stavu, EmptyOrderException pro prázdnou objednávku. Volající se
tak může rozhodnout podle typu, ne podle textu zprávy. Obě třídy, spolu
s OrderNotFoundException z repozitáře níže, vypisuje kapitola
Implementace v Symfony 8; dědí
z \DomainException a chybovou hlášku nese pojmenovaná továrna. Výpočet totalAmount()
přebírá měnu z položek. Sčítání začíná u první z nich a Money::add() při
nesouladu vyhodí výjimku. Objednávka kombinující dvě měny tak neprojde tiše.
Události agregát zatím nezaznamenává. Předka AggregateRoot a volání record()
doplní sekce o životním cyklu. OrderItem je zde
záměrně zjednodušený na neměnný záznam bez odkazu zpět na objednávku; identitu mu
uvnitř agregátu stačí dát produkt. Plnou verzi ukazuje kapitola
Návrh agregátu, včetně metody
increaseQuantity() pro invariant „jedna položka na produkt“.
Tato podoba Order nemá perzistenci: položky drží obyčejné pole a na třídě není ani
jeden Doctrine atribut. Model tak jde číst bez znalosti ORM. Verze, kterou opisujete
do projektu, je ta z kapitoly Návrh agregátu.
Přidává další stavové přechody (markPaid(), ship()), Collection místo pole,
mapování a OrderItem s odkazem zpět na objednávku. Bez odkazu by Doctrine neměla co
zapsat do cizího klíče.
06.06 Repozitáře (Repositories)#
Doménová vrstva by neměla vědět, jestli agregát žije v PostgreSQL, MongoDB, nebo v paměti. Tuto neznalost zajišťuje repozitář: pro doménu vypadá jako kolekce agregátů v paměti, skutečné uložení řeší implementace v infrastrukturní vrstvě. Vzor pochází z katalogu Patterns of Enterprise Application Architecture. Edward Hieatt a Rob Mee ho tam popsali jako prostředníka mezi doménou a mapováním dat, který se navenek tváří jako kolekce [11].
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Repository;6 7use App\Ordering\Domain\Exception\OrderNotFoundException;8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\ValueObject\OrderId;10 11interface OrderRepository12{13 public function save(Order $order): void;14 15 /** @throws OrderNotFoundException když objednávka neexistuje */16 public function get(OrderId $id): Order;17}
OrderRepository je záměrně úzký: uložit agregát a načíst ho podle identity.
Chybějící objednávka je chyba volajícího, ne prázdný výsledek, proto get() vrací
Order a hází výjimku místo null. Implementaci si volí infrastruktura – nejčastěji
Doctrine ORM, ale stejně dobře in-memory varianta pro testy. Doctrine variantu rozepisuje
kapitola Implementace v Symfony 8.
Tři pravidla oddělují repozitář od obyčejné servisní třídy nad databází:
- Jeden repozitář na kořen agregátu, ne na každou entitu.
OrderItemvlastní repozitář nemá, načítá se a ukládá jako součást objednávky. - Rozhraní vrací sestavené agregáty, ne řádky ani asociativní pole. Tím se repozitář liší od DAO, které mluví v pojmech tabulek a nabízí nad nimi CRUD.
- Dotazy pro obrazovky, třeba „všechny objednávky zákazníka“, sem nepatří. Obsluhuje je read model, který rozebírá kapitola CQRS.
Metoda save() se vědomě odchyluje od původní představy repozitáře jako kolekce.
Vernon rozlišuje dvě podoby.
Collection-oriented repozitář se chová jako kolekce (add(), remove()) a spoléhá
na to, že persistence sleduje změny sama. Persistence-oriented varianta s metodou
save() se hodí tam, kde úložiště změny nesleduje
[4].
Doctrine změny sleduje, takže by první podoba obstála. Explicitní save() je přesto
čitelnější: v kódu je vidět, kde se zápis odehrává.
06.07 Doménové služby (Domain Services)#
Některá pravidla nepatří jednomu agregátu ani jednomu hodnotovému objektu. Koordinují více objektů nebo zachycují proces, který nemá vlastníka. Takovou logiku přebírá doménová služba: nedrží stav, nemá životní cyklus a pracuje s entitami a hodnotovými objekty, které dostane.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Service;6 7use App\Ordering\Domain\Model\Customer;8use App\Ordering\Domain\Model\Order;9use App\SharedKernel\Domain\Currency;10use App\SharedKernel\Domain\Money;11 12final class ShippingFeeService13{14 // Počítají se řádky objednávky, ne kusy zboží.15 private const int FREE_SHIPPING_FROM_LINES = 5;16 private const int FLAT_FEE_CENTS = 99_00;17 18 public function feeFor(Order $order, Customer $customer): Money19 {20 $freeShipping = $customer->isVip()21 || count($order->items()) >= self::FREE_SHIPPING_FROM_LINES;22 23 return $freeShipping24 ? Money::zero(Currency::CZK)25 : new Money(self::FLAT_FEE_CENTS, Currency::CZK);26 }27}28 29// --- src/Ordering/Domain/Model/Customer.php ---30namespace App\Ordering\Domain\Model;31 32use App\Ordering\Domain\ValueObject\CustomerId;33 34// Ilustrační výřez: agregát Customer kniha dál nerozvádí, službě stačí35// věrnostní status. Skutečný model by ho odvozoval z historie nákupů.36final class Customer37{38 public function __construct(39 public readonly CustomerId $id,40 private bool $vip = false,41 ) {}42 43 public function isVip(): bool44 {45 return $this->vip;46 }47}
Pravidlo „doprava zdarma pro VIP zákazníky a velké objednávky“ čte data dvou
agregátů: Customer a Order. Nepatří ani jednomu z nich – Customer o dopravném
nic neví a Order nezná věrnostní status zákazníka. ShippingFeeService proto obě
znalosti spojuje na jednom místě, bez stavu a bez závislosti na repozitáři či databázi.
Evans popisuje službu jako operaci, kterou model nabízí samostatným rozhraním. Ukázka
z něj nedělá PHP interface, protože implementace je jediná a předčasná abstrakce by
přidala jen soubor navíc.
Přibude-li druhý způsob výpočtu nebo potřeba službu v testu nahradit, lze rozhraní
ShippingFeeCalculator doplnit kdykoli.
06.08 Doménové události (Domain Events)#
„Objednávka byla potvrzena.“ „Platba byla přijata.“ Doménová událost je neměnný záznam o něčem, co se v doméně stalo a o čem doménoví experti chtějí vědět. Evans k tomu dodává, že událost obvykle nese časové razítko a identitu zúčastněných entit [3]. Název je proto vždy v minulém čase – popisuje hotovou věc, ne příkaz.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Event;6 7use App\Ordering\Domain\ValueObject\CustomerId;8use App\Ordering\Domain\ValueObject\OrderId;9 10final readonly class OrderPlaced11{12 public \DateTimeImmutable $occurredAt;13 14 public function __construct(15 public OrderId $orderId,16 public CustomerId $customerId,17 ) {18 $this->occurredAt = new \DateTimeImmutable();19 }20}
OrderPlaced nese tři údaje: které objednávky se týká, kterého zákazníka
a kdy k události došlo. Vlastnosti jsou veřejné a readonly, protože událost je neměnný
záznam a příjemci ji jen čtou.
Kolik dat do události patří, je rozhodnutí, ne pravidlo. Tenká událost nese
identifikátory a zbytek si příjemce dotáhne sám; tlustá veze celý stav a příjemce se
už na nic ptát nemusí. Druhou podobu Fowler pojmenoval Event-Carried State Transfer
a řadí ji vedle prosté notifikace, Event Sourcingu a CQRS jako jednu ze čtyř variant
event-driven architektury
[12]. Uvnitř jednoho
kontextu se osvědčí tenká varianta, jakou ukazuje OrderPlaced: příjemce má
k agregátu přístup a duplikovaná data by se dřív nebo později rozešla.
Hranice kontextu rozděluje události na doménové a integrační. Doménová událost mluví
jazykem Ordering a zůstává uvnitř. Integrační je kontrakt pro cizí kontexty
a mění se jen tak rychle, jak její příjemci snesou
[13].
Poslat OrderPlaced ven proto znamená zveřejnit vnitřní model se vším, co z toho
plyne. Překlad na stabilní kontrakt řeší
Published Language. Spolehlivé doručení
a idempotenci na straně příjemce rozebírá Outbox Pattern,
protože Messenger doručuje at-least-once.
Domain Events tvoří základ pro dvě architektonické techniky: oddělení čtení a zápisu v CQRS a uložení stavu jako sekvence událostí v Event Sourcingu.
06.09 Agregát a doménové události: lifecycle#
Cesta události k příjemcům má dva kroky. Agregát ji zaznamená ve chvíli, kdy se změna stane – uvnitř doménové metody. Aplikační vrstva ji publikuje až poté, co se změna uložila. Mezi oběma kroky drží události bázová třída kořene agregátu:
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain;6 7abstract class AggregateRoot8{9 /** @var list<object> */10 private array $domainEvents = [];11 12 final protected function record(object $event): void13 {14 $this->domainEvents[] = $event;15 }16 17 /** @return list<object> */18 final public function releaseEvents(): array19 {20 $events = $this->domainEvents;21 $this->domainEvents = [];22 23 return $events;24 }25}
Agregát Order ze sekce o agregátech z této třídy dědí. Výřez ukazuje
jeho place(), addItem() a confirm() doplněné o volání record():
1final class Order extends AggregateRoot2{3 // ... vlastnosti a metody ze sekce 06.05 ...4 5 public static function place(OrderId $id, CustomerId $customerId): self6 {7 $order = new self($id, $customerId);8 $order->record(new OrderPlaced($id, $customerId));9 10 return $order;11 }12 13 public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void14 {15 // ... kontrola stavu ze sekce 06.05 ...16 $this->items[] = new OrderItem($productId, $quantity, $unitPrice);17 $this->record(new OrderItemAdded($this->id, $productId, $quantity));18 }19 20 public function confirm(): void21 {22 if ($this->status !== OrderStatus::Draft) {23 throw InvalidOrderStateTransitionException::cannotTransition(24 $this->status->value,25 OrderStatus::Confirmed->value,26 );27 }28 29 if ($this->items === []) {30 throw EmptyOrderException::cannotConfirm();31 }32 33 $this->status = OrderStatus::Confirmed;34 $this->record(new OrderConfirmed($this->id, $this->customerId, new \DateTimeImmutable()));35 }36}
OrderItemAdded a OrderConfirmed jsou obdoby OrderPlaced z předchozí sekce; jejich
definice vypisuje Návrh agregátu. Volání record()
stojí v pojmenované factory a v doménových metodách, nikdy v __construct. Důvodem je
reconstitution, tedy sestavení agregátu z uložených dat. Doctrine při
hydrataci konstruktor obchází, ruční Order::reconstitute() ho ale volá – a kdyby
v něm record() byl, každé načtení objednávky by znovu ohlásilo její vznik.
Reconstitution jako zvláštní typ factory rozebírají
Doplňující taktické vzory.
Druhou polovinu životního cyklu obstará command handler. Uloží agregát
a teprve potom vyzvedne zaznamenané události přes releaseEvents():
1$order = Order::place(OrderId::generate(), $customerId);2 3$this->orders->save($order); // jen persist agregátu4$this->em->flush(); // zápis SQL; commit řídí doctrine_transaction middleware5 6foreach ($order->releaseEvents() as $event) {7 $this->eventBus->dispatch($event);8}
Middleware doctrine_transaction otevře transakci před handlerem a commituje ji až
po jeho návratu. flush() tedy sám nic nepotvrzuje a dispatch běží uvnitř otevřené
transakce. Synchronním posluchačům ve stejném procesu to nevadí: spadne-li transakce,
zmizí i jejich zápisy. Co opouští proces (broker, e-mail, cizí služba), jde přes
Outbox.
Pořadí „nejdřív uložit, pak publikovat“ je volba knihy, ne jediná možnost. Publikace
před flushem by příjemcům oznámila změnu, kterou databáze mohla odmítnout. Bez
middlewaru, kdy commit obstará sám flush(), hrozí opak: když proces spadne mezi
uložením a publikací, událost se ztratí. Zadarmo není ani jedna varianta.
Dispatch uvnitř transakce hájí i Jimmy Bogard. Vedlejší efekty podle něj patří
do téže logické transakce jako změna, která je vyvolala
[14].
Kdo chce doručení naopak odložit, má v Symfony middleware dispatch_after_current_bus a stamp
DispatchAfterCurrentBusStamp, který zprávu pustí až po úspěšném dokončení aktuálního
handleru [15].
Riziko ztracené události odstraní až transakční outbox: událost i změna agregátu
se zapíšou jednou transakcí. Plné zapojení do Symfony (repozitář, event bus přes
Messenger) popisuje kapitola
Implementace v Symfony 8, outbox potom
Outbox Pattern.
Časté otázky
Jaký je rozdíl mezi Entitou a Value Objectem?
Entita má jednoznačnou identitu (ID), která ji odlišuje od ostatních instancí i tehdy, sdílejí-li stejné atributy. Dva uživatelé se stejným jménem a e-mailem jsou stále dvě různé entity. Value Object identitu nemá a porovnává se podle hodnot všech svých atributů – typické příklady jsou Money, Address, Email. Entitu lze v čase měnit, Value Object je zpravidla neměnný. Srovnání obou konceptů v sekci o Entitách a sekci o Value Objects.
K čemu slouží Hodnotový objekt (Value Object)?
Hodnotový objekt zapouzdřuje doménový koncept, který určují pouze jeho hodnoty, nikoli identita – například peněžní částka s měnou, rozsah kalendářních dní nebo e-mailová adresa. Umožňuje přesunout pravidla platnosti a doménové chování blízko dat, která popisují, a eliminuje tzv. Primitive Obsession (používání primitivních typů tam, kde patří doménový pojem). Neměnnost Value Objectu zjednodušuje uvažování o kódu i paralelním přístupu. Více v sekci o Hodnotových objektech.
Co je Agregát a proč je jeho hranice důležitá?
Agregát je skupina doménových objektů, které se mění jako jeden celek. Přístup k jeho vnitřním částem vede výhradně přes kořenovou entitu (Aggregate Root). Hranice agregátu bývá zároveň hranicí transakční konzistence: co je uvnitř, musí být po každé operaci ve validním stavu. Správně vymezený agregát brání porušení doménových invariantů a ulehčuje rozhodování o tom, co lze měnit souběžně. Podrobný rozbor v sekci o Agregátech.
K čemu slouží Repozitář v DDD?
Repozitář poskytuje doménové vrstvě rozhraní podobné kolekci pro ukládání a načítání agregátů, aniž by doména musela znát konkrétní persistenční technologii. Skutečné uložení do databáze obstará infrastrukturní vrstva, která rozhraní implementuje. Díky tomu lze testovat doménu proti in-memory repozitáři a nahradit úložiště bez zásahu do doménových pravidel. Více v sekci o Repozitářích.
Kdy použít Doménovou službu místo metody na Entitě?
Doménová služba se hodí tam, kde operace přirozeně nepatří žádné Entitě ani Value Objectu. Koordinuje více agregátů nebo počítá nad daty, která dostane. Vstup z externího systému jí dodá volající, případně doménové rozhraní implementované v infrastruktuře. Když chování přirozeně patří do metody entity, má metoda přednost. Doménová služba není aplikační koordinátor; drží doménovou logiku bez stavu. Rozbor a typické případy užití v sekci o Doménových službách.
Co je Doménová událost a k čemu slouží?
Doménová událost je neměnný záznam o tom, že se v doméně stalo něco podstatného – například „objednávka byla potvrzena“ nebo „platba byla přijata“. Události umožňují oddělit části systému, které reagují na změny, od částí, které změny vyvolávají: místo přímého volání se publikuje událost a zájemci ji zpracují. V DDD tvoří události také základ pro Event Sourcing. Mezi Bounded Contexty putují až po překladu na integrační události. Detailní rozbor v sekci o Doménových událostech.