Základní koncepty DDD
Domain-Driven Design nabízí sadu stavebních bloků, které pomáhají převést znalosti o doméně do strukturovaného softwarového modelu. Každý z těchto konceptů řeší konkrétní problém – od vymezení hranic mezi částmi systému přes zachycení identity objektů až po komunikaci mezi komponentami.
Obsah kapitoly
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í nedává smysl. Ohraničený kontext je explicitně vymezená oblast, uvnitř které platí jeden konzistentní model a jeden slovník [1]. Různé kontexty proto mají různé modely, a to záměrně. Jde o strategické téma. 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. Tato kapitola s kontexty dál pracuje jen jako s hranicí, uvnitř které žijí taktické stavební bloky.
06.02 Všudypřítomný jazyk (Ubiquitous Language)#
Pokud kód mluví o Customer a produktový tým o „uživateli“, každý rozhovor nad
zadáním začíná překladem – a právě 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#
Co odlišuje uživatele se stejným jménem a stejným e-mailem? Identita. Entita je doménový objekt, který nese vlastní identifikátor a zachovává si ho po celý život. Evans v DDD Reference mluví o objektech, jež drží nit kontinuity a identity napříč celým životním cyklem [3]. Jméno, adresa i e-mail se přitom 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}
User je v ukázce entita, jejíž identitu určuje UserId. Uživatel může změnit jméno
i e-mail, identifikátor zůstává stejný.
Rovnost entit
Dvě entity jsou totožné právě tehdy, když mají stejné ID. Proto equals()
porovnává výhradně identifikátory. Porovnání operátorem == 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 – identita se přitom nezměnila. Operátor === zase porovnává
identitu instance v paměti. Stejný agregát načtený ve dvou různých kontextech
(dva requesty, deserializace ze zprávy) existuje jako dvě instance. === proto
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, kterými identita vzniká.
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ý ohraničený kontext (Another Bounded Context
Assigns Identity) [4].
Tato kniha volí druhou z nich. Důvod je praktický: agregát, který identifikátor dostane
až od databáze, ho při vzniku nemá k dispozici. Nemůže tedy zaznamenat událost o svém
vzniku ani se na sebe odkázat z jiné agregátní hranice. 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 kvůli lepší entropii a 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. Kniha je používá průběžně, proto je uvádíme 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 sice ušetřil řádky, ale zároveň by dovolil předat
ProductId tam, kde se čeká CustomerId – a právě tomu mají typované identifikátory
zabránit. OrderId má identický tvar, plnou verzi ukazuje kapitola
Návrh agregátu.
Přirozený identifikátor je legitimní alternativa. 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]. Rodné číslo, ISBN i IČO se ovšem mění a recyklují – kdo je použije jako primární identitu agregátu, zdědí všechny výjimky, které k nim patří. 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“ – je to jedna a tatáž hodnota.
Hodnotový objekt je doménový pojem, který identifikuje sám sebe celou svou hodnotou,
ne odděleným ID [3].
Z toho plynou dvě vlastnosti: neměnnost (immutable) a rovnost po hodnotě, ne po referenci.
Druhý důvod pro hodnotové objekty je pragmatický. 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 v ukázce drží jediný řetězec jako public readonly vlastnost, protože
getter by jen přidával šum. Formát hlídá konstruktor, normalizaci vstupu
z formulářů obstará pojmenovaná factory fromUserInput(). Žádné ID, žádné
settery: dva e-maily se shodují právě tehdy, když mají stejnou hodnotu. Třída je
final readonly, takže hodnotový objekt nikdo nedědí ani nemění po vytvoření.
Money a Currency
Email drží jedinou hodnotu. Druhý hodnotový objekt, se kterým kniha pracuje napříč
kapitolami, jich skládá víc. 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 throw new \DomainException(36 "Cannot add {$this->currency->value} and {$other->currency->value}"37 );38 }39 40 return new self($this->amountInCents + $other->amountInCents, $this->currency);41 }42 43 public function subtract(self $other): self44 {45 if ($this->currency !== $other->currency) {46 throw new \DomainException(47 "Cannot subtract {$other->currency->value} from {$this->currency->value}"48 );49 }50 51 return new self($this->amountInCents - $other->amountInCents, $this->currency);52 }53 54 public function multiply(int $factor): self55 {56 return new self($this->amountInCents * $factor, $this->currency);57 }58 59 /** Procentní podíl. Sazby jsou celá procenta, dělení zaokrouhluje nahoru. */60 public function percentage(int $percent): self61 {62 return new self(intdiv($this->amountInCents * $percent + 99, 100), $this->currency);63 }64 65 public function equals(self $other): bool66 {67 return $this->amountInCents === $other->amountInCents68 && $this->currency === $other->currency;69 }70}
Částka je celé číslo v haléřích. 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
Konvence této knihy 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 stojí na jedné pozici: objekt se nesmí ocitnout v nevalidním stavu ani na okamžik. Vladimir Khorikov ji nazývá always-valid domain model [10]. Jde o volbu, ne o samozřejmost – protipól 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ěnit položku znamená přepočítat částku; zrušit objednávku znamená překontrolovat stav. Pokud tato pravidla nepatří jednomu strážci, rozsypou se. Agregát je právě tento strážce, tedy 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ě. Pravidlo „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 7use App\Ordering\Domain\Exception\EmptyOrderException;8use App\Ordering\Domain\Exception\InvalidOrderStateTransitionException;9use App\Ordering\Domain\ValueObject\CustomerId;10use App\SharedKernel\Domain\Money;11use App\Ordering\Domain\ValueObject\OrderId;12use App\Ordering\Domain\ValueObject\ProductId;13use App\Ordering\Domain\ValueObject\OrderStatus;14 15class Order16{17 /** @var list<OrderItem> */18 private array $items = [];19 20 private OrderStatus $status;21 private readonly \DateTimeImmutable $createdAt;22 23 private function __construct(24 // Identita i vlastník jsou veřejné readonly vlastnosti, stejně jako25 // v kanonickém agregátu z kapitoly Návrh agregátu. Zbytek knihy26 // je čte jako `$order->id`, ne přes getter.27 public readonly OrderId $id,28 public readonly CustomerId $customerId,29 ) {30 $this->status = OrderStatus::Draft;31 $this->createdAt = new \DateTimeImmutable();32 }33 34 public static function place(OrderId $id, CustomerId $customerId): self35 {36 return new self($id, $customerId);37 }38 39 public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void40 {41 if ($this->status !== OrderStatus::Draft) {42 throw InvalidOrderStateTransitionException::notAllowedInState(43 'přidání položky',44 $this->status->value,45 );46 }47 48 $this->items[] = new OrderItem($productId, $quantity, $unitPrice);49 }50 51 public function removeItem(ProductId $productId): void52 {53 if ($this->status !== OrderStatus::Draft) {54 throw new InvalidOrderStateTransitionException('Cannot remove items from a draft order only');55 }56 57 $this->items = array_values(array_filter(58 $this->items,59 static fn (OrderItem $item): bool => !$item->productId()->equals($productId),60 ));61 }62 63 public function confirm(): void64 {65 if ($this->status !== OrderStatus::Draft) {66 throw new InvalidOrderStateTransitionException('Cannot confirm a non-created order');67 }68 69 if ($this->items === []) {70 throw EmptyOrderException::cannotConfirm();71 }72 73 $this->status = OrderStatus::Confirmed;74 }75 76 public function cancel(): void77 {78 if ($this->status !== OrderStatus::Draft && $this->status !== OrderStatus::Confirmed) {79 throw new InvalidOrderStateTransitionException('Cannot cancel a shipped or cancelled order');80 }81 82 $this->status = OrderStatus::Cancelled;83 }84 85 public function totalAmount(): Money86 {87 if ($this->items === []) {88 throw EmptyOrderException::cannotBePlaced();89 }90 91 $total = $this->items[0]->unitPrice->multiply($this->items[0]->quantity);92 93 foreach (array_slice($this->items, 1) as $item) {94 $total = $total->add($item->unitPrice->multiply($item->quantity));95 }96 97 return $total;98 }99 100 /** @return list<OrderItem> */101 public function items(): array102 {103 return $this->items;104 }105 106 public function itemCount(): int107 {108 return count($this->items);109 }110 111 public function status(): OrderStatus112 {113 return $this->status;114 }115 116 public function isConfirmed(): bool117 {118 return $this->status === OrderStatus::Confirmed;119 }120 121 public function createdAt(): \DateTimeImmutable122 {123 return $this->createdAt;124 }125}
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('Množství musí být kladné.');21 }22 }23}
Order v ukázce je kořen agregátu a drží kolekci OrderItem objektů. Konstruktor je
privátní a instance vzniká pojmenovanou factory Order::place(); nikdo tak 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, vlastní 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. 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 je záměrně bez perzistence: položky drží obyčejné pole a po třídě
není ani jedna Doctrine anotace. Model tak jde číst bez znalosti ORM. Verze, kterou
opisujete do projektu, je ta z kapitoly Návrh agregátu.
Má stejné metody, ale Collection místo pole, mapování a OrderItem s odkazem zpět
na objednávku; jinak 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. Repozitář je rozhraní, které tuto neznalost umožňuje. 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. Dotazy typu
„všechny objednávky zákazníka“ do něj nepatří. Obsluhuje je read model, jak rozvádí
kapitola CQRS. 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. Praktickou implementaci v Symfony 8 popisuje 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 sem nepatří. Pro ně vede samostatná cesta, kterou rozebírá CQRS.
Metoda save() je vědomá odchylka od původní formulace. 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 se save()
přichází na řadu 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, jen pracuje s entitami a hodnotovými objekty.
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 private const int FREE_SHIPPING_FROM_ITEMS = 5;15 private const int FLAT_FEE_CENTS = 99_00;16 17 public function feeFor(Order $order, Customer $customer): Money18 {19 $freeShipping = $customer->isVip()20 || count($order->items()) >= self::FREE_SHIPPING_FROM_ITEMS;21 22 return $freeShipping23 ? Money::zero(Currency::CZK)24 : new Money(self::FLAT_FEE_CENTS, Currency::CZK);25 }26}
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 mluví o službě jako o samostatném rozhraní. Ukázka žádné nezavádí, 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 věci, která se v doméně stala a o které 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 v ukázce nese tři údaje: které objednávky se týká, kterého zákazníka
a kdy vznikla. 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 řeší 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#
Kdo událost vytvoří a kdy se dostane k příjemcům? Odpověď má dvě části. Agregát událost 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():
1class 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 new InvalidOrderStateTransitionException('Cannot confirm a non-created order');24 }25 26 if ($this->items === []) {27 throw EmptyOrderException::cannotConfirm();28 }29 30 $this->status = OrderStatus::Confirmed;31 $this->record(new OrderConfirmed($this->id));32 }33}
OrderConfirmed je analogická událost k OrderPlaced z předchozí sekce. Volání
record() stojí v named constructoru a v doménových metodách, nikdy v __construct.
Na vině 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 nahrané 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 do DB; transakci vlastní aplikační vrstva5 6foreach ($order->releaseEvents() as $event) {7 $this->eventBus->dispatch($event);8}
Pod middlewarem doctrine_transaction je situace jiná. Transakci otevře před
handlerem a commituje ji až po jeho návratu, takže flush() sám nic nepotvrzuje
a dispatch běží uvnitř otevřené transakce. Nasazení middlewaru proto vyžaduje
Outbox, ne dispatch přímo z handleru.
Toto pořadí volí kniha záměrně, není to jediná možnost. Publikace před flushem by příjemcům oznámila změnu, kterou databáze mohla odmítnout. Dispatch po flushi zase o událost přijde, když proces spadne mezi uložením a publikací. Zadarmo není ani jedna varianta.
Druhý tábor události odesílá uvnitř transakce. Jimmy Bogard to opírá o argument, že
vedlejší efekty patří do téže logické transakce jako změna, která je vyvolala
[14].
Symfony pro takové odložení nabízí middleware dispatch_after_current_bus a stamp
DispatchAfterCurrentBusStamp, který doručení posune až za konec 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.
Jakou roli má 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. Pro kód v doménové vrstvě vypadá repozitář jako in-memory kolekce objektů; skutečné uložení do databáze probíhá v infrastrukturní vrstvě, 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ů, komunikuje s externím systémem nebo počítá nad kolekcí objektů. Pokud lze chování přirozeně umístit do metody Entity, má vždy přednost. Doménová služba není datový transfer objekt ani 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 a pro komunikaci mezi Bounded Contexty. Detailní rozbor v sekci o Doménových událostech.