Kapitola 23 · Syntéza · Praktické příklady

Praktické příklady

Tři malé projekty v Symfony 8: e-shop, blog a správa uživatelů. Na každém je vidět rozdělení na Bounded Contexty, doménový model a vertical slice architektura.

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

Tři krátké příklady skládají vzory z taktického DDD, CQRS a Implementace v Symfony do 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. Detaily (Doctrine mapování, kontrolery, testy, okrajové případy) zůstávají v předchozích kapitolách.

Plný end-to-end příklad rozebírá krok za krokem navazující Případová studie: od doménové analýzy přes Context Mapu po read modely.

Výchozím bodem je prázdný projekt: composer create-project symfony/skeleton. Ukázky v knize cílí na PHP 8.4, Symfony 8 a Doctrine ORM 3 a potřebují tyto balíčky:

bash terminál
1composer require \2    symfony/uid \3    symfony/messenger \4    symfony/doctrine-messenger \5    symfony/serializer symfony/property-access symfony/property-info \6    symfony/validator egulias/email-validator \7    symfony/security-bundle \8    symfony/twig-bundle symfony/form \9    symfony/expression-language \10    doctrine/orm doctrine/doctrine-bundle doctrine/doctrine-migrations-bundle

Seznam nevypadá minimalisticky, každá položka ale odpovídá jedné kapitole. doctrine-messenger dodává transport doctrine://default. Bez serializer a property-access neprojde outbox, bez egulias/email-validator shodí Assert\Email s VALIDATION_MODE_STRICT každý dispatch. A bez migrations-bundle nespustíte ani jednu migraci z kapitoly o Outboxu. twig-bundle a form potřebují kontrolery, které vracejí HTML. Ty z kapitoly o CQRS volají createForm() a render(). Kdo staví jen JSON API, obejde se bez nich. expression-language je naopak povinný, jakmile v projektu leží PolicyEvaluator z kapitoly o autorizaci. Komponentu bere jako výchozí hodnotu parametru, takže bez balíčku spadne už cache:clear, ne teprve vyhodnocení pravidla.

Instalace tím ale nekončí. Další dva kroky se přeskakují, protože se jejich vynechání hned neprojeví. Recept doctrine/doctrine-bundle vygeneruje mapování na src/Entity s prefixem App\Entity, tedy na adresář, který ve vertikálním řezu neexistuje. Doctrine pak mlčky nevidí žádnou entitu:

bash terminál
1php bin/console doctrine:schema:update --dump-sql2# [OK] No Metadata Classes to process.

Hláška vypadá jako úspěch, znamená ale opak. Blok mappings: je proto potřeba přepsat podle kapitoly o agregátech dřív, než vznikne první migrace. Druhý krok je DATABASE_URL v .env. Recept nastaví PostgreSQL, takže u SQLite nebo MySQL připojení míří do prázdna:

ini .env.local
1# SQLite stačí na všechny ukázky v knize a nepotřebuje běžící server2DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db"

Přepínače testovacích adaptérů z kapitoly o ságách patří naopak do verzovaného .env, ne do .env.local. Kernel testy .env.local nečtou, takže by první test sáhající na platební bránu spadl na EnvNotFoundException:

ini .env
1PAYMENT_FAILS=02STOCK_FAILS=0
bash terminál
1# doctrine:database:create u SQLite není potřeba a skončí chybou2# „getCreateDatabaseSQL is not supported by platform“ – soubor si3# ovladač založí sám při prvním připojení.4 5# Migrace nejdřív vzniknou z mapování. Bez tohoto kroku skončí migrate6# hláškou „there are no registered migrations“.7php bin/console doctrine:migrations:diff8php bin/console doctrine:migrations:migrate

Tabulky read modelů migrations:diff nevygeneruje: ORM o nich neví a schema_filter je z porovnávání schématu záměrně vyřazuje. Migraci pro order_dashboard, reporting_orders a order_audit_log proto napíšete ručně podle DDL v kapitolách o CQRS, o Outboxu a o autorizaci.

Testy z kapitoly o CQRS běží proti skutečné databázi, takže potřebují víc přípravy. Test ságy z kapitoly Ságy a Process Managery si vystačí s in-memory repozitářem, PHPUnit ale potřebuje také. V seznamu výše záměrně chybí, protože ho nepotřebuje každý:

bash terminál
1composer require --dev symfony/test-pack

Zbývá detail, který stojí půl hodiny hledání: .env.local se v prostředí test nenačítá. Kernel testy proto sáhnou po DATABASE_URL z .env, tedy po PostgreSQL z receptu, a spadnou na Connection refused. Hodnota patří do .env.test.local:

ini .env.test.local
1DATABASE_URL="sqlite:///%kernel.project_dir%/var/test.db"

Recept Symfony 8 zapíná bezstavovou ochranu proti CSRF (csrf_protection.stateless_token_ids). Formuláře z knihy s ní projdou i bez řádku JavaScriptu: SameOriginCsrfTokenManager uzná požadavek, kterému sedí Origin nebo Sec-Fetch-Site: same-origin, a to prohlížeč u běžného odeslání formuláře posílá. Ochrana přitom funguje, cross-origin požadavek neprojde. Klasický režim s tokenem v session zapnete smazáním klíče stateless_token_ids z config/packages/csrf.yaml, potřeba to ale není.

Testovací databáze je jiný soubor a potřebuje vlastní migraci, jinak první kernel test spadne na no such table: users:

bash terminál
1php bin/console doctrine:migrations:migrate --no-interaction --env=test

23.00 Aplikace potřebuje dva běžící procesy#

Tento krok se přehlédne nejsnáz, protože jeho absence nic neshodí. Po instalaci a spuštění webserveru objednávka projde: POST /orders vrátí přesměrování, hláška oznámí úspěch, detail se vykreslí. Dál se ale nic nestane.

bash terminál
1# stav po odeslání objednávky, když neběží workery2order            confirmed  (saga_in_progress = 1)3order_dashboard  0 řádků4reporting_orders 0 řádků5outbox           1 řádek ve stavu pending

Objednávka zůstane zamčená procesem, který nikdy nezačal, takže ji nepůjde ani zrušit – isCancellable() vrací false a šablona tlačítko nenabídne. Nikde přitom nespadne výjimka a v logu nic není.

Chybí dva trvale běžící procesy. Relay vybírá řádky z outboxu a posílá je na sběrnici; worker je zpracovává:

bash terminál – dva samostatné terminály
1php bin/console app:outbox:dispatch --time-limit=36002php bin/console messenger:consume async_events async_commands --time-limit=3600

V produkci je hlídá supervisord nebo systemd, jak popisuje kapitola o Outboxu. Při vývoji stačí dvě okna terminálu. Bez nich aplikace jen sbírá objednávky, které nikdo nezpracuje.

23.01 Příklad: E-commerce aplikace#

Výřez e-shopu nad košíkem a objednávkami má dva Bounded Contexts: Cart (rozpracovaný nákup) a Ordering (potvrzená transakce). Mezi nimi přechází doménová událost CartCheckedOut, na kterou kontext Ordering reaguje vytvořením agregátu Order.

FIG. 23.1-A E-shop: Bounded Contexty Cart a Ordering

Struktura projektu

bash src/ struktura
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.php, CartAlreadyCheckedOutException.php9│   │   └── Repository/CartRepository.php10│   ├── Infrastructure/Repository/DoctrineCartRepository.php11│   ├── AddItem/{Command, Controller}/  # Feature slice12│   ├── GetCart/{Query, ViewModel}/     # Feature slice13│   └── Checkout/Controller/             # Feature slice14├── Ordering/                  # Bounded Context: Objednávky (vrstvy)15│   ├── Domain/Model/Order.php          # Aggregate Root16│   ├── Domain/ValueObject/OrderId.php, CustomerId.php, ProductId.php17│   ├── Domain/Event/OrderPlaced.php18│   ├── Application/Command/PlaceOrder.php19│   ├── Application/Handler/PlaceOrderHandler.php20│   ├── Application/Listener/PlaceOrderOnCartCheckedOut.php21│   └── Infrastructure/Http/PlaceOrderController.php22└── SharedKernel/Domain/{Money.php, Exception/DomainException.php}

Cart je členěný do feature slices. Ordering drží vrstvy Domain, Application a Infrastructure jako ve zbytku knihy, takže command i handler jde převzít z kapitoly o Outboxu beze změny namespace.

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:

php src/Cart/Domain/Model/Cart.php (skeleton)
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    private bool $checkedOut = false;8 9    public static function open(CartId $id, UserId $userId): self { /* ... */ }10 11    public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void12    {13        // INVARIANT: jedna položka na produkt – množství se sčítá, řádek se neduplikuje.14        $existing = $this->findItem($productId);15 16        if ($existing !== null) {17            $existing->increaseQuantity($quantity);18        } else {19            $this->items->add(new CartItem($this->id, $productId, $quantity, $unitPrice));20        }21 22        $this->record(new ItemAddedToCart($this->id, $productId, $quantity));23    }24 25    public function checkout(): void26    {27        // INVARIANT: z prázdného košíku objednávka nevznikne.28        if ($this->items->isEmpty()) {29            throw EmptyCartException::withId($this->id);30        }31 32        // INVARIANT: košík se odbaví jednou. Bez toho by dvojklik nebo33        // retry vyrobil dvě události, a tím dvě objednávky.34        if ($this->checkedOut) {35            throw CartAlreadyCheckedOutException::withId($this->id);36        }37 38        $this->checkedOut = true;39 40        $this->record(new CartCheckedOut(41            $this->id,42            $this->userId,43            array_map(CheckedOutItem::fromCartItem(...), $this->items->toArray()),44            new \DateTimeImmutable(),45        ));46    }47 48    public function removeItem(ProductId $productId): void { /* ... */ }49    public function totalAmount(): Money { /* sumace přes items */ }50    private function findItem(ProductId $productId): ?CartItem { /* ... */ }51}

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ží.

php src/Cart/AddItem/Command/AddItemToCartHandler.php (skeleton)
1#[AsMessageHandler(bus: 'command.bus')]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->get(new CartId($command->cartId));12        $product = $this->products->get(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 outboxem ukazují kapitoly 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čí. Událost nese kopii dat, ne entity košíku, protože kontexty se znají jen přes identifikátory a hodnoty.

php src/Cart/Domain/Event/CartCheckedOut.php
1<?php2 3declare(strict_types=1);4 5namespace App\Cart\Domain\Event;6 7use App\Cart\Domain\ValueObject\CartId;8use App\Cart\Domain\ValueObject\UserId;9 10final readonly class CartCheckedOut11{12    /** @param list<CheckedOutItem> $items */13    public function __construct(14        public CartId $cartId,15        public UserId $userId,16        public array $items,17        public \DateTimeImmutable $occurredAt,18    ) {}19}

Na druhé straně hranice stojí listener kontextu Ordering. Cizí slovník překládá na command vlastního kontextu: UserId z košíku se stává customerId objednávky.

php src/Ordering/Application/Listener/PlaceOrderOnCartCheckedOut.php
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Listener;6 7use App\Cart\Domain\Event\CartCheckedOut;8use App\Cart\Domain\Event\CheckedOutItem;9use App\Ordering\Application\Command\PlaceOrder;10use Symfony\Component\DependencyInjection\Attribute\Target;11use Symfony\Component\Messenger\Attribute\AsMessageHandler;12use Symfony\Component\Messenger\MessageBusInterface;13 14#[AsMessageHandler(bus: 'event.bus')]15final readonly class PlaceOrderOnCartCheckedOut16{17    public function __construct(18        #[Target('command.bus')]19        private MessageBusInterface $commandBus,20    ) {}21 22    public function __invoke(CartCheckedOut $event): void23    {24        // Listener sám nic neukládá. event.bus nemá doctrine_transaction,25        // takže save() bez flushe by nic nezapsal a OrderPlaced by nikdo26        // nevyzvedl. Transakci, potvrzení i outbox obstará handler commandu.27        $this->commandBus->dispatch(new PlaceOrder(28            customerId: $event->userId->value,29            items: array_map(30                static fn (CheckedOutItem $item): array => [31                    'productId' => $item->productId->value,32                    'quantity' => $item->quantity,33                    'unitPriceInCents' => $item->unitPrice->amountInCents,34                ],35                $event->items,36            ),37        ));38    }39}

Command PlaceOrder je týž jako v kapitole Outbox Pattern, včetně FQCN App\Ordering\Application\Command\PlaceOrder. Messenger hledá handler podle třídy, takže kopie v jiném namespace by skončila na NoHandlerForMessageException. Kanonický handler objednávku založí přes placeWithItems(), potvrdí ji a integrační událost uloží do outboxu v téže transakci.

Listener pro stručnost odebírá doménovou událost kontextu Cart přímo. V produkci by Cart publikoval integrační událost, jako to dělá Ordering s OrderPlacedIntegrationEvent. Nejpozději ve chvíli, kdy se kontext Ordering osamostatní, potřebuje vlastní integrační DTO naplněné z payloadu zprávy. Důvody rozebírá DDD a microservices. Spolehlivé doručení mezi kontexty přitom nezajistí sběrnice sama, ale Outbox Pattern.

23.02 Příklad: Blog#

Blog tvoří jeden Bounded Context s jediným agregátem Post a slices pro vytvoření příspěvku, přidání komentáře, výpis a detail. Comment je entita uvnitř agregátu.

FIG. 23.2-A Blog: doménový model a feature slices

Struktura projektu

bash src/ struktura
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.php, InvalidPostTitleException.php,9    │   │   EmptyPostContentException.php10    │   └── Repository/PostRepository.php11    ├── Infrastructure/Repository/DoctrinePostRepository.php12    ├── CreatePost/{Command, Controller}/13    ├── AddComment/{Command, Controller}/14    ├── GetPost/{Query, Controller, ViewModel}/15    └── GetPosts/{Query, Controller, ViewModel}/

Agregát Post

Agregát Post vzniká přes named constructor create(). Ten vynucuje invarianty (titulek 3–255 znaků, neprázdný obsah) a nová instance zaznamená PostCreated. Konstruktor zůstává privátní a událost nenahrává, jinak by ji rekonstituce z databáze emitovala znovu.

php src/Blog/Domain/Model/Post.php (skeleton)
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        // INVARIANT: titulek má 3–255 znaků a obsah není prázdný.20        $titleLength = mb_strlen($title);21        if ($titleLength < 3 || $titleLength > 255) {22            throw InvalidPostTitleException::lengthOutOfRange($titleLength);23        }24        if (trim($content) === '') {25            throw EmptyPostContentException::forPost($id);26        }27 28        $post = new self($id, $title, $content, $authorId, new \DateTimeImmutable());29        $post->record(new PostCreated($id, $title, $authorId));30 31        return $post;32    }33 34    public function addComment(CommentId $id, AuthorId $authorId, string $text): void35    {36        // INVARIANT: do uzavřené diskuse komentář nepřibude.37        if ($this->commentsClosed) {38            throw CommentsClosedException::forPost($this->id);39        }40 41        $this->comments->add(new Comment($id, $this->id, $authorId, $text));42        $this->record(new CommentAdded($this->id, $id, $authorId));43    }44 45    public function closeComments(): void { /* ... */ }46    public function updateTitle(string $newTitle): void { /* ... */ }47    public function updateContent(string $newContent): void { /* ... */ }48}

Comment je entita uvnitř agregátu, ne samostatný kořen agregátu. 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

php src/Blog/CreatePost/Command/CreatePostHandler.php (skeleton)
1#[AsMessageHandler(bus: 'command.bus')]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ů patří mimo zápisový repozitář: paginace, řazení podle data, projekce z událostí. Rozebírá ho CQRS a Read modely, projekce a výkon.

23.03 Příklad: Správa uživatelů#

Bounded Context UserManagement drží jediný agregát User a tři slices: registraci, autentizaci a profil. Agregát se integruje se Symfony Security (implementuje UserInterface).

FIG. 23.3-A Správa uživatelů: feature slices

Struktura projektu

bash src/ struktura
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 logiku hashování.

php src/UserManagement/Domain/Model/User.php (skeleton, varianta s UserInterface)
1final class User extends AggregateRoot implements UserInterface, PasswordAuthenticatedUserInterface2{3    private function __construct(4        public readonly UserId $id,5        private UserName $name,6        private Email $email,7        private HashedPassword $hashedPassword,8        public readonly \DateTimeImmutable $createdAt,9    ) {10    }11 12    public static function register(UserId $id, UserName $name, Email $email, HashedPassword $hashedPassword): self13    {14        $user = new self($id, $name, $email, $hashedPassword, 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 rename(UserName $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->hashedPassword->value; }27}

Jde o zjednodušenou variantu referenční implementace z kapitoly Implementace v Symfony 8. Signatura register() i hodnotové objekty UserName, Email a HashedPassword jsou stejné a událost UserRegistered se nahrává v tovární metodě register(), nikdy v konstruktoru. Malý příklad dělá jeden kompromis: UserInterface implementuje přímo agregát, zatímco v plné architektuře patří adaptéru v infrastrukturní vrstvě (viz Autorizace v DDD). final u entity mapované Doctrine přitom projde, protože se zapnutými nativními lazy objekty (PHP 8.4, ORM 3.4+) Doctrine z entity nedědí.

Hash hesla nemá putovat do session. Dokumentace PasswordAuthenticatedUserInterface k tomu nabízí __serialize() na entitě: hash lze ze serializace vynechat, nebo ho nahradit otiskem crc32c. Druhá varianta zachová, že změna hesla zneplatní přihlášené relace.

Command Handler: RegisterUser

php src/UserManagement/Registration/Command/RegisterUserHandler.php (skeleton)
1#[AsMessageHandler(bus: 'command.bus')]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            new UserName($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, kterou souběžné registrace obejdou. Rozbor race condition a obou vrstev ochrany je v Implementaci v Symfony.

Autorizaci uživatele po přihlášení rozebírá Autorizace v DDD: čtyři vrstvy přístupu, Voter a doménové invarianty.

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 její 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, Ordering) 1 1
Použité vzory Agregáty, hodnotové objekty, doménová událost mezi kontexty, CQRS, repozitář Agregát s entitou uvnitř, named constructor, CQRS slices, repozitář Agregát s UserInterface, hodnotové objekty Email a HashedPassword, repozitář
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. Strategický i taktický design v plné šíři (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 pokrývá projekty typu blog nebo správa uživatelů: agregát s repozitářem, bez oddělených read modelů a bez více kontextů. Doménová pravidla existují a zaslouží si zapouzdření, čtení ale zůstává triviální a tým malý. Signál k dalšímu kroku je jeden: 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 jen 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.

Celý projekt od Event Stormingu přes Context Mapu po read modely s reconciliation rozebírá navazující Případová studie.

Č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. Doplňují se: každá feature má vlastní command nebo query handler. Command pracuje s agregátem, query vrací ViewModel pro odpověď. Kombinace se v ukázkách opakuje záměrně. Výjimkou je kontext Ordering v e-shopu, který drží vrstvy, aby seděl s kanonickým kódem z kapitoly o Outboxu; podobné členění podle případů užití s CQRS sběrnicí 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, členění podle funkcí 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 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 (agregát User se Symfony Security, custom typy pro hodnotové objekty, repozitář s outbox patternem).

Proč mají blog a správa uživatelů jen jeden Bounded Context?

Pro shrnující kapitolu fungují srozumitelněji jednodušší případy s jedním kontextem. E-shop má dva kontexty (Cart a Ordering), aby ilustroval komunikaci mezi kontexty 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 látka Případové studie.