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

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.

Autor M. Katuščák
Doba čtení ≈ 12 min
Náročnost pokročilá
Publikováno · Aktualizováno ·
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. Detailní implementace (plné Doctrine mapování, 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.

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.

FIG. 23.1-A E-shop: bounded contexts Cart a Order

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, Quantity.php, Money.php7│   │   ├── Event/ItemAddedToCart.php, CartCheckedOut.php8│   │   └── Repository/CartRepository.php9│   ├── Infrastructure/Repository/DoctrineCartRepository.php10│   ├── AddItem/{Command, Controller}/  # Feature slice11│   ├── GetCart/{Query, ViewModel}/     # Feature slice12│   └── Checkout/Controller/             # Feature slice13├── Order/                     # Bounded Context: Objednávky14│   ├── Domain/Model/Order.php          # Aggregate Root15│   ├── Domain/Event/OrderCreated.php16│   └── CreateOrder/{Command, Controller}/17└── Shared/Domain/Exception/DomainException.php

Agregát Cart

Agregát Cart hlídá pravidlo: u stejného productId navyšuje quantity stávající položky místo přidání nové. Skeleton:

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 8    public static function open(CartId $id, UserId $userId): self { /* ... */ }9 10    public function addItem(ProductId $productId, Quantity $quantity, Money $price): void11    {12        // Invariant: pokud productId existuje, zvyš quantity; jinak přidej nový item.13        // Vyemituje ItemAddedToCart event.14    }15 16    public function removeItem(ProductId $productId): void { /* ... */ }17    public function totalAmount(): Money { /* sumace přes items */ }18    public function checkout(): void { /* invariant: cart nesmí být prázdný */ }19}

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]2final class AddItemToCartHandler3{4    public function __construct(5        private CartRepository $cartRepository,6        private ProductRepository $productRepository,7    ) {}8 9    public function __invoke(AddItemToCart $command): void10    {11        $cart = $this->cartRepository->findByIdOrFail(new CartId($command->cartId));12        $product = $this->productRepository->findByIdOrFail(new ProductId($command->productId));13 14        $cart->addItem($product->id(), new Quantity($command->quantity), $product->price());15 16        $this->cartRepository->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.

23.02 Příklad: Blog#

Blog drží jeden Bounded Context s jediným agregátem PostComment je entita uvnitř něj – a sekcemi pro vytvoření příspěvku, výpis a detail.

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    │   └── Repository/PostRepository.php9    ├── Infrastructure/Repository/DoctrinePostRepository.php10    ├── CreatePost/{Command, Controller}/11    ├── GetPost/{Query, Controller, ViewModel}/12    └── 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.

php src/Blog/Domain/Model/Post.php (skeleton)
1final class Post extends AggregateRoot2{3    private function __construct(4        public readonly PostId $id,5        private string $title,6        private string $content,7        public readonly AuthorId $authorId,8        public readonly \DateTimeImmutable $createdAt,9    ) {10        $this->record(new PostCreated($id, $title, $authorId));11    }12 13    public static function create(PostId $id, string $title, string $content, AuthorId $authorId): self14    {15        // Invarianty: title 3–255 znaků, content nesmí být prázdný16        return new self($id, $title, $content, $authorId, new \DateTimeImmutable());17    }18 19    public function updateTitle(string $newTitle): void { /* ... */ }20    public function updateContent(string $newContent): void { /* ... */ }21}

Command Handler: CreatePost

php src/Blog/CreatePost/Command/CreatePostHandler.php (skeleton)
1#[AsMessageHandler]2final class CreatePostHandler3{4    public function __construct(private PostRepository $posts) {}5 6    public function __invoke(CreatePost $command): string7    {8        $post = Post::create(9            PostId::generate(),10            $command->title,11            $command->content,12            new AuthorId($command->authorId),13        );14 15        $this->posts->save($post);16 17        return $post->id->value;18    }19}

Pro implementaci read modelu pro výpis příspěvků (paginace, řazení podle data, projekce z eventů) viz 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).

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    │   └── Repository/UserRepository.php8    ├── Infrastructure/Repository/DoctrineUserRepository.php9    ├── Registration/{RegisterUser, RegisterUserHandler, RegistrationController}.php10    ├── Authentication/SecurityController.php11    └── Profile/{GetUserProfile, GetUserProfileHandler, ProfileController}.php

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.

php src/UserManagement/Domain/Model/User.php (skeleton)
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 se u entit mapovaných Doctrine vynechává, protože lazy proxy z entity dědí.

Command Handler: RegisterUser

php src/UserManagement/Registration/RegisterUserHandler.php (skeleton)
1#[AsMessageHandler]2final class RegisterUserHandler3{4    public function __construct(5        private UserRepository $users,6        private UserPasswordHasherInterface $passwordHasher,7    ) {}8 9    public function __invoke(RegisterUser $command): void10    {11        $email = new Email($command->email);12 13        // Invariant na úrovni handleru: email musí být unikátní (DB unique constraint14        // je pojistka pro race condition, viz /implementace-v-symfony#register-race-heading).15        if ($this->users->findByEmail($email) !== null) {16            throw DuplicateEmailException::with($email);17        }18 19        $user = User::register(20            UserId::generate(),21            $command->name,22            $email,23            HashedPassword::fromHasher($this->passwordHasher, $command->password),24        );25 26        $this->users->save($user);27    }28}

Pro autorizaci uživatele po přihlášení (čtyři vrstvy přístupu, Voter, doménové invarianty) viz Autorizace v DDD.

23.04 Tři projekty vedle sebe#

Tři příklady pokrývají tři různé úrovně doménové komplexity. Srovnání ukazuje, co která úroveň 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á: pár validačních pravidel na titulku a obsahu 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 named constructorem, 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ář → event. 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ář.

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ěď. Tato kombinace se v ukázkách opakuje záměrně – odpovídá typickému tvaru produkčního DDD projektu v Symfony 8.

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.