Kapitola 06 · Taktika · Základní koncepty DDD

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.

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

Kapitola ve videokurzu

Celý videokurz
  1. Kapitoly videa a přepis dílu 06a →
  2. Kapitoly videa a přepis dílu 06b →

Př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.

FIG. 06.1-A Ohraničené kontexty

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.

FIG. 06.2-A Všudypřítomný jazyk

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

php src/UserManagement/Domain/Model/User.php (bez perzistence)
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].

php src/UserManagement/Domain/ValueObject/UserId.php (základní podoba)
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ě:

php src/Ordering/Domain/ValueObject/OrderId.php + CustomerId.php + ProductId.php
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.

php src/UserManagement/Domain/ValueObject/Email.php (základní podoba)
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.

php src/SharedKernel/Domain/Money.php + Currency.php
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.

php src/Ordering/Domain/Model/Order.php (bez perzistence)
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}
php src/Ordering/Domain/Model/OrderItem.php
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].

php src/Ordering/Domain/Repository/OrderRepository.php
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í:

  1. Jeden repozitář na kořen agregátu, ne na každou entitu. OrderItem vlastní repozitář nemá, načítá se a ukládá jako součást objednávky.
  2. 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.
  3. 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.

php src/Ordering/Domain/Service/ShippingFeeService.php + Model/Customer.php
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.

php src/Ordering/Domain/Event/OrderPlaced.php
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:

php src/SharedKernel/Domain/AggregateRoot.php (základní podoba)
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():

php src/Ordering/Domain/Model/Order.php (výřez)
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():

php src/Ordering/Application/Handler/PlaceOrderHandler.php (výřez)
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.