Praktické příklady
Praktické příklady implementace Domain-Driven Design v Symfony 8 na třech zjednodušených projektech – e-commerce, blog a správa uživatelů. Ukázka bounded contexts, doménových modelů a vertikální slice architektury.
Obsah kapitoly
Tato kapitola je shrnující průřez předchozími kapitolami. Tři krátké příklady ukazují, jak vzory z taktického DDD, CQRS a Implementace v Symfony drží pohromadě jako funkční aplikace. Každý příklad obsahuje strukturu projektu a kostru hlavních tříd; plné tělo dostávají jen metody, které nesou doménový invariant. Detailní implementace (Doctrine mapování, kontrolery, testy, okrajové případy) najdete v předchozích kapitolách.
Plný end-to-end příklad – od doménové analýzy přes kontextovou mapu po read modely – rozebírá krok za krokem navazující Případová studie.
Výchozím bodem je prázdný projekt: composer create-project symfony/skeleton
a k němu symfony/uid na identifikátory, symfony/messenger na command bus
a doctrine/orm na persistenci. Ukázky cílí na PHP 8.4, Symfony 8 a Doctrine ORM 3.
23.01 Příklad: E-commerce aplikace#
E-commerce výřez nad košíkem a objednávkami. Dva Bounded Contexts: Cart (rozpracovaný nákup)
a Order (potvrzená transakce). Mezi nimi přechází doménová událost CartCheckedOut,
na kterou kontext Order reaguje vytvořením agregátu Order.
Struktura projektu
1src/2├── Cart/ # Bounded Context: Košík3│ ├── Domain/4│ │ ├── Model/Cart.php # Aggregate Root5│ │ ├── Model/CartItem.php6│ │ ├── ValueObject/CartId.php, ProductId.php, UserId.php7│ │ ├── Event/ItemAddedToCart.php, CartCheckedOut.php, CheckedOutItem.php8│ │ ├── Exception/EmptyCartException.php9│ │ └── Repository/CartRepository.php10│ ├── Infrastructure/Repository/DoctrineCartRepository.php11│ ├── AddItem/{Command, Controller}/ # Feature slice12│ ├── GetCart/{Query, ViewModel}/ # Feature slice13│ └── Checkout/Controller/ # Feature slice14├── Order/ # Bounded Context: Objednávky15│ ├── Domain/Model/Order.php # Aggregate Root16│ ├── Domain/ValueObject/OrderId.php, CustomerId.php17│ ├── Domain/Event/OrderPlaced.php18│ └── PlaceOrder/{Command, Controller, Listener}/19└── Shared/Domain/{Money.php, Exception/DomainException.php}
Agregát Cart
Agregát Cart hlídá pravidlo: u stejného productId navyšuje množství stávající
položky místo přidání nové. Obě metody nesoucí invariant mají plné tělo, zbytek
zůstává kostrou:
1final class Cart extends AggregateRoot2{3 public readonly CartId $id;4 public readonly UserId $userId;5 /** @var Collection<int, CartItem> */6 private Collection $items;7 8 public static function open(CartId $id, UserId $userId): self { /* ... */ }9 10 public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void11 {12 // INVARIANT: jedna položka na produkt – množství se sčítá, řádek se neduplikuje.13 $existing = $this->findItem($productId);14 15 if ($existing !== null) {16 $existing->increaseQuantity($quantity);17 } else {18 $this->items->add(new CartItem($this->id, $productId, $quantity, $unitPrice));19 }20 21 $this->record(new ItemAddedToCart($this->id, $productId, $quantity));22 }23 24 public function checkout(): void25 {26 // INVARIANT: z prázdného košíku objednávka nevznikne.27 if ($this->items->isEmpty()) {28 throw EmptyCartException::withId($this->id);29 }30 31 $this->record(new CartCheckedOut(32 $this->id,33 $this->userId,34 array_map(CheckedOutItem::fromCartItem(...), $this->items->toArray()),35 new \DateTimeImmutable(),36 ));37 }38 39 public function removeItem(ProductId $productId): void { /* ... */ }40 public function totalAmount(): Money { /* sumace přes items */ }41 private function findItem(ProductId $productId): ?CartItem { /* ... */ }42}
Plnou implementaci včetně Doctrine mappingu (#[ORM\OneToMany], cascade, orphanRemoval,
optimistický zámek přes #[ORM\Version]) ukazuje Návrh agregátu a
Implementace v Symfony.
Command Handler: AddItemToCart
Tenký aplikační handler: načte agregát, deleguje doménovou logiku, uloží.
1#[AsMessageHandler]2final readonly class AddItemToCartHandler3{4 public function __construct(5 private CartRepository $carts,6 private ProductRepository $products,7 ) {}8 9 public function __invoke(AddItemToCart $command): void10 {11 $cart = $this->carts->getOrFail(new CartId($command->cartId));12 $product = $this->products->getOrFail(new ProductId($command->productId));13 14 $cart->addItem($product->id, $command->quantity, $product->price);15 16 $this->carts->save($cart);17 }18}
ProductRepository ve struktuře projektu výše nefiguruje záměrně: v Cart kontextu existuje
jen jako rozhraní (port), implementaci dodává kontext Catalog, který ukázka vynechává.
Plnou CQRS implementaci s validací, autorizací a outbox patternem najdete v CQRS a Outbox Pattern.
Přechod z košíku do objednávky
Checkout je jediné místo, kde se oba kontexty potkávají. Cart o objednávkách nic neví; zaznamená událost a tím pro něj práce končí. Payload události nese kopii dat, ne entity košíku – kontexty se znají jen přes identifikátory a hodnoty.
1final readonly class CartCheckedOut2{3 /** @param list<CheckedOutItem> $items */4 public function __construct(5 public CartId $cartId,6 public UserId $userId,7 public array $items,8 public \DateTimeImmutable $occurredAt,9 ) {}10}
Na druhé straně hranice stojí handler kontextu Order. Ten si cizí slovník překládá na svůj:
UserId z košíku se stává CustomerId objednávky.
1#[AsMessageHandler]2final readonly class PlaceOrderOnCartCheckedOut3{4 public function __construct(private OrderRepository $orders) {}5 6 public function __invoke(CartCheckedOut $event): void7 {8 // Překlad mezi kontexty na hranici: UserId košíku → CustomerId objednávky.9 $order = Order::place(OrderId::generate(), new CustomerId($event->userId->value));10 11 foreach ($event->items as $item) {12 $order->addItem($item->productId, $item->quantity, $item->unitPrice);13 }14 15 $this->orders->save($order);16 }17}
V monolitu handler odebírá doménovou událost přímo. Jakmile se kontext Order osamostatní, potřebuje vlastní integrační DTO naplněné z payloadu zprávy – důvody rozebírá DDD a mikroslužby. Spolehlivé doručení mezi kontexty přitom nezajistí sběrnice sama, ale Outbox Pattern.
23.02 Příklad: Blog#
Blog drží jeden Bounded Context s jediným agregátem Post – Comment je entita uvnitř něj –
a sekcemi pro vytvoření příspěvku, výpis a detail.
Struktura projektu
1src/2└── Blog/ # Bounded Context: Blog3 ├── Domain/4 │ ├── Model/Post.php # Aggregate Root5 │ ├── Model/Comment.php6 │ ├── ValueObject/PostId.php, CommentId.php, AuthorId.php7 │ ├── Event/PostCreated.php, CommentAdded.php8 │ ├── Exception/CommentsClosedException.php9 │ └── Repository/PostRepository.php10 ├── Infrastructure/Repository/DoctrinePostRepository.php11 ├── CreatePost/{Command, Controller}/12 ├── AddComment/{Command, Controller}/13 ├── GetPost/{Query, Controller, ViewModel}/14 └── GetPosts/{Query, Controller, ViewModel}/
Agregát Post
Agregát Post se vytváří přes named constructor create(). Ten vynucuje invarianty
(titul 3–255 znaků, neprázdný obsah) a nová instance zaznamená PostCreated. Konstruktor
zůstává privátní a událost nenahrává – rekonstituce z databáze by jinak emitovala
události znovu.
1final class Post extends AggregateRoot2{3 /** @var Collection<int, Comment> */4 private Collection $comments;5 private bool $commentsClosed = false;6 7 private function __construct(8 public readonly PostId $id,9 private string $title,10 private string $content,11 public readonly AuthorId $authorId,12 public readonly \DateTimeImmutable $createdAt,13 ) {14 $this->comments = new ArrayCollection();15 }16 17 public static function create(PostId $id, string $title, string $content, AuthorId $authorId): self18 {19 // Invarianty: title 3–255 znaků, content nesmí být prázdný20 $post = new self($id, $title, $content, $authorId, new \DateTimeImmutable());21 $post->record(new PostCreated($id, $title, $authorId));22 23 return $post;24 }25 26 public function addComment(CommentId $id, AuthorId $authorId, string $text): void27 {28 // INVARIANT: do uzavřené diskuse komentář nepřibude.29 if ($this->commentsClosed) {30 throw CommentsClosedException::forPost($this->id);31 }32 33 $this->comments->add(new Comment($id, $this->id, $authorId, $text));34 $this->record(new CommentAdded($this->id, $id, $authorId));35 }36 37 public function closeComments(): void { /* ... */ }38 public function updateTitle(string $newTitle): void { /* ... */ }39 public function updateContent(string $newContent): void { /* ... */ }40}
Comment je entita uvnitř agregátu, ne samostatný Aggregate Root. Vzniká jen přes
Post::addComment(), takže invariant „uzavřená diskuse“ nelze obejít. Hranici agregátu
a důsledky pro souběžné zápisy rozebírá Návrh agregátu.
Command Handler: CreatePost
1#[AsMessageHandler]2final readonly class CreatePostHandler3{4 public function __construct(private PostRepository $posts) {}5 6 public function __invoke(CreatePost $command): void7 {8 $post = Post::create(9 new PostId($command->postId),10 $command->title,11 $command->content,12 new AuthorId($command->authorId),13 );14 15 $this->posts->save($post);16 }17}
Handler nic nevrací a identifikátor příspěvku přichází v commandu – kontroler ho
vygeneruje přes PostId::generate() ještě před dispatchem. Návrat ID z handleru přes
HandledStamp je druhá možnost, ale u asynchronního transportu se výsledek k volajícímu
nedostane; srovnání obou variant je v CQRS.
Read model pro výpis příspěvků – paginace, řazení podle data, projekce z událostí – patří mimo zápisový repozitář. Rozebírá ho CQRS – ViewModely a Read Modely a Výkonnostní aspekty.
23.03 Příklad: Správa uživatelů#
Bounded Context UserManagement drží jediný agregát User a tři sub-features: registraci,
autentizaci, profil. Agregát se integruje se Symfony Security (implementuje UserInterface).
Struktura projektu
1src/2└── UserManagement/ # Bounded Context: Správa uživatelů3 ├── Domain/4 │ ├── Model/User.php # Aggregate Root5 │ ├── ValueObject/UserId.php, Email.php, HashedPassword.php6 │ ├── Event/UserRegistered.php7 │ ├── Exception/DuplicateEmailException.php8 │ └── Repository/UserRepository.php9 ├── Infrastructure/Repository/DoctrineUserRepository.php10 ├── Registration/{Command, Controller}/11 ├── Authentication/Controller/12 └── Profile/{Query, Controller}/
Agregát User
Agregát User implementuje Symfony UserInterface pro Security komponentu. Hodnotový
objekt Email validuje formát v konstruktoru, HashedPassword zapouzdřuje hash logiku.
1final class User extends AggregateRoot implements UserInterface, PasswordAuthenticatedUserInterface2{3 private function __construct(4 public readonly UserId $id,5 private string $name,6 private Email $email,7 private HashedPassword $password,8 public readonly \DateTimeImmutable $createdAt,9 ) {10 }11 12 public static function register(UserId $id, string $name, Email $email, HashedPassword $password): self13 {14 $user = new self($id, $name, $email, $password, new \DateTimeImmutable());15 $user->record(new UserRegistered($id, $email, $user->createdAt));16 17 return $user;18 }19 20 public function changeEmail(Email $newEmail): void { /* invariant: nový != starý */ }21 public function changeName(string $newName): void { /* ... */ }22 23 // UserInterface – eraseCredentials() Symfony 8 z rozhraní odstranilo24 public function getRoles(): array { return ['ROLE_USER']; }25 public function getUserIdentifier(): string { return $this->email->value; }26 public function getPassword(): ?string { return $this->password->hash; }27}
Jde o zjednodušenou variantu referenční implementace z kapitoly
Implementace v Symfony 8 – událost UserRegistered
se nahrává ve factory register(), nikdy v konstruktoru. Dva kompromisy malého
příkladu: UserInterface implementuje přímo agregát, zatímco v plné architektuře
patří na security adapter v infrastrukturní vrstvě (viz
Autorizace v DDD). A final u entit mapovaných Doctrine projde – nativní lazy objekty
z entity nedědí.
Hash hesla nemá putovat do session. PasswordAuthenticatedUserInterface k tomu
doporučuje __serialize() a __unserialize() na entitě, které citlivé pole vynechají.
Command Handler: RegisterUser
1#[AsMessageHandler]2final readonly class RegisterUserHandler3{4 public function __construct(5 private UserRepository $users,6 private EntityManagerInterface $em,7 ) {}8 9 public function __invoke(RegisterUser $command): void10 {11 // Normalizace vstupu (trim, lowercase) patří do fromUserInput(),12 // konstruktor Email jen validuje.13 $email = Email::fromUserInput($command->email);14 15 $user = User::register(16 UserId::generate(),17 $command->name,18 $email,19 HashedPassword::fromPlainText($command->password),20 );21 22 try {23 $this->users->save($user);24 // Flush ručně: unique constraint se vyhodnotí až zde a překlad25 // na doménovou výjimku musí proběhnout uvnitř try bloku.26 $this->em->flush();27 } catch (UniqueConstraintViolationException $e) {28 throw DuplicateEmailException::with($email, $e);29 }30 }31}
Unikátnost e-mailu garantuje databázový constraint, ne kontrola přes findByEmail()
před zápisem. Ta je vůči souběžným registracím nedostatečná – rozbor race condition
a obou vrstev ochrany je v
Implementaci v Symfony.
Autorizaci uživatele po přihlášení – čtyři vrstvy přístupu, Voter, doménové invarianty – rozebírá Autorizace v DDD.
23.04 Tři projekty vedle sebe#
Příklady se liší doménovou komplexitou i počtem kontextů. Srovnání ukazuje, co která varianta vyžaduje a kde zvolená struktura narazí na strop:
| E-shop | Blog | Správa uživatelů | |
|---|---|---|---|
| Komplexita domény | Střední: invarianty v košíku, přechod stavu checkout → objednávka | Nízká: validace titulku a obsahu, uzavírání diskuse pod příspěvkem | Nízká až střední: unikátní e-mail, hash hesla, integrace se Security |
| Počet Bounded Contexts | 2 (Cart, Order) | 1 | 1 |
| Použité vzory | Agregáty, hodnotové objekty, doménová událost mezi kontexty, CQRS, repository | Agregát s entitou uvnitř, named constructor, CQRS slices, repository | Agregát s UserInterface, hodnotové objekty Email a HashedPassword, repository |
| Co se změní při růstu | Přibudou kontexty Payment, Inventory, Shipping; checkout se stane procesem přes ságu; publikace událostí dostane outbox | Moderace a verzování obsahu si vyžádají oddělený Comment kontext a read model pro výpisy | Role, oprávnění a SSO oddělí Identity od Profile; autorizační pravidla se přesunou do voterů |
| Kdy struktura přestane stačit | Když synchronní komunikace mezi kontexty začne vytvářet řetězy závislostí – pak nastupuje plně asynchronní integrace | Jakmile přibude workflow redakce a schvalování, přestane stačit jediný kontext s CRUD jádrem | Když počet pravidel „kdo smí co“ přeroste agregát – pravidla patří do samostatné autorizační vrstvy |
Rozhodnutí o hloubce stacku se odvíjí od domény, ne od technologie. Plný strategický i taktický DDD (více kontextů, CQRS, doménové události, outbox) se vyplatí tam, kde doména nese netriviální invarianty, na systému pracuje více týmů a jednotlivé části se vyvíjejí různým tempem. E-shop z této kapitoly k tomu směřuje: dva kontexty a událost mezi nimi jsou první krok, zbytek přijde s růstem.
Střední cesta – agregát s repository, bez oddělených read modelů a bez více kontextů – pokrývá projekty typu blog nebo správa uživatelů. Doménová pravidla existují a zaslouží si zapouzdření, ale čtení zůstává triviální a tým malý. Vyplatí se hlídat jeden signál: jakmile výpisy začnou hydratovat agregáty jen kvůli zobrazení, je čas na oddělený read model.
Kde pravidla nejsou žádná a aplikace jen přesouvá data mezi formulářem a tabulkou, DDD nepřináší hodnotu a stojí čas. Symfony formuláře, Doctrine entity a generické CRUD kontrolery takový případ řeší levněji. Hranici mezi oběma světy rozebírá kapitola Kdy DDD nepoužívat.
23.05 Závěr#
Všechny tři příklady sledují stejný řetězec: kontroler → command bus → handler → agregát → repozitář → událost. Variace v počtu Bounded Contexts, počtu agregátů a integraci se Symfony Security tu kostru nemění. Doménové invarianty patří do agregátu, aplikační orchestraci nese handler, infrastrukturu drží repozitář.
Ukázky zabírají střed toho řetězce. Kontrolery, Doctrine mapování a implementace repozitářů zůstávají v Implementaci v Symfony, kde mají prostor na detail.
Reálný projekt s plnou doménovou analýzou, kontextovou mapou, read modely, reconciliation a důsledky pro konzistenci rozebírá navazující Případová studie. Provede vás systémem pro správu projektů krok za krokem – od event stormingu po hotové read modely.
Časté otázky
Proč všechny tři příklady kombinují vertikální slice a CQRS?
Vertikální slice určuje, jak kód organizovat (podle feature); CQRS odděluje čtení od zápisu. Dohromady se doplňují: každá feature má vlastní command nebo query handler, vlastní model zápisu (agregát) a vlastní read model pro odpověď. Kombinace se v ukázkách opakuje záměrně; stejné členění drží i veřejné referenční projekty, například CodelyTV/php-ddd-example.
Lze strukturu z těchto příkladů přímo převzít do produkčního projektu?
Ukázky jsou záměrně zjednodušené – chybí jim autentizace, autorizace, transakční koordinace mezi agregáty, retry logika a komplexnější doménová pravidla. Převzít lze principy: oddělení doménové a infrastrukturní vrstvy, vertikální organizaci feature a CQRS sběrnici. Adresářová struktura slouží jako výchozí šablona; rozšiřuje se podle reálných potřeb projektu. Doporučená dlouhodobá architektura v kapitole Implementace DDD v Symfony 8.
Kde najdu plnou implementaci agregátu se všemi metodami?
V kapitolách Návrh agregátu (kompletní agregát Order s invariantami, optimistickým zámkem, doménovými událostmi a Doctrine mappingem) a Implementace v Symfony 8 (User agregát s Symfony Security, custom typy pro hodnotové objekty, repozitář s outbox patternem).
Proč je v každém příkladu jen jeden Bounded Context kromě e-shopu?
Pro shrnující kapitolu fungují srozumitelněji jednodušší případy s jedním kontextem. E-shop má dva kontexty (Cart a Order), aby ilustroval cross-context komunikaci přes doménovou událost CartCheckedOut. V reálném projektu by každý ze tří příkladů měl pravděpodobně více kontextů (Identity, Billing, Notifications), ale to už je doména Případové studie.