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)
Doména větší aplikace nikdy nemluví jediným jazykem. Slovo „zákazník“ má v marketingu jiný význam než ve fakturaci a v expedici jiný než v reklamacích. Ohraničený kontext je explicitně vymezená oblast, uvnitř které platí jeden konzistentní model a jeden slovník [1]. Různé kontexty mají různé modely – to je záměr, ne nedostatek.
src/
├── OrderManagement/ # Ohraničený kontext: Správa objednávek
│ ├── Domain/
│ │ ├── Model/
│ │ │ ├── Order.php
│ │ │ ├── OrderItem.php
│ │ │ └── OrderStatus.php
│ │ ├── ValueObject/
│ │ │ ├── OrderId.php
│ │ │ └── Money.php
│ │ ├── Event/
│ │ │ └── OrderCreated.php
│ │ └── Repository/
│ │ └── OrderRepository.php
│ └── Application/
│ ├── Command/
│ │ ├── CreateOrder.php
│ │ └── CreateOrderHandler.php
│ └── Query/
│ ├── GetOrder.php
│ └── GetOrderHandler.php
└── UserManagement/ # Ohraničený kontext: Správa uživatelů
├── Domain/
│ ├── Model/
│ │ ├── User.php
│ │ └── UserStatus.php
│ ├── ValueObject/
│ │ ├── UserId.php
│ │ └── Email.php
│ ├── Event/
│ │ └── UserRegistered.php
│ └── Repository/
│ └── UserRepository.php
└── Application/
├── Command/
│ ├── RegisterUser.php
│ └── RegisterUserHandler.php
└── Query/
├── GetUser.php
└── GetUserHandler.php
OrderManagement a UserManagement jsou ve výše uvedené ukázce dva oddělené kontexty.
Každý má svůj model a jazyk. OrderManagement reprezentuje uživatele pouze
jako UserId; UserManagement ho modeluje jako plnohodnotnou entitu User.
Kompletní příklad rozdělení reálného systému do pěti bounded contexts je v kapitole
Případová studie – Doménová analýza.
06.02 Všudypřítomný jazyk (Ubiquitous Language)
Pokud vývojář říká „uživatel“, produktový manažer „klient“ a obchod „lead“, mluví o téže osobě třemi termíny. Tři termíny znamenají tři různé představy o jejím chování. Všudypřítomný jazyk tuto trhlinu uzavírá: vývojáři a doménoví experti se domluví na jediném slovníku, který pak důsledně používají v kódu, dokumentaci i běžné konverzaci [2].
Tyto pojmy se objevují stejně v kódu, dokumentaci i e-mailu od PM. Pokud kód mluví
o Customer a produktový tým o „uživateli“, slovník selhal a je potřeba ho srovnat.
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á.
<?php
declare(strict_types=1);
namespace App\UserManagement\Domain\Model;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\UserId;
class User
{
private readonly UserId $id;
private string $name;
private Email $email;
private readonly \DateTimeImmutable $createdAt;
public function __construct(UserId $id, string $name, Email $email)
{
$this->id = $id;
$this->name = $name;
$this->email = $email;
$this->createdAt = new \DateTimeImmutable();
}
public function id(): UserId
{
return $this->id;
}
public function name(): string
{
return $this->name;
}
public function email(): Email
{
return $this->email;
}
public function changeName(string $name): void
{
$this->name = $name;
}
public function changeEmail(Email $email): void
{
$this->email = $email;
}
public function createdAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}
User je v ukázce entita definovaná UserId. Uživatel může změnit jméno
i e-mail, identifikátor zůstává stejný.
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.
<?php
declare(strict_types=1);
namespace App\UserManagement\Domain\ValueObject;
class Email
{
private readonly string $value;
public function __construct(string $value)
{
$normalized = mb_strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email address');
}
$this->value = $normalized;
}
public function value(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
Email v ukázce nese pouze normalizovaný řetězec a metodu equals(). Žádné
ID, žádné settery. Dva e-maily se shodují právě tehdy, když mají stejnou hodnotu.
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.
<?php
declare(strict_types=1);
namespace App\OrderManagement\Domain\Model;
use App\OrderManagement\Domain\ValueObject\Currency;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\ProductId;
use App\OrderManagement\Domain\ValueObject\UserId;
final class Order
{
private readonly OrderId $id;
private readonly UserId $userId;
private array $items = [];
private OrderStatus $status;
private readonly \DateTimeImmutable $createdAt;
public function __construct(OrderId $id, UserId $userId)
{
$this->id = $id;
$this->userId = $userId;
$this->status = OrderStatus::CREATED;
$this->createdAt = new \DateTimeImmutable();
}
public function id(): OrderId
{
return $this->id;
}
public function userId(): UserId
{
return $this->userId;
}
public function addItem(ProductId $productId, int $quantity, Money $price): void
{
if ($this->status !== OrderStatus::CREATED) {
throw new \DomainException('Cannot add items to a non-created order');
}
$this->items[] = new OrderItem($this->id, $productId, $quantity, $price);
}
public function removeItem(ProductId $productId): void
{
if ($this->status !== OrderStatus::CREATED) {
throw new \DomainException('Cannot remove items from a non-created order');
}
$this->items = array_filter($this->items, function (OrderItem $item) use ($productId) {
return !$item->productId()->equals($productId);
});
}
public function confirm(): void
{
if ($this->status !== OrderStatus::CREATED) {
throw new \DomainException('Cannot confirm a non-created order');
}
if (empty($this->items)) {
throw new \DomainException('Cannot confirm an empty order');
}
$this->status = OrderStatus::CONFIRMED;
}
public function cancel(): void
{
if ($this->status !== OrderStatus::CREATED && $this->status !== OrderStatus::CONFIRMED) {
throw new \DomainException('Cannot cancel a non-created or non-confirmed order');
}
$this->status = OrderStatus::CANCELLED;
}
public function totalAmount(): Money
{
$total = new Money(0, Currency::CZK);
foreach ($this->items as $item) {
$total = $total->add($item->unitPrice()->multiply($item->quantity()));
}
return $total;
}
public function items(): array
{
return $this->items;
}
public function status(): OrderStatus
{
return $this->status;
}
public function createdAt(): \DateTimeImmutable
{
return $this->createdAt;
}
}
<?php
declare(strict_types=1);
namespace App\OrderManagement\Domain\Model;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\ProductId;
class OrderItem
{
private readonly OrderId $orderId;
private readonly ProductId $productId;
private readonly int $quantity;
private readonly Money $unitPrice;
public function __construct(OrderId $orderId, ProductId $productId, int $quantity, Money $unitPrice)
{
if ($quantity <= 0) {
throw new \DomainException('Množství musí být kladné.');
}
$this->orderId = $orderId;
$this->productId = $productId;
$this->quantity = $quantity;
$this->unitPrice = $unitPrice;
}
public function productId(): ProductId { return $this->productId; }
public function quantity(): int { return $this->quantity; }
public function unitPrice(): Money { return $this->unitPrice; }
}
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í.
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ě.
<?php
declare(strict_types=1);
namespace App\OrderManagement\Domain\Repository;
use App\OrderManagement\Domain\Model\Order;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\UserId;
interface OrderRepository
{
public function save(Order $order): void;
public function findById(OrderId $id): ?Order;
public function findByUserId(UserId $userId): array;
}
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. Doménová služba je bezstavové místo, kam takovou logiku umístit. Nedrží stav, nemá životní cyklus, jen pracuje s entitami a hodnotovými objekty.
<?php
declare(strict_types=1);
namespace App\OrderManagement\Domain\Service;
use App\OrderManagement\Domain\Model\Order;
use App\OrderManagement\Domain\Model\OrderStatus;
use App\OrderManagement\Domain\Model\Payment;
use App\OrderManagement\Domain\ValueObject\PaymentId;
use App\OrderManagement\Domain\ValueObject\PaymentMethod;
class PaymentService
{
public function processPayment(Order $order, PaymentMethod $paymentMethod): Payment
{
if ($order->status() !== OrderStatus::CONFIRMED) {
throw new \DomainException('Cannot process payment for a non-confirmed order');
}
return new Payment(
new PaymentId(),
$order->id(),
$order->totalAmount(),
$paymentMethod
);
}
}
PaymentService v ukázce zapouzdřuje logiku zpracování platby a vrací nový objekt
Payment. Repozitář ani databázi nezná – uložení vraceného agregátu řeší volající
vrstva (Application Service nebo Command Handler). Třídy Payment, PaymentId
a PaymentMethod patří do doménového modelu plateb a řídí se stejnými principy jako
ostatní entity a hodnotové objekty v této kapitole.
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.
<?php
declare(strict_types=1);
namespace App\OrderManagement\Domain\Event;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\UserId;
final class OrderCreatedEvent
{
private readonly OrderId $orderId;
private readonly UserId $userId;
private readonly \DateTimeImmutable $occurredAt;
public function __construct(OrderId $orderId, UserId $userId)
{
$this->orderId = $orderId;
$this->userId = $userId;
$this->occurredAt = new \DateTimeImmutable();
}
public function orderId(): OrderId
{
return $this->orderId;
}
public function userId(): UserId
{
return $this->userId;
}
public function occurredAt(): \DateTimeImmutable
{
return $this->occurredAt;
}
}
OrderCreatedEvent v ukázce nese tři údaje: které objednávky se týká, kterého
uživatele 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. 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.
Č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.