Kapitola 06 · Taktika · Základní koncepty DDD

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.

Autor M. Katuščák
Doba čtení ≈ 18 min
Náročnost mírně pokročilá
Publikováno · Aktualizováno ·
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.

FIG. 06.1-A Ohraničené kontexty

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.

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

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

php src/UserManagement/Domain/Model/User.php
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    private readonly \DateTimeImmutable $createdAt;13 14    public function __construct(15        private readonly UserId $id,16        private string $name,17        private Email $email,18    ) {19        $this->createdAt = new \DateTimeImmutable();20    }21 22    public function id(): UserId23    {24        return $this->id;25    }26 27    public function name(): string28    {29        return $this->name;30    }31 32    public function email(): Email33    {34        return $this->email;35    }36 37    public function changeName(string $name): void38    {39        $this->name = $name;40    }41 42    public function changeEmail(Email $email): void43    {44        $this->email = $email;45    }46 47    public function createdAt(): \DateTimeImmutable48    {49        return $this->createdAt;50    }51 52    public function equals(self $other): bool53    {54        return $this->id->equals($other->id);55    }56}

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í: 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].

php src/UserManagement/Domain/ValueObject/UserId.php
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(Uuid::v7()->toRfc4122());22    }23 24    public function equals(self $other): bool25    {26        return $this->value === $other->value;27    }28}

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.

php src/UserManagement/Domain/ValueObject/Email.php
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 – 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 – 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.

php src/OrderManagement/Domain/ValueObject/Money.php + Currency.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\ValueObject;6 7enum Currency: string8{9    case CZK = 'CZK';10    case EUR = 'EUR';11    case USD = 'USD';12}13 14final readonly class Money15{16    public function __construct(17        public int $amountInCents,18        public Currency $currency,19    ) {20        if ($amountInCents < 0) {21            throw new \InvalidArgumentException('Money cannot be negative');22        }23    }24 25    public static function zero(Currency $currency): self26    {27        return new self(0, $currency);28    }29 30    public function add(self $other): self31    {32        if ($this->currency !== $other->currency) {33            throw new \DomainException(34                "Cannot add {$this->currency->value} and {$other->currency->value}"35            );36        }37 38        return new self($this->amountInCents + $other->amountInCents, $this->currency);39    }40 41    public function multiply(int $factor): self42    {43        return new self($this->amountInCents * $factor, $this->currency);44    }45 46    public function equals(self $other): bool47    {48        return $this->amountInCents === $other->amountInCents49            && $this->currency === $other->currency;50    }51}

Čá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, což je 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 – 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.

php src/OrderManagement/Domain/Model/Order.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Model;6 7use App\OrderManagement\Domain\Exception\InvalidOrderStateTransitionException;8use App\OrderManagement\Domain\ValueObject\CustomerId;9use App\OrderManagement\Domain\ValueObject\Money;10use App\OrderManagement\Domain\ValueObject\OrderId;11use App\OrderManagement\Domain\ValueObject\ProductId;12 13class Order14{15    /** @var list<OrderItem> */16    private array $items = [];17 18    private OrderStatus $status;19    private readonly \DateTimeImmutable $createdAt;20 21    private function __construct(22        private readonly OrderId $id,23        private readonly CustomerId $customerId,24    ) {25        $this->status = OrderStatus::Created;26        $this->createdAt = new \DateTimeImmutable();27    }28 29    public static function place(OrderId $id, CustomerId $customerId): self30    {31        return new self($id, $customerId);32    }33 34    public function id(): OrderId35    {36        return $this->id;37    }38 39    public function customerId(): CustomerId40    {41        return $this->customerId;42    }43 44    public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void45    {46        if ($this->status !== OrderStatus::Created) {47            throw new \DomainException('Cannot add items to a non-created order');48        }49 50        $this->items[] = new OrderItem($productId, $quantity, $unitPrice);51    }52 53    public function removeItem(ProductId $productId): void54    {55        if ($this->status !== OrderStatus::Created) {56            throw new \DomainException('Cannot remove items from a non-created order');57        }58 59        $this->items = array_values(array_filter(60            $this->items,61            static fn (OrderItem $item): bool => !$item->productId()->equals($productId),62        ));63    }64 65    public function confirm(): void66    {67        if ($this->status !== OrderStatus::Created) {68            throw new InvalidOrderStateTransitionException('Cannot confirm a non-created order');69        }70 71        if ($this->items === []) {72            throw new \DomainException('Cannot confirm an empty order');73        }74 75        $this->status = OrderStatus::Confirmed;76    }77 78    public function cancel(): void79    {80        if ($this->status !== OrderStatus::Created && $this->status !== OrderStatus::Confirmed) {81            throw new \DomainException('Cannot cancel a non-created or non-confirmed order');82        }83 84        $this->status = OrderStatus::Cancelled;85    }86 87    public function totalAmount(): Money88    {89        if ($this->items === []) {90            throw new \DomainException('Cannot calculate total of an empty order');91        }92 93        $total = $this->items[0]->unitPrice()->multiply($this->items[0]->quantity());94 95        foreach (array_slice($this->items, 1) as $item) {96            $total = $total->add($item->unitPrice()->multiply($item->quantity()));97        }98 99        return $total;100    }101 102    /** @return list<OrderItem> */103    public function items(): array104    {105        return $this->items;106    }107 108    public function status(): OrderStatus109    {110        return $this->status;111    }112 113    public function createdAt(): \DateTimeImmutable114    {115        return $this->createdAt;116    }117}
php src/OrderManagement/Domain/Model/OrderItem.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Model;6 7use App\OrderManagement\Domain\ValueObject\Money;8use App\OrderManagement\Domain\ValueObject\ProductId;9 10class OrderItem11{12    public function __construct(13        private readonly ProductId $productId,14        private readonly int $quantity,15        private readonly Money $unitPrice,16    ) {17        if ($quantity <= 0) {18            throw new \InvalidArgumentException('Množství musí být kladné.');19        }20    }21 22    public function productId(): ProductId { return $this->productId; }23    public function quantity(): int { return $this->quantity; }24    public function unitPrice(): Money { return $this->unitPrice; }25}

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í. Pojmenovanou výjimku dostal jen přechod stavu v confirm(); ostatní pravidla ukázka zkracuje na holou \DomainException. 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 s chováním – metodou increaseQuantity() pro invariant „jedna položka na produkt“ – ukazuje kapitola Návrh agregátu.

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, kde ho Edward Hieatt a Rob Mee popsali jako prostředníka mezi doménou a mapováním dat, který se navenek tváří jako kolekce [11].

php src/OrderManagement/Domain/Repository/OrderRepository.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Repository;6 7use App\OrderManagement\Domain\Model\Order;8use App\OrderManagement\Domain\ValueObject\CustomerId;9use App\OrderManagement\Domain\ValueObject\OrderId;10 11interface OrderRepository12{13    public function save(Order $order): void;14 15    public function findById(OrderId $id): ?Order;16 17    /** @return list<Order> */18    public function findByCustomerId(CustomerId $customerId): array;19}

OrderRepository v ukázce definuje metody pro ukládání a načítání objednávek. 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í:

  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 sem nepatří. Pro ně vede samostatná cesta, kterou rozebírá CQRS.

Metoda save() je vědomá odchylka od původní formulace. Vernon rozlišuje collection-oriented repozitář, který se chová jako kolekce (add(), remove()) a spoléhá na to, že persistence sleduje změny sama, od persistence-oriented varianty se save() – ta 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.

php src/OrderManagement/Domain/Service/ShippingFeeService.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Service;6 7use App\OrderManagement\Domain\Model\Customer;8use App\OrderManagement\Domain\Model\Order;9use App\OrderManagement\Domain\ValueObject\Currency;10use App\OrderManagement\Domain\ValueObject\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, ShippingFeeCalculator jako rozhraní lze 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.

php src/OrderManagement/Domain/Event/OrderCreated.php
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Event;6 7use App\OrderManagement\Domain\ValueObject\CustomerId;8use App\OrderManagement\Domain\ValueObject\OrderId;9 10final readonly class OrderCreated11{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}

OrderCreated v ukázce nese tři údaje: které objednávky se týká, kterého zákazníka a kdy k vytvoření došlo. Vlastnosti jsou veřejné a readonly – 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 OrderCreated: 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 OrderManagement 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 OrderCreated 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 – Messenger doručuje at-least-once – řeší Outbox Pattern.

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:

php src/SharedKernel/Domain/AggregateRoot.php
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() a confirm() doplněné o volání record():

php src/OrderManagement/Domain/Model/Order.php (výřez)
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 OrderCreated($id, $customerId));9 10        return $order;11    }12 13    public function confirm(): void14    {15        if ($this->status !== OrderStatus::Created) {16            throw new InvalidOrderStateTransitionException('Cannot confirm a non-created order');17        }18 19        if ($this->items === []) {20            throw new \DomainException('Cannot confirm an empty order');21        }22 23        $this->status = OrderStatus::Confirmed;24        $this->record(new OrderConfirmed($this->id));25    }26}

OrderConfirmed je analogická událost k OrderCreated 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():

php src/OrderManagement/Application/Command/CreateOrderHandler.php (výřez)
1$order = Order::place(OrderId::generate(), $customerId);2 3$this->orders->save($order); // jen persist agregátu4$this->em->flush();          // commit – transakci vlastní aplikační vrstva5                             // (v produkci ji obvykle řídí doctrine_transaction middleware)6 7foreach ($order->releaseEvents() as $event) {8    $this->eventBus->dispatch($event);9}

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ý je definován pouze svými hodnotami, nikoli identitou – 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 použije, když 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.