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 klíčových tříd. Detailní implementace (plné Doctrine mapování, testy, okrajové případy) najdete v předchozích kapitolách.
Reálný projekt 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,
která vytvoří Order agregát.
Struktura projektu
src/
├── Cart/ # Bounded Context: Košík
│ ├── Domain/
│ │ ├── Model/Cart.php # Aggregate Root
│ │ ├── Model/CartItem.php
│ │ ├── ValueObject/CartId.php, ProductId.php, Quantity.php, Money.php
│ │ ├── Event/ItemAddedToCart.php, CartCheckedOut.php
│ │ └── Repository/CartRepository.php
│ ├── Infrastructure/Repository/DoctrineCartRepository.php
│ ├── AddItem/{Command, Controller}/ # Feature slice
│ ├── GetCart/{Query, ViewModel}/ # Feature slice
│ └── Checkout/Controller/ # Feature slice
├── Order/ # Bounded Context: Objednávky
│ ├── Domain/Model/Order.php # Aggregate Root
│ ├── Domain/Event/OrderCreated.php
│ └── CreateOrder/{Command, Controller}/
└── Shared/Domain/Exception/DomainException.php
Klíčový 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:
final class Cart extends AggregateRoot
{
public readonly CartId $id;
public readonly UserId $userId;
/** @var Collection<int, CartItem> */
private Collection $items;
public static function open(CartId $id, UserId $userId): self { /* ... */ }
public function addItem(ProductId $productId, Quantity $quantity, Money $price): void
{
// Invariant: pokud productId existuje, zvyš quantity; jinak přidej nový item.
// Vyemituje ItemAddedToCart event.
}
public function removeItem(ProductId $productId): void { /* ... */ }
public function totalAmount(): Money { /* sumace přes items */ }
public function checkout(): void { /* invariant: cart nesmí být prázdný */ }
}
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ží.
#[AsMessageHandler]
final class AddItemToCartHandler
{
public function __construct(
private CartRepository $cartRepository,
private ProductRepository $productRepository,
) {}
public function __invoke(AddItemToCart $command): void
{
$cart = $this->cartRepository->findByIdOrFail(new CartId($command->cartId));
$product = $this->productRepository->findByIdOrFail(new ProductId($command->productId));
$cart->addItem($product->id(), new Quantity($command->quantity), $product->price());
$this->cartRepository->save($cart);
}
}
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 se dvěma agregáty (Post, Comment) a sekcemi pro vytvoření
příspěvku, výpis a detail.
Struktura projektu
src/
└── Blog/ # Bounded Context: Blog
├── Domain/
│ ├── Model/Post.php # Aggregate Root
│ ├── Model/Comment.php
│ ├── ValueObject/PostId.php, CommentId.php, AuthorId.php
│ ├── Event/PostCreated.php, CommentAdded.php
│ └── Repository/PostRepository.php
├── Infrastructure/Repository/DoctrinePostRepository.php
├── CreatePost/{Command, Controller}/
├── GetPost/{Query, Controller, ViewModel}/
└── GetPosts/{Query, Controller, ViewModel}/
Klíčový agregát: Post
Agregát Post se vytváří přes named constructor create(), který emituje PostCreated event.
Konstruktor vynucuje invarianty: titul 3–255 znaků, neprázdný autor.
final class Post extends AggregateRoot
{
private function __construct(
public readonly PostId $id,
private string $title,
private string $content,
public readonly AuthorId $authorId,
public readonly \DateTimeImmutable $createdAt,
) {
$this->record(new PostCreated($id, $title, $authorId));
}
public static function create(PostId $id, string $title, string $content, AuthorId $authorId): self
{
// Invarianty: title 3–255 znaků, content nesmí být prázdný
return new self($id, $title, $content, $authorId, new \DateTimeImmutable());
}
public function updateTitle(string $newTitle): void { /* ... */ }
public function updateContent(string $newContent): void { /* ... */ }
}
Command Handler: CreatePost
#[AsMessageHandler]
final class CreatePostHandler
{
public function __construct(private PostRepository $posts) {}
public function __invoke(CreatePost $command): string
{
$post = Post::create(
PostId::generate(),
$command->title,
$command->content,
new AuthorId($command->authorId),
);
$this->posts->save($post);
return $post->id->value();
}
}
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).
Struktura projektu
src/
└── UserManagement/ # Bounded Context: Správa uživatelů
├── Domain/
│ ├── Model/User.php # Aggregate Root
│ ├── ValueObject/UserId.php, Email.php, HashedPassword.php
│ ├── Event/UserRegistered.php
│ └── Repository/UserRepository.php
├── Infrastructure/Repository/DoctrineUserRepository.php
├── Registration/{RegisterUser, RegisterUserHandler, RegistrationController}.php
├── Authentication/SecurityController.php
└── Profile/{GetUserProfile, GetUserProfileHandler, ProfileController}.php
Klíčový 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.
final class User extends AggregateRoot implements UserInterface, PasswordAuthenticatedUserInterface
{
private function __construct(
public readonly UserId $id,
private string $name,
private Email $email,
private HashedPassword $password,
public readonly \DateTimeImmutable $createdAt,
) {
$this->record(new UserRegistered($id, $email));
}
public static function register(UserId $id, string $name, Email $email, HashedPassword $password): self
{
return new self($id, $name, $email, $password, new \DateTimeImmutable());
}
public function changeEmail(Email $newEmail): void { /* invariant: nový != starý */ }
public function changeName(string $newName): void { /* ... */ }
// UserInterface
public function getRoles(): array { return ['ROLE_USER']; }
public function getUserIdentifier(): string { return $this->email->value(); }
public function getPassword(): ?string { return $this->password->hash(); }
public function eraseCredentials(): void {}
}
Command Handler: RegisterUser
#[AsMessageHandler]
final class RegisterUserHandler
{
public function __construct(
private UserRepository $users,
private UserPasswordHasherInterface $passwordHasher,
) {}
public function __invoke(RegisterUser $command): void
{
$email = new Email($command->email);
// Invariant na úrovni handleru: email musí být unikátní (DB unique constraint
// je pojistka pro race condition, viz Implementace v Symfony, sekce 11.13).
if ($this->users->findByEmail($email) !== null) {
throw new \DomainException('User with this email already exists.');
}
$user = User::register(
UserId::generate(),
$command->name,
$email,
HashedPassword::fromHasher($this->passwordHasher, $command->password),
);
$this->users->save($user);
}
}
Pro autorizaci uživatele po přihlášení (čtyři vrstvy přístupu, Voter, doménové invarianty) viz Autorizace v DDD.
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. Provází systém pro správu projektů krok za krokem od event stormingu po deployment.
Č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 určuje, jak oddělit č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.