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 [3]. Atributy se v čase mění – jméno, adresa, e-mail – 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 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.
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.
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í.
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.
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 tvoří jednu transakční hranici konzistence [3]. Vstup do agregátu vede výhradně přes kořen (Aggregate Root). Š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\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 public 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 function id(): OrderId30 {31 return $this->id;32 }33 34 public function customerId(): CustomerId35 {36 return $this->customerId;37 }38 39 public function addItem(ProductId $productId, int $quantity, Money $price): void40 {41 if ($this->status !== OrderStatus::Created) {42 throw new \DomainException('Cannot add items to a non-created order');43 }44 45 $this->items[] = new OrderItem($this->id, $productId, $quantity, $price);46 }47 48 public function removeItem(ProductId $productId): void49 {50 if ($this->status !== OrderStatus::Created) {51 throw new \DomainException('Cannot remove items from a non-created order');52 }53 54 $this->items = array_values(array_filter(55 $this->items,56 static fn (OrderItem $item): bool => !$item->productId()->equals($productId),57 ));58 }59 60 public function confirm(): void61 {62 if ($this->status !== OrderStatus::Created) {63 throw new InvalidOrderStateTransitionException('Cannot confirm a non-created order');64 }65 66 if ($this->items === []) {67 throw new \DomainException('Cannot confirm an empty order');68 }69 70 $this->status = OrderStatus::Confirmed;71 }72 73 public function cancel(): void74 {75 if ($this->status !== OrderStatus::Created && $this->status !== OrderStatus::Confirmed) {76 throw new \DomainException('Cannot cancel a non-created or non-confirmed order');77 }78 79 $this->status = OrderStatus::Cancelled;80 }81 82 public function totalAmount(): Money83 {84 if ($this->items === []) {85 throw new \DomainException('Cannot calculate total of an empty order');86 }87 88 $total = $this->items[0]->unitPrice()->multiply($this->items[0]->quantity());89 90 foreach (array_slice($this->items, 1) as $item) {91 $total = $total->add($item->unitPrice()->multiply($item->quantity()));92 }93 94 return $total;95 }96 97 /** @return list<OrderItem> */98 public function items(): array99 {100 return $this->items;101 }102 103 public function status(): OrderStatus104 {105 return $this->status;106 }107 108 public function createdAt(): \DateTimeImmutable109 {110 return $this->createdAt;111 }112}
1<?php2 3declare(strict_types=1);4 5namespace App\OrderManagement\Domain\Model;6 7use App\OrderManagement\Domain\ValueObject\Money;8use App\OrderManagement\Domain\ValueObject\OrderId;9use App\OrderManagement\Domain\ValueObject\ProductId;10 11class OrderItem12{13 public function __construct(14 private readonly OrderId $orderId,15 private readonly ProductId $productId,16 private readonly int $quantity,17 private readonly Money $unitPrice,18 ) {19 if ($quantity <= 0) {20 throw new \InvalidArgumentException('Množství musí být kladné.');21 }22 }23 24 public function productId(): ProductId { return $this->productId; }25 public function quantity(): int { return $this->quantity; }26 public function unitPrice(): Money { return $this->unitPrice; }27}
Order v ukázce je kořen agregátu a drží kolekci OrderItem objektů. 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. OrderItem je zde záměrně
zjednodušený na neměnný záznam. 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ě.
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 public function findByCustomerId(CustomerId $customerId): array;18}
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.
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\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.
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. Název je vždy v minulém čase. Událost obsahuje všechna data potřebná k popisu změny – nespoléhá na pozdější dotazování zdrojového agregátu.
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. Tolik stačí příjemcům, aby na změnu mohli
reagovat bez dalšího dotazu zpět do OrderManagement. Vlastnosti jsou veřejné
a readonly – událost je neměnný záznam a příjemci ji jen čtou.
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 $recordedEvents = [];11 12 protected function record(object $event): void13 {14 $this->recordedEvents[] = $event;15 }16 17 /** @return list<object> */18 public function releaseEvents(): array19 {20 $events = $this->recordedEvents;21 $this->recordedEvents = [];22 23 return $events;24 }25}
Agregát Order ze sekce o agregátech z této třídy dědí a volá
record() ve svých doménových metodách:
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. 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(); // 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}
Pořadí je závazné. Publikace před flushem by příjemcům oznámila změnu, kterou databáze mohla odmítnout. Dispatch po flushi má ovšem také slabinu: pád procesu mezi uložením a publikací znamená ztracenou událost. Plné zapojení do Symfony (repozitář, event bus přes Messenger) popisuje kapitola Implementace v Symfony 8; spolehlivé publikování přes transakční outbox řeší 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 je 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.