◢
Vzor
Evoluce příkladů napříč průvodcem
Kódové příklady v tomto průvodci záměrně přibývají na komplexitě. Kapitola
Základní koncepty zjednodušuje příklady
na minimum, aby ilustrovala čistý koncept. Tato kapitola přidává reálné aspekty
implementace v Symfony: Doctrine atributy s custom typy pro hodnotové objekty,
optimistický zámek a generování doménových událostí. Kapitola
Anti-vzory pak ukazuje produkční kvalitu kódu s vlastními
výjimkami, factory metodami a plnou validací invariantů.
§
Poznámka
Mapping volba: atributy jako výchozí přístup
Tento průvodce používá Doctrine atributy přímo na doménových třídách
(#[ORM\Entity], #[ORM\Column]). Argumentem proti je porušení
Dependency Inversion – doména „ví“ o Doctrine. V praxi jde o metadata,
ne o chování: třída se chová stejně, pouze nese popisek pro mapper. Symfony Maker,
oficiální dokumentace i drtivá většina open-source projektů používá atributy.
Pokud chcete striktně oddělenou doménu, korektní cesta není XML mapping (taky
„znečištěné“, jen jiným formátem), ale Persisted Object Pattern – samostatná
persistence třída + mapper na doménový agregát. Detail v sekci
Persisted Object Pattern – čistá DDD varianta .
10.01 Kde končí DDD a kde začíná Symfony#
Následující diagram ukazuje hranici mezi čistým DDD kódem (zelená oblast) a Symfony infrastrukturou
(oranžová oblast). Vše v zelené oblasti je čistý PHP bez závislosti na frameworku –
testovatelné v izolaci, přenositelné mezi projekty. Symfony vrstva implementuje kontrakty
definované doménou (repository interface, event dispatch) a zajišťuje HTTP, persistenci a messaging.
FIG. 10.1-A Hranice mezi DDD a Symfony
+
−
⤢
⛶
Směr závislostí je určující: Symfony závisí na DDD (implementuje jeho rozhraní), nikdy naopak.
Doménová vrstva neimportuje žádný Symfony namespace. Díky tomu lze Doctrine nahradit
jiným ORM nebo Messenger jiným bus systémem, aniž by se dotklo doménové logiky.
Tento směr závislostí formalizuje hexagonální, onion i clean architektura – jejich
srovnání rozvádí kapitola o architektonických stylech .
10.02 Struktura projektu#
Vertikální slice architektura v Symfony 8 organizuje strukturu projektu podle Bounded Contexts (ohraničených kontextů). Každý kontext drží svou doménu, infrastrukturu i feature složky pohromadě. Příklad:
◢
Vzor
Příklad: Správná struktura projektu pro DDD s vertikální slice architekturou v Symfony 8
bash
src/ (vertikální slice struktura)
Kopírovat
1 src/ 2 ├── UserManagement/ 3 │ ├── Domain/ 4 │ │ ├── Model/ 5 │ │ │ └── User.php 6 │ │ ├── ValueObject/ 7 │ │ │ ├── UserId.php 8 │ │ │ └── Email.php 9 │ │ ├── Event/ 10 │ │ │ └── UserRegistered.php 11 │ │ └── Repository/ 12 │ │ └── UserRepository.php 13 │ ├── Infrastructure/ 14 │ │ └── Repository/ 15 │ │ └── DoctrineUserRepository.php 16 │ ├── Registration/ 17 │ │ ├── Command/ 18 │ │ │ ├── RegisterUser.php 19 │ │ │ └── RegisterUserHandler.php 20 │ │ ├── Controller/ 21 │ │ │ └── RegistrationController.php 22 │ │ ├── Form/ 23 │ │ │ └── RegistrationFormType.php 24 │ │ └── View/ 25 │ │ └── registration.html.twig 26 │ └── Profile/ 27 │ ├── Query/ 28 │ │ ├── GetUserProfile.php 29 │ │ └── GetUserProfileHandler.php 30 │ ├── Controller/ 31 │ │ └── ProfileController.php 32 │ ├── Form/ 33 │ │ └── ProfileFormType.php 34 │ └── View/ 35 │ └── profile.html.twig 36 ├── OrderManagement/ 37 │ ├── Domain/ 38 │ │ ├── Model/ 39 │ │ │ ├── Order.php 40 │ │ │ └── OrderItem.php 41 │ │ ├── ValueObject/ 42 │ │ │ ├── OrderId.php 43 │ │ │ └── Money.php 44 │ │ ├── Event/ 45 │ │ │ └── OrderCreated.php 46 │ │ └── Repository/ 47 │ │ └── OrderRepository.php 48 │ ├── Infrastructure/ 49 │ │ └── Repository/ 50 │ │ └── DoctrineOrderRepository.php 51 │ ├── Checkout/ 52 │ │ ├── Command/ 53 │ │ │ ├── CreateOrder.php 54 │ │ │ └── CreateOrderHandler.php 55 │ │ ├── Controller/ 56 │ │ │ └── CheckoutController.php 57 │ │ ├── Form/ 58 │ │ │ └── CheckoutFormType.php 59 │ │ └── View/ 60 │ │ └── checkout.html.twig 61 │ └── OrderHistory/ 62 │ ├── Query/ 63 │ │ ├── GetOrderHistory.php 64 │ │ └── GetOrderHistoryHandler.php 65 │ ├── Controller/ 66 │ │ └── OrderHistoryController.php 67 │ └── View/ 68 │ └── order_history.html.twig 69 └── Shared/ 70 ├── Domain/ 71 │ └── ValueObject/ 72 │ └── Id.php 73 └── Infrastructure/ 74 └── Persistence/ 75 └── Doctrine/ 76 └── Mapping/ 77 └── MappingTrait.php
Závislosti mezi kontexty procházejí přes Application vrstvu nebo události – nikdy přes přímý import doménových tříd cizího kontextu.
FIG. 10.2-A Struktura projektu s Bounded Contexts
+
−
⤢
⛶
§
Poznámka
Hlavní principy správné struktury DDD projektu:
Izolace domén – Každá doména (Bounded Context) má svůj vlastní model, který odráží její specifické potřeby a jazyk.
Ubiquitous Language – Jazyk kontextu se promítá do kódu; tým ho používá konzistentně od názvů tříd po dokumentaci.
Jasné hranice – Čtenář kódu pozná, kde končí jedna doména a začíná druhá.
Minimalizace závislostí – Kontexty drží své modely odděleně. Změna v jednom by neměla nutit úpravu druhého.
!
Pozor
Časté chyby při implementaci DDD
Umístění všech doménových modelů do sdílené složky – Model v Shared/ ztrácí vazbu na kontext, kterému patří.
Sdílení doménových modelů mezi doménami – Přímý přístup k datům cizího kontextu obchází Anti-Corruption Layer i Domain Events.
Příliš mnoho závislostí mezi doménami – Cross-context import doménových tříd je signál chybějící Anti-Corruption Layer.
Ignorování Ubiquitous Language – Kód, dokumentace a komunikace v týmu se rozcházejí v pojmech.
10.03 Implementace entit#
Vstupní bod do agregátu je kořen agregátu – třída dědí z bázové AggregateRoot,
konstruktor je private a vznik probíhá přes pojmenovanou factory metodu
(User::register(), Order::place()). To zaručuje, že nelze vytvořit
agregát v nekonzistentním stavu. Definice entity je v kapitole
Základní koncepty ; tato sekce řeší její podobu v Symfony.
◢
Vzor
Příklad: kořen agregátu User
php
src/SharedKernel/Domain/AggregateRoot.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \SharedKernel \Domain ;6 7 abstract class AggregateRoot 8 {9 10 private array $domainEvents = []; 11 12 final protected function record (object $event) : void 13 {14 $this ->domainEvents[] = $event; 15 } 16 17 18 final public function releaseEvents () : array 19 {20 $events = $this ->domainEvents; 21 $this ->domainEvents = []; 22 23 return $events; 24 } 25 }
php
src/UserManagement/Domain/Model/User.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \Model ;6 7 use App \SharedKernel \Domain \AggregateRoot ;8 use App \UserManagement \Domain \Event \UserRegistered ;9 use App \UserManagement \Domain \ValueObject \Email ;10 use App \UserManagement \Domain \ValueObject \HashedPassword ;11 use App \UserManagement \Domain \ValueObject \UserId ;12 use App \UserManagement \Domain \ValueObject \UserName ;13 use Doctrine \ORM \Mapping as ORM ;14 15 16 17 class User extends AggregateRoot 18 {19 20 21 public readonly UserId $id; 22 23 24 private UserName $name; 25 26 27 private Email $email; 28 29 30 private readonly HashedPassword $hashedPassword; 31 32 33 public readonly \DateTimeImmutable $createdAt; 34 35 36 37 private int $version = 1 ; 38 39 private function __construct ( 40 UserId $id, 41 UserName $name, 42 Email $email, 43 HashedPassword $hashedPassword, 44 ) {45 $this ->id = $id; 46 $this ->name = $name; 47 $this ->email = $email; 48 $this ->hashedPassword = $hashedPassword; 49 $this ->createdAt = new \DateTimeImmutable(); 50 } 51 52 public static function register ( 53 UserId $id, 54 UserName $name, 55 Email $email, 56 HashedPassword $hashedPassword, 57 ) : self {58 $user = new self ($id, $name, $email, $hashedPassword); 59 $user->record(new UserRegistered($id, $email, $user->createdAt)); 60 61 return $user; 62 } 63 64 public function name () : UserName 65 {66 return $this ->name; 67 } 68 69 public function email () : Email 70 {71 return $this ->email; 72 } 73 74 public function rename (UserName $newName) : void 75 {76 if ($this ->name->equals($newName)) { 77 return ; 78 } 79 80 $this ->name = $newName; 81 } 82 83 public function changeEmail (Email $newEmail) : void 84 {85 if ($this ->email->equals($newEmail)) { 86 return ; 87 } 88 89 $this ->email = $newEmail; 90 } 91 }
Detaily implementace:
extends AggregateRoot, bez final. Bázová třída poskytuje record()
a releaseEvents() – sdílené chování pro všechny agregáty, ne duplicitní
kopii v každé entitě. final patří hodnotovým objektům; entity mapované
Doctrine zůstávají ne-final, protože lazy ghost proxy z entity dědí
(rozbor v kapitole Návrh agregátu ).
Privátní konstruktor + factory register(). Jediná legální cesta vytvoření.
Kdyby přibyla další kategorie (importovaný uživatel z LDAP), přidá se další
factory, ne přepínač uvnitř konstruktoru. Událost UserRegistered se nahrává
ve factory, ne v konstruktoru – konstruktorem prochází i rekonstituce
uloženého agregátu a ta žádnou událost vyvolat nesmí.
VO uloženy přímo, ne jako primitivy. UserId, Email, UserName
a HashedPassword jsou typy vlastností. Doctrine je hydratuje přes custom typy
(user_id, email_vo) nebo #[ORM\Embedded]. Žádné re-validace v getterech.
#[ORM\Version] pro optimistický zámek. Souběžná modifikace agregátu
skončí výjimkou OptimisticLockException, kterou aplikační vrstva přeloží na retry.
Názvy metod z Ubiquitous Language. rename() místo setName(),
changeEmail() místo updateEmail(). Doménový jazyk, ne CRUD slovník.
§
Poznámka
Proč VO ukládáme přímo, ne jako primitivy
V dřívějších verzích tohoto průvodce se v entitě VO ukládaly jako string a getter
vracel new UserId($this->id). Důvod byl Doctrine hydration: Doctrine při čtení
z DB nastavuje vlastnosti přímo, bez konstruktoru, takže UserId jako typ vlastnosti
by skončilo na TypeError.
Doctrine ORM 3 to ale řeší přes custom DBAL types (UserIdType, EmailType)
a #[ORM\Embedded]. Při načítání Doctrine sám zavolá custom type, který
vyrobí instanci VO, a vlastnost dostane správný objektový typ. Kód agregátu pak
pracuje výhradně s typovými hodnotami, bez re-konstrukce při každém volání getteru.
Detaily a registrace v sekci Doctrine custom types .
10.04 Implementace hodnotových objektů#
V Symfony 8 se hodnotový objekt zapisuje jako final readonly PHP třída.
Validace patří do konstruktoru, rovnost se počítá z hodnot, ne z identity.
Detailní rozbor sémantiky VO je v kapitole Základní koncepty :
◢
Vzor
Příklad: hodnotový objekt Email
php
src/UserManagement/Domain/ValueObject/Email.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \ValueObject ;6 7 final readonly class Email 8 {9 public function __construct ( 10 public string $value, 11 ) {12 if (!filter_var($value, FILTER_VALIDATE_EMAIL)) { 13 throw new \InvalidArgumentException( 14 sprintf('Neplatný formát e-mailu: "%s".' , $value), 15 ); 16 } 17 } 18 19 public static function fromUserInput (string $raw) : self 20 {21 22 23 24 return new self (mb_strtolower(trim($raw))); 25 } 26 27 public function equals (self $other) : bool 28 {29 return $this ->value === $other->value; 30 } 31 32 public function __toString () : string 33 {34 return $this ->value; 35 } 36 }
!
Pozor
Limity FILTER_VALIDATE_EMAIL
PHP FILTER_VALIDATE_EMAIL ověřuje syntaxi podle zjednodušeného RFC 5322.
Drobnosti, které je dobré znát:
Odmítá i některé technicky platné adresy – a@b (doména bez tečky,
např. user@localhost) neprojde, přestože RFC ji připouští.
Nepouští IDN domény (uživatel@české-domény.cz)
bez explicitního převodu přes idn_to_ascii().
Neověřuje existenci schránky. Validní syntaxe ≠ doručitelná adresa.
V doménové vrstvě tedy validujeme syntakticky . Pravdivost potvrdí až
e-mail s ověřovacím odkazem (out-of-band proces), který v doméně modeluje
agregát EmailVerification nebo událost EmailVerificationRequested.
Pro pokročilejší syntaktickou validaci existuje knihovna
egulias/email-validator ,
kterou používá i Symfony Validator pod kapotou.
◢
Vzor
Příklad: hodnotový objekt UserName s vlastními invarianty
php
src/UserManagement/Domain/ValueObject/UserName.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \ValueObject ;6 7 use Doctrine \ORM \Mapping as ORM ;8 9 10 final class UserName 11 {12 public const MIN_LENGTH = 2 ; 13 public const MAX_LENGTH = 100 ; 14 15 16 public readonly string $value; 17 18 public function __construct (string $value) 19 {20 $trimmed = trim($value); 21 $length = mb_strlen($trimmed); 22 23 if ($length < self ::MIN_LENGTH || $length > self ::MAX_LENGTH) { 24 throw new \InvalidArgumentException(sprintf( 25 'Jméno musí mít %d–%d znaků (zadáno %d).' , 26 self ::MIN_LENGTH, 27 self ::MAX_LENGTH, 28 $length, 29 )); 30 } 31 32 $this ->value = $trimmed; 33 } 34 35 public function equals (self $other) : bool 36 {37 return $this ->value === $other->value; 38 } 39 40 public function __toString () : string 41 {42 return $this ->value; 43 } 44 }
UserName ukazuje plnou cenu hodnotového objektu: invariant „jméno není prázdné
a má rozumnou délku“ je vynucen typem. Volající kód nemá šanci vložit prázdný
string – pokud by to zkusil, dostane výjimku v konstruktoru, ne až v repozitáři.
#[ORM\Embeddable] říká Doctrine, že VO se ukládá jako sloupec ve stejné tabulce
jako vlastník (žádná samostatná tabulka pro VO).
Třetí typ hodnotového objektu je identita agregátu. Generuje se v aplikaci,
ne v databázi – handler tak zná ID ještě před uložením:
◢
Vzor
Příklad: UserId s generováním přes symfony/uid
php
src/UserManagement/Domain/ValueObject/UserId.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \ValueObject ;6 7 use Symfony \Component \Uid \Uuid ;8 9 final readonly class UserId 10 {11 public function __construct ( 12 public string $value, 13 ) {14 if (!Uuid::isValid($value)) { 15 throw new \InvalidArgumentException( 16 sprintf('Neplatné UserId: "%s".' , $value), 17 ); 18 } 19 } 20 21 public static function generate () : self 22 {23 return new self ((string) Uuid::v7()); 24 } 25 26 public function equals (self $other) : bool 27 {28 return $this ->value === $other->value; 29 } 30 }
Uuid::v7() z balíčku symfony/uid vrací časově řaditelné UUID, vhodné
jako primární klíč (sekvenční zápisy nedrobí B-tree index). Stejný vzor platí
pro OrderId nebo PaymentId; hodnotu vždy zpřístupňuje public readonly
property $value, žádná metoda value().
10.05 Implementace repozitářů#
Repozitář se v Symfony 8 dělí na dvojici: rozhraní v doméně + Doctrine implementace v infrastruktuře. Doménový kód se opírá pouze o rozhraní, výměna persistence se odehraje v jediném souboru:
◢
Vzor
Příklad: Implementace repozitáře v Symfony 8
php
src/UserManagement/Domain/Repository/UserRepository.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \Repository ;6 7 use App \UserManagement \Domain \Model \User ;8 use App \UserManagement \Domain \ValueObject \Email ;9 use App \UserManagement \Domain \ValueObject \UserId ;10 11 interface UserRepository 12 {13 public function save (User $user) : void ; 14 15 public function findById (UserId $id) : ?User ; 16 17 public function findByEmail (Email $email) : ?User ; 18 }
php
src/UserManagement/Infrastructure/Repository/DoctrineUserRepository.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Infrastructure \Repository ;6 7 use App \UserManagement \Domain \Model \User ;8 use App \UserManagement \Domain \Repository \UserRepository ;9 use App \UserManagement \Domain \ValueObject \Email ;10 use App \UserManagement \Domain \ValueObject \UserId ;11 use Doctrine \ORM \EntityManagerInterface ;12 13 final class DoctrineUserRepository implements UserRepository 14 {15 public function __construct ( 16 private readonly EntityManagerInterface $em, 17 ) {}18 19 public function save (User $user) : void 20 {21 22 23 24 25 $this ->em->persist($user); 26 } 27 28 public function findById (UserId $id) : ?User 29 {30 return $this ->em->find(User::class, $id->value); 31 } 32 33 public function findByEmail (Email $email) : ?User 34 {35 return $this ->em->getRepository(User::class) 36 ->findOneBy(['email' => $email->value]); 37 } 38 }
DoctrineUserRepository implementuje doménové rozhraní UserRepository přes Doctrine ORM.
save() jen zařadí agregát k uložení přes persist(); flush a commit provede
transakční middleware na command busu, takže jeden use case odpovídá jedné transakci.
Publikace doménových událostí je samostatný krok aplikační vrstvy – proběhne až po
commitu, aby příjemci viděli uložený stav.
!
Pozor
Limit naivní publikace událostí
Nabízí se vypustit události hned po uložení agregátu: releaseEvents() a synchronní
dispatch() v handleru. Mezi commitem a dispatchem ale může proces spadnout: OOM kill,
deploy restart, výpadek brokera. Agregát pak v databázi je, ale událost se nikdy
nedoručí – z pohledu ostatních kontextů se registrace neudála. Pro vývoj a méně
důležité události je to přijatelné. Jakmile na události závisí jiný Bounded Context
nebo platební tok, produkčním řešením je Outbox Pattern: událost se zapíše do stejné
DB transakce jako agregát a samostatný worker ji doručí s retry. Detail v kapitole
Outbox Pattern .
!
Pozor
Dvojí transakce: repozitář vs. middleware
Nabízí se obalit tělo save() ještě vlastní transakcí přes
wrapInTransaction(). Pokud ale command bus
používá doctrine_transaction middleware (viz aplikační služby ),
vzniknou dvě vrstvy transakcí: middleware otevře vnější, repozitář vnitřní
přes savepoint. Commit point přestane být zřejmý a rollback vnitřní vrstvy
nezruší vnější zápisy. Transakci má vlastnit jedna vrstva – doporučená volba
je middleware na command busu, který obalí celý handler, zavolá flush()
a commitne. Repozitář pak jen volá persist(), transakci ani flush neřídí.
S middlewarem se mění i okamžik commitu: flush() zapíše SQL, commit provede
až middleware po doběhnutí handleru. Synchronní dispatch v handleru tedy běží
uvnitř otevřené transakce – a pokud se transakce poté vrátí zpět (rollback), listenery už
reagovaly na událost, která se nikdy nestala. Spolehlivé řešení je opět
Outbox Pattern : událost se commituje spolu s agregátem.
10.06 Persisted Object Pattern – čistá DDD varianta#
Pokud trváte na tom, že doménová vrstva nesmí obsahovat ani metadata
o persistenci, korektní cesta není XML mapping (také „znečištěné“, jen jiným
formátem), ale Persisted Object Pattern . Jde o variantu vzoru Data Mapper (Fowler, PoEAA , 2002);
v DDD kontextu ji rozebírá Vladimir Khorikov v sérii blogpostů „Persistence model“ a Vaughn Vernon v IDDD , kap. 12.
Idea: doménová třída zůstane POPO bez atributů. Vedle ní v infrastrukturní
vrstvě existuje samostatná persistence třída se všemi Doctrine atributy.
Dva mappery (one-way každým směrem) překládají mezi nimi.
◢
Vzor
Příklad: doména POPO + persistence model + mapper
php
src/UserManagement/Domain/Model/User.php (POPO – bez atributů)
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \Model ;6 7 use App \SharedKernel \Domain \AggregateRoot ;8 use App \UserManagement \Domain \ValueObject \Email ;9 use App \UserManagement \Domain \ValueObject \HashedPassword ;10 use App \UserManagement \Domain \ValueObject \UserId ;11 use App \UserManagement \Domain \ValueObject \UserName ;12 13 final class User extends AggregateRoot 14 {15 private function __construct ( 16 public readonly UserId $id, 17 private UserName $name, 18 private Email $email, 19 private readonly HashedPassword $hashedPassword, 20 public readonly \DateTimeImmutable $createdAt, 21 ) {}22 23 public static function register () : self { } 24 public static function reconstitute ( 25 UserId $id, 26 UserName $name, 27 Email $email, 28 HashedPassword $hashedPassword, 29 \DateTimeImmutable $createdAt, 30 ) : self {31 32 33 return new self ($id, $name, $email, $hashedPassword, $createdAt); 34 } 35 36 37 }
php
src/UserManagement/Infrastructure/Persistence/Doctrine/UserPersistenceModel.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Infrastructure \Persistence \Doctrine ;6 7 use Doctrine \ORM \Mapping as ORM ;8 9 10 11 class UserPersistenceModel 12 {13 14 15 public string $id; 16 17 18 public string $name; 19 20 21 public string $email; 22 23 24 public string $passwordHash; 25 26 27 public \DateTimeImmutable $createdAt; 28 29 30 31 public int $version = 1 ; 32 }
php
src/UserManagement/Infrastructure/Persistence/Doctrine/UserMapper.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Infrastructure \Persistence \Doctrine ;6 7 use App \UserManagement \Domain \Model \User ;8 use App \UserManagement \Domain \ValueObject \Email ;9 use App \UserManagement \Domain \ValueObject \HashedPassword ;10 use App \UserManagement \Domain \ValueObject \UserId ;11 use App \UserManagement \Domain \ValueObject \UserName ;12 13 final class UserMapper 14 {15 public function toDomain (UserPersistenceModel $row) : User 16 {17 return User::reconstitute( 18 new UserId($row->id), 19 new UserName($row->name), 20 new Email($row->email), 21 HashedPassword::fromHash($row->passwordHash), 22 $row->createdAt, 23 ); 24 } 25 26 public function toPersistence (User $user) : UserPersistenceModel 27 {28 $model = new UserPersistenceModel(); 29 $model->id = $user->id->value; 30 $model->name = (string) $user->name(); 31 $model->email = $user->email()->value; 32 $model->passwordHash = $user->hashedPassword()->value; 33 $model->createdAt = $user->createdAt; 34 35 return $model; 36 } 37 }
§
Poznámka
Cena pure varianty
Persisted Object Pattern drží doménu úplně mimo ORM. Žádný atribut, žádný use Doctrine\…, žádná stopa po infrastruktuře. Cena:
2× kód. Doménová třída + persistence model + mapper. Pro každý agregát.
Mapování VO ručně. Custom typy z hlavní cesty zde nepoužijete – musí to dělat
mapper. U 5+ VO se kód mapperu rozrůstá.
Riziko driftu. Když přibude pole v doméně, musí přibýt v persistence modelu
i v mapperech. Žádný compiler to nehlídá.
Optimistický zámek je řešení navíc. #[ORM\Version] je v persistence modelu;
doména User musí přijmout version jako parametr reconstitute(), nebo
se spolehnout na infrastrukturu, že verzi sleduje sama.
Update vyžaduje find-and-copy. toPersistence() výše vytváří novou instanci
s version = 1 – to stačí pro insert. Při update musí repozitář nejprve načíst
existující UserPersistenceModel a přepsat její pole; nová instance by
kolidovala s primárním klíčem a vynulovala optimistický zámek.
Doporučení: použít Persisted Object jen v kontextech, kde je oddělení
opravdu důležité (Core Domain s vysokou hodnotou, dlouhodobá údržba, plán
na výměnu persistence). Pro většinu Bounded Contextů jsou atributy přijatelný kompromis.
V dalších příkladech v tomto průvodci pokračujeme s atributy přímo na agregátech.
Persisted Object Pattern dále nerozvíjíme – principy jsou identické, jen vyžadují
explicitní mapper na každý agregát.
10.07 Doctrine custom types pro Value Objects#
Sekce Implementace entit deklaruje vlastnosti přímo typu Email
nebo UserId. Tuto hydrataci zajišťuje Doctrine custom type – konvertor
mezi databázovým primitivem a hodnotovým objektem. Zde je jeho implementace
a registrace.
◢
Vzor
Příklad: Doctrine custom type pro Email
php
src/UserManagement/Infrastructure/Doctrine/Type/EmailType.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Infrastructure \Doctrine \Type ;6 7 use App \UserManagement \Domain \ValueObject \Email ;8 use Doctrine \DBAL \Platforms \AbstractPlatform ;9 use Doctrine \DBAL \Types \StringType ;10 11 final class EmailType extends StringType 12 {13 public const NAME = 'email_vo' ; 14 15 public function convertToPHPValue (mixed $value, AbstractPlatform $platform) : ?Email 16 {17 if ($value === null ) { 18 return null ; 19 } 20 21 return new Email((string) $value); 22 } 23 24 public function convertToDatabaseValue (mixed $value, AbstractPlatform $platform) : ?string 25 {26 if ($value === null ) { 27 return null ; 28 } 29 30 return $value instanceof Email ? $value->value : (string) $value; 31 } 32 33 }
◢
Vzor
Registrace custom type v Symfony
yaml
config/packages/doctrine.yaml
Kopírovat
1 2 doctrine: 3 dbal: 4 types: 5 email_vo: 6 class: App\UserManagement\Infrastructure\Doctrine\Type\EmailType 7 user_id: 8 class: App\UserManagement\Infrastructure\Doctrine\Type\UserIdType
php
src/UserManagement/Domain/Model/User.php (použití typu)
Kopírovat
1 2 3 private Email $email;
XML mapping (User.orm.xml) dokáže totéž bez atributů ve třídě, doménu od ORM
ale neoddělí – jen přesune metadata do jiného formátu. Kdo chce striktní oddělení,
najde řešení v sekci Persisted Object Pattern .
§
Poznámka
Kdy se bez custom type obejdete
Custom type je v tomto průvodci výchozí cesta – entita pracuje přímo s VO,
bez re-konstrukce v getterech. Primitivní ukládání (string vlastnost, getter
vrací new Email($this->email)) ušetří jednu třídu na typ. Hodí se pro prototyp
nebo první kontakt s DDD; s rostoucím počtem VO se vyplatí přejít na custom typy.
10.08 PHP 8.1+ Enums pro stavové typy#
Stav objednávky, role uživatele, priorita úkolu – konečné množiny hodnot, které se dřív modelovaly konstantami ve třídě,
mají od PHP 8.1 nativní typ: enum. Překlep v názvu case odhalí statická analýza, neznámou hodnotu odmítne typová kontrola za běhu.
◢
Vzor
Příklad: Backed enum pro stav objednávky
php
src/OrderManagement/Domain/ValueObject/OrderStatus.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \OrderManagement \Domain \ValueObject ;6 7 enum OrderStatus: string 8 { 9 case DRAFT = 'draft' ; 10 case CONFIRMED = 'confirmed' ; 11 case PAID = 'paid' ; 12 case SHIPPED = 'shipped' ; 13 case DELIVERED = 'delivered' ; 14 case CANCELLED = 'cancelled' ; 15 16 17 18 19 20 21 public function allowedTransitions () : array 22 {23 return match ($this ) { 24 self ::DRAFT => [self ::CONFIRMED, self ::CANCELLED], 25 self ::CONFIRMED => [self ::PAID, self ::CANCELLED], 26 self ::PAID => [self ::SHIPPED, self ::CANCELLED], 27 self ::SHIPPED => [self ::DELIVERED], 28 self ::DELIVERED => [], 29 self ::CANCELLED => [], 30 }; 31 } 32 33 public function canTransitionTo (self $target) : bool 34 {35 return in_array($target, $this ->allowedTransitions(), true ); 36 } 37 }
◢
Vzor
Příklad: Použití enum v doménové entitě
php
src/OrderManagement/Domain/Model/Order.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \OrderManagement \Domain \Model ;6 7 use App \OrderManagement \Domain \Event \OrderCreated ;8 use App \OrderManagement \Domain \Event \OrderStatusChanged ;9 use App \OrderManagement \Domain \ValueObject \OrderId ;10 use App \OrderManagement \Domain \ValueObject \OrderStatus ;11 use App \SharedKernel \Domain \AggregateRoot ;12 13 final class Order extends AggregateRoot 14 {15 private OrderStatus $status; 16 private readonly \DateTimeImmutable $createdAt; 17 18 private function __construct ( 19 public readonly OrderId $id, 20 ) {21 $this ->status = OrderStatus::DRAFT; 22 $this ->createdAt = new \DateTimeImmutable(); 23 } 24 25 public static function place (OrderId $id) : self 26 {27 $order = new self ($id); 28 $order->record(new OrderCreated($id)); 29 30 return $order; 31 } 32 33 public function status () : OrderStatus 34 {35 return $this ->status; 36 } 37 38 public function transitionTo (OrderStatus $newStatus) : void 39 {40 if (!$this ->status->canTransitionTo($newStatus)) { 41 throw new \DomainException(sprintf( 42 'Nelze přejít ze stavu "%s" do stavu "%s".' , 43 $this ->status->value, 44 $newStatus->value 45 )); 46 } 47 48 $oldStatus = $this ->status; 49 $this ->status = $newStatus; 50 51 $this ->record(new OrderStatusChanged($this ->id, $oldStatus, $newStatus)); 52 } 53 }
§
Poznámka
Kdy použít enum a kdy plnohodnotný hodnotový objekt?
Enum – pro jednoduché konečné stavy, kde hodnota je jedna z pevně daných variant: OrderStatus, UserRole, TaskPriority, Currency. Enums podporují metody, takže lze zapouzdřit i přechodovou logiku (viz allowedTransitions()).
Plnohodnotný hodnotový objekt (Value Object) – pro komplexní typy, které vyžadují validaci, formátování nebo aritmetiku: Money (částka + měna + zaokrouhlování), Email (validace formátu), Address (více polí), DateRange (interval s logikou překrývání).
Obecné pravidlo: pokud typ má konečný, předem známý počet hodnot a nepotřebuje složitou vnitřní logiku, je enum správná volba. Pokud typ obsahuje libovolné hodnoty, validaci nebo výpočty, je namístě hodnotový objekt.
§
Poznámka
Kdy sáhnout po symfony/workflow
Ruční automat v enumu není jediná možnost – Symfony nabízí komponentu Workflow.
Ta přidává vizualizaci stavového grafu (workflow:dump → Graphviz), guard eventy
napojené na služby (Voter, feature flag) a audit trail přechodů. Vyplatí se
u procesů s mnoha stavy, které potřebuje vidět i ne-vývojář. V doménovém modelu
bývá ruční automat čistší: komponenta tahá závislost na frameworku do domény
a přesouvá přechodová pravidla z agregátu do YAML konfigurace. Enum s match
drží pravidla tam, kde je vynucuje typový systém.
10.09 Doménové služby (a kdy je nepoužít )#
Doménová služba zapouzdřuje pravidlo, které přirozeně nepatří žádnému agregátu
ani hodnotovému objektu – typicky operaci nad dvěma a více agregáty
(MoneyTransferService mezi dvěma účty) nebo bezstavový výpočet vyžadující
externí zdroj (kurzovní převod, kalkulace daně podle jurisdikce).
Před sáhnutím po doménové službě stojí vždy jedna otázka: nepatří to do agregátu?
Pravidlo „lze platit jen confirmed objednávku“ je čistý invariant agregátu Order –
jen Order zná svůj stav a jen on smí ten stav měnit. Domain service na to
je anti-vzor, který oslabuje agregát a vede k anemickému modelu.
✕
Anti-vzor
Anti-vzor: doménová služba pro invariant jednoho agregátu
php
src/OrderManagement/Domain/Service/PaymentService.php (ANTI-VZOR)
Kopírovat
1 <?php 2 3 4 5 final class PaymentService 6 {7 public function processPayment (Order $order, Money $amount, PaymentMethod $pm) : Payment 8 {9 if ($order->status() !== OrderStatus::CONFIRMED) { 10 throw new \DomainException('Cannot process payment for a non-confirmed order' ); 11 } 12 13 return new Payment(PaymentId::generate(), $order->id(), $amount, $pm); 14 } 15 }
Co se tu pokazilo:
Invariant uniká agregátu. Order neví, že někdo kontroluje jeho stav
zvenčí. Když přibude nový stav (REFUNDED), musíte sáhnout do servicu,
ne do agregátu.
Anemický model. Order má getter status() jako veřejné API,
což je signál, že vnitřní stav je manipulovatelný zvenčí.
Otevřená cesta k inkonzistenci. Nikdo nezabrání druhé službě, aby
obešla pravidlo a vytvořila Payment přímo.
◢
Vzor
Správně: invariant uvnitř agregátu, factory metoda na výsledek
php
src/OrderManagement/Domain/Model/Order.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \OrderManagement \Domain \Model ;6 7 use App \OrderManagement \Domain \Event \PaymentRecorded ;8 use App \OrderManagement \Domain \Exception \InvalidOrderStateTransitionException ;9 use App \OrderManagement \Domain \ValueObject \Money ;10 use App \OrderManagement \Domain \ValueObject \OrderStatus ;11 use App \OrderManagement \Domain \ValueObject \PaymentId ;12 use App \OrderManagement \Domain \ValueObject \PaymentMethod ;13 14 final class Order extends AggregateRoot 15 {16 17 18 public function recordPayment (Money $amount, PaymentMethod $method) : Payment 19 {20 if ($this ->status !== OrderStatus::CONFIRMED) { 21 throw InvalidOrderStateTransitionException::cannotTransition( 22 $this ->status->value, 23 OrderStatus::PAID->value, 24 ); 25 } 26 27 if (!$amount->equals($this ->totalAmount())) { 28 throw new \DomainException('Payment amount does not match order total.' ); 29 } 30 31 $this ->status = OrderStatus::PAID; 32 33 $payment = Payment::record(PaymentId::generate(), $this ->id, $amount, $method); 34 35 $this ->record(new PaymentRecorded($this ->id, $payment->id(), $amount)); 36 37 return $payment; 38 } 39 }
Order::recordPayment() zapouzdřuje pravidlo i přechod stavu uvnitř agregátu.
Jediný způsob, jak vytvořit Payment pro danou objednávku, vede přes tuto metodu –
což znamená, že invariant „platit lze jen confirmed objednávku“ je vynucen
typovým systémem, ne nadějí, že někdo zavolá správnou službu. Aplikační handler
pak má triviální koordinační roli. Pojmy command a handler vysvětluje
sekce o aplikačních službách , podrobně kapitola CQRS :
◢
Vzor
Aplikační handler nad agregátem
php
src/OrderManagement/Application/Command/RecordPaymentHandler.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \OrderManagement \Application \Command ;6 7 use App \OrderManagement \Domain \Repository \OrderRepository ;8 use App \OrderManagement \Domain \Repository \PaymentRepository ;9 use App \OrderManagement \Domain \ValueObject \Money ;10 use App \OrderManagement \Domain \ValueObject \OrderId ;11 use App \OrderManagement \Domain \ValueObject \PaymentMethod ;12 use Symfony \Component \Messenger \Attribute \AsMessageHandler ;13 14 15 final class RecordPaymentHandler 16 {17 public function __construct ( 18 private readonly OrderRepository $orders, 19 private readonly PaymentRepository $payments, 20 ) {}21 22 public function __invoke (RecordPayment $cmd) : void 23 {24 $order = $this ->orders->get(OrderId::fromString($cmd->orderId)); 25 26 $payment = $order->recordPayment( 27 Money::fromAmount($cmd->amount, $cmd->currency), 28 PaymentMethod::from($cmd->method), 29 ); 30 31 $this ->orders->save($order); 32 $this ->payments->save($payment); 33 } 34 }
§
Poznámka
Kdy doménová služba opravdu dává smysl
Doménová služba je správná volba ve třech přesně vymezených případech:
Operace nad 2+ agregáty. Klasický MoneyTransferService::transfer($from, $to, $amount)
– pravidlo „součet zůstatků je konstantní“ se týká dvou účtů a nepatří jednomu
ani druhému. (Pozor: stejně se ukládá v jedné transakci na jeden agregát –
viz agregát = transakční hranice .)
Bezstavový výpočet s externí znalostí. Daňová sazba podle jurisdikce a typu
zboží, převod měn podle aktuálního kurzu. Logika je čistě doménová, ale
vstupy přicházejí zvenčí.
Generická doménová operace bez přirozeného vlastníka. „Vyčisti expirované
rezervace starší než X dnů“ – akce nad množinou agregátů, kde žádný z nich
není přirozený vlastník pravidla.
Ve všech ostatních případech: pravidlo patří do agregátu, hodnotového objektu nebo
specifikace (Specification Pattern ).
10.10 Specification Pattern#
Specification Pattern (Eric Evans, DDD , kap. 9) zapouzdřuje doménové pravidlo
do samostatného objektu s jedinou metodou isSatisfiedBy(). Pravidlo „objednávka
je způsobilá k expedici“ pak existuje na jednom místě – stejná specifikace slouží
validaci v agregátu, filtrování kolekcí i výběru v repozitáři. Malá pravidla se
skládají kombinátory and(), or() a not() do složitějších, bez kopírování
podmínek po kódu.
Plný výklad včetně implementace v PHP, kombinátorů a double-dispatch napojení
na Doctrine najdete v kapitole
Specification Pattern .
10.11 Implementace doménových událostí#
Doménová událost je fakt minulého času: registrace proběhla, platba byla zaznamenána. Kód ji v Symfony 8 modeluje jako neměnnou PHP třídu, kterou agregát publikuje při změně stavu:
◢
Vzor
Příklad: Implementace doménové události v Symfony 8
php
src/UserManagement/Domain/Event/UserRegistered.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \Event ;6 7 use App \UserManagement \Domain \ValueObject \Email ;8 use App \UserManagement \Domain \ValueObject \UserId ;9 10 final readonly class UserRegistered 11 {12 public string $userId; 13 public string $email; 14 15 public function __construct ( 16 UserId $userId, 17 Email $email, 18 public \DateTimeImmutable $occurredAt, 19 ) {20 21 $this ->userId = $userId->value; 22 $this ->email = $email->value; 23 } 24 }
UserRegistered nese minimum potřebné pro obnovu kontextu: ID uživatele, e-mail a čas registrace.
Listenery i externí konzumenti z těchto tří hodnot poskládají reakci, aniž by sahali zpět do UserRepository.
Konstruktor odpovídá volání record(new UserRegistered(...)) ve factory
User::register() v sekci Implementace entit .
§
Poznámka
Symfony EventDispatcher vs. Messenger pro doménové události
Symfony nabízí dva mechanismy pro „něco se stalo“:
EventDispatcher (EventDispatcherInterface) – synchronní,
in-process. Listenery se provedou okamžitě v témž PHP požadavku, ve sdíleném
paměťovém prostoru. Bez serializace, bez síťové cesty tam a zpět.
Messenger (MessageBusInterface) – může být synchronní i asynchronní.
Podporuje transporty (RabbitMQ, Redis, Doctrine outbox), retry strategii
a serializaci zprávy. Příjemce může běžet v jiném procesu, jiném serveru.
Volba podle role příjemce:
In-context, in-request listenery (read model uvnitř téhož kontextu,
audit log, cache invalidace uvnitř téhož commitu) → EventDispatcher .
Žádná serializace, listenery vidí stejný EntityManager, stejnou transakci.
Cross-context komunikace (publikace události mimo Bounded Context, kterou
zpracuje jiný kontext / služba / projekce) → Messenger . Zpráva dorazí
do brokera, jiný kontext si ji odebere. Spolehlivé doručení napříč kontexty
zajišťuje v produkci Outbox Pattern .
Anti-vzor: používat Messenger jako náhradu za EventDispatcher uvnitř téhož
kontextu, protože „je to flexibilnější“. Cena: každá zpráva projde JSON serializací,
ztráta typů, ztráta transakční koheze, nutnost správy transportů. Mechanismus se volí
podle hranice, kterou událost překračuje – ne podle hypotetické budoucí potřeby.
10.12 Strategie zpracování chyb v DDD#
V DDD se výjimky liší podle vrstvy, ve které vznikají. Každá vrstva
má jiné odpovědnosti a jiný typ chyb:
§
Poznámka
Typy výjimek podle vrstvy
Doménové výjimky – porušení doménových pravidel a invariantů.
Vyhazuje je doménový model (entity, agregáty, value objects).
Příklady: OrderCannotBeConfirmedException,
InsufficientFundsException, InvalidEmailException.
Aplikační výjimky – chyby na úrovni use case.
Vyhazují je command/query handlery.
Příklady: UserNotFoundException,
DuplicateEmailException.
Infrastrukturní výjimky – technické chyby (databáze, síť, souborový systém).
Vznikají v infrastrukturní vrstvě a zachytává je aplikační vrstva.
Příklady: ConnectionException, TimeoutException.
◢
Vzor
Příklad: Vlastní doménová výjimka
php
src/OrderManagement/Domain/Exception/InvalidOrderStateTransitionException.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \OrderManagement \Domain \Exception ;6 7 8 9 10 final class InvalidOrderStateTransitionException extends \DomainException 11 {12 public static function cannotTransition (string $from, string $to) : self 13 {14 return new self (sprintf( 15 'Nelze přejít ze stavu "%s" do stavu "%s".' , 16 $from, 17 $to, 18 )); 19 } 20 }
!
Pozor
Doporučení pro výjimky v DDD
Doménové výjimky by měly dědit z \DomainException – tím signalizují, že jde o porušení doménového pravidla, ne o technickou chybu.
Statické factory metody (cannotTransition()) drží vytváření výjimek čitelné a konzistentní.
Nepropagujte infrastrukturní výjimky do doménové vrstvy – repozitáře by je měly zachytit a přeložit na doménové výjimky.
Kontrolery by měly zachytávat doménové výjimky a překládat je na HTTP odpovědi (400, 404, 409).
10.13 Implementace aplikačních služeb#
Tato sekce poprvé skládá dohromady trojici command – handler – bus, proto
krátké vysvětlení pojmů. Command je neměnný objekt popisující záměr:
„zaregistruj uživatele s tímto jménem a e-mailem“. Nemá chování, nese jen data
use case. Handler je třída, která command vykoná – načte agregáty, zavolá
doménovou metodu, uloží výsledek.
Command bus oba spojuje. Volající předá command busu (MessageBusInterface
ze Symfony Messenger) a ten najde příslušný handler podle typu zprávy. Mezi
dispatch a handler se navíc vkládají middleware: validation spustí Symfony
Validator nad commandem, doctrine_transaction obalí handler databázovou
transakcí (podrobně v kapitole CQRS ).
Průvodce používá bus už zde, protože je to idiomatická Symfony cesta: kontroler
nezná handler, jen popis záměru. Stejný command lze později zpracovat asynchronně
bez zásahu do volajícího kódu. Plný výklad včetně oddělených busů pro commandy
a queries přináší kapitola CQRS .
Aplikační služba má tedy v Symfony 8 podobu command nebo query handleru. Načte agregáty přes repozitář,
zavolá doménovou metodu a zapíše výsledek – žádná doménová pravidla v ní nežijí:
◢
Vzor
Příklad: Implementace command handleru v Symfony 8
php
src/UserManagement/Registration/Command/RegisterUser.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Registration \Command ;6 7 use Symfony \Component \Validator \Constraints as Assert ;8 9 final readonly class RegisterUser 10 {11 public function __construct ( 12 #[Assert\NotBlank] 13 #[Assert\Length(min: 2 , max: 100 ) ] 14 public string $name, 15 16 #[Assert\NotBlank] 17 #[Assert\Email(mode: Assert\Email::VALIDATION_MODE_STRICT) ] 18 public string $email, 19 20 #[Assert\NotBlank] 21 #[Assert\Length(min: 12 ) ] 22 public string $password, 23 ) {}24 }
php
src/UserManagement/Registration/Command/RegisterUserHandler.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Registration \Command ;6 7 use App \UserManagement \Domain \Exception \DuplicateEmailException ;8 use App \UserManagement \Domain \Model \User ;9 use App \UserManagement \Domain \Repository \UserRepository ;10 use App \UserManagement \Domain \ValueObject \Email ;11 use App \UserManagement \Domain \ValueObject \HashedPassword ;12 use App \UserManagement \Domain \ValueObject \UserId ;13 use App \UserManagement \Domain \ValueObject \UserName ;14 use Doctrine \DBAL \Exception \UniqueConstraintViolationException ;15 use Doctrine \ORM \EntityManagerInterface ;16 use Symfony \Component \Messenger \Attribute \AsMessageHandler ;17 18 19 final readonly class RegisterUserHandler 20 {21 public function __construct ( 22 private UserRepository $userRepository, 23 private EntityManagerInterface $em, 24 ) {}25 26 public function __invoke (RegisterUser $command) : void 27 {28 $email = Email::fromUserInput($command->email); 29 30 $user = User::register( 31 UserId::generate(), 32 new UserName($command->name), 33 $email, 34 HashedPassword::fromPlainText($command->password), 35 ); 36 37 try { 38 $this ->userRepository->save($user); 39 40 41 42 43 $this ->em->flush(); 44 } catch (UniqueConstraintViolationException $e) { 45 46 47 48 throw DuplicateEmailException::with($email, $e); 49 } 50 } 51 }
!
Pozor
Race condition v naivní variantě s findByEmail()
V dřívějších verzích tohoto průvodce handler zjišťoval unikátnost přes
findByEmail() před save(). To je TOCTOU race : dvě paralelní
registrace se stejným e-mailem obě projdou checkem (databáze ještě neviděla zápis
té druhé) a obě se úspěšně uloží. Výsledek: dva uživatelé se stejným e-mailem.
Bezpečné řešení má dvě vrstvy:
DB unique constraint na sloupci email. Druhý INSERT
vyhodí UniqueConstraintViolationException. Toto je jediná
garance napříč souběžnými requesty.
Překlad na doménovou výjimku v command handleru (nebo lépe v repozitáři),
aby aplikační vrstva nemusela znát infrastrukturní typy.
Explicitní flush() v handleru je záměrná odchylka od pravidla „flush vlastní
middleware“ (viz dvojí transakce ): databáze
constraint vyhodnocuje až při flushi, a má-li se infrastrukturní výjimka přeložit
na doménovou ještě v handleru, musí flush proběhnout v jeho try bloku.
Middleware pak při commitu už jen potvrdí zapsané SQL.
Aplikační check přes findByEmail() můžete ponechat navíc pro hezčí
chybovou hlášku v běžném (ne-souběžném) případu – ale nikdy jako jedinou ochranu .
◢
Vzor
Příklad: doménová výjimka s factory metodou
php
src/UserManagement/Domain/Exception/DuplicateEmailException.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Domain \Exception ;6 7 use App \UserManagement \Domain \ValueObject \Email ;8 9 final class DuplicateEmailException extends \DomainException 10 {11 public static function with (Email $email, ?\Throwable $previous = null) : self 12 {13 return new self ( 14 sprintf('Uživatel s e-mailem "%s" již existuje.' , $email->value), 15 previous: $previous, 16 ); 17 } 18 }
◢
Vzor
Příklad: Implementace query handleru v Symfony 8
php
src/UserManagement/Profile/Query/GetUserProfile.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Profile \Query ;6 7 class GetUserProfile 8 {9 public function __construct ( 10 public readonly string $userId 11 ) {12 } 13 }
php
src/UserManagement/Profile/Query/GetUserProfileHandler.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Profile \Query ;6 7 use App \UserManagement \Domain \Repository \UserRepository ;8 use App \UserManagement \Domain \ValueObject \UserId ;9 use Symfony \Component \Messenger \Attribute \AsMessageHandler ;10 11 12 final readonly class GetUserProfileHandler 13 {14 public function __construct ( 15 private UserRepository $userRepository, 16 ) {}17 18 public function __invoke (GetUserProfile $query) : ?UserProfileViewModel 19 {20 $user = $this ->userRepository->findById(new UserId($query->userId)); 21 22 if ($user === null ) { 23 return null ; 24 } 25 26 return new UserProfileViewModel( 27 id: $user->id->value, 28 name: (string) $user->name(), 29 email: $user->email()->value, 30 createdAt: $user->createdAt, 31 ); 32 } 33 }
RegisterUserHandler a GetUserProfileHandler jsou aplikační služby (command a query handlery).
Koordinují use case a delegují doménovou logiku na entitu nebo doménovou službu.
§
Poznámka
Kde validovat: Symfony Validator vs. doménová validace
V DDD existují dva druhy validace, každý na jiné vrstvě:
Symfony Validator (aplikační vrstva) – validace vstupních dat
na úrovni Commands a Queries: formát e-mailu, délka jména, povinná pole.
Atributy #[Assert\Email], #[Assert\NotBlank] patří přímo
na command třídy. Tato validace chrání doménovou vrstvu před neplatnými vstupy.
Doménová validace (doménová vrstva) – doménová pravidla, která vynucují
entity, agregáty a value objects: „uživatel s tímto e-mailem již existuje“,
„objednávku nelze potvrdit bez položek“. Tato validace je součástí doménového modelu
a Symfony Validator na ní nesmí záviset.
Pravidlo: formát vynucuje hodnotový objekt vždy – je to jeho invariant.
Symfony Validator tutéž kontrolu opakuje na hraně aplikace, aby neplatný vstup
skončil srozumitelnou chybovou zprávou, ne doménovou výjimkou. Sémantická
pravidla („uživatel s tímto e-mailem již existuje“) patří výhradně doménové vrstvě.
10.14 Implementace kontrolerů#
Kontroler je adapter mezi HTTP a aplikační vrstvou. Smí: validovat formát vstupu,
transformovat ho na command/query, dispatchovat, přeložit doménovou výjimku
na HTTP odpověď. Nesmí: nést doménová pravidla, volat repozitáře přímo,
manipulovat s agregáty.
Symfony nabízí od verze 6.3 #[MapRequestPayload], který deserializuje a validuje
JSON požadavek přímo do typového commandu. Pro klasické HTML formuláře pak existuje
varianta #[MapRequestPayload(acceptFormat: 'form')] nebo Symfony Form.
◢
Vzor
Příklad: kontroler s MapRequestPayload (JSON API)
php
src/UserManagement/Registration/Controller/RegistrationController.php
Kopírovat
1 <?php 2 3 declare (strict_types=1 );4 5 namespace App \UserManagement \Registration \Controller ;6 7 use App \UserManagement \Domain \Exception \DuplicateEmailException ;8 use App \UserManagement \Registration \Command \RegisterUser ;9 use Symfony \Component \HttpFoundation \JsonResponse ;10 use Symfony \Component \HttpFoundation \Response ;11 use Symfony \Component \HttpKernel \Attribute \MapRequestPayload ;12 use Symfony \Component \Messenger \Exception \HandlerFailedException ;13 use Symfony \Component \Messenger \MessageBusInterface ;14 use Symfony \Component \Routing \Attribute \Route ;15 16 final class RegistrationController 17 {18 public function __construct ( 19 private readonly MessageBusInterface $commandBus, 20 ) {}21 22 23 public function register ( 24 #[MapRequestPayload] RegisterUser $command, 25 ) : Response {26 try { 27 $this ->commandBus->dispatch($command); 28 } catch (HandlerFailedException $e) { 29 foreach ($e->getWrappedExceptions() as $wrapped) { 30 if ($wrapped instanceof DuplicateEmailException) { 31 return new JsonResponse( 32 ['error' => $wrapped->getMessage()], 33 Response::HTTP_CONFLICT, 34 ); 35 } 36 } 37 38 throw $e; 39 } 40 41 return new JsonResponse(['status' => 'created' ], Response::HTTP_CREATED); 42 } 43 }
MapRequestPayload převezme deserializaci, validaci přes Symfony Validator
(atributy #[Assert\…] na commandu) i překlad chyby validace na HTTP 422.
Kontroler tak má jen tři odpovědnosti: dispatch, mapování doménových výjimek
na HTTP, návrat odpovědi.
!
Pozor
Messenger balí výjimky
Častá past: catch (DuplicateEmailException) kolem dispatch() nikdy nechytí
nic. Synchronní Messenger každou výjimku z handleru zabalí do
HandlerFailedException – původní typ se na catch blok nepropaguje. Zabalené
výjimky zpřístupňuje getWrappedExceptions(); je jich pole, protože jedna
zpráva může mít víc handlerů. Kontroler proto chytá obálku, projde zabalené
výjimky a na známé doménové typy reaguje HTTP odpovědí. Vše ostatní pošle dál –
ticho po neznámé chybě by maskovalo skutečné selhání. Kdo nechce iteraci
opakovat v každém kontroleru, napíše dekorátor command busu, který první
zabalenou výjimku rozbalí a vyhodí znovu. Ani HandleTrait výjimky
nerozbaluje – vrací sice návratovou hodnotu handleru (z HandledStamp),
ale HandlerFailedException propouští zabalenou stejně jako přímý dispatch.
§
Poznámka
Adresáře Form/ ve struktuře projektu drží FormType
pro HTML formuláře. Zásadní rozhodnutí je data_class: formulář se váže
na command (DTO), nikdy na doménovou entitu. Form komponenta totiž nastavuje
vlastnosti napřímo a obchází factory metody i invarianty agregátu – rozepsaný
formulář by držel User v nekonzistentním stavu. Tok je stejný jako u JSON
API: Form naplní RegisterUser, kontroler ho dispatchne, handler teprve
vytvoří agregát. U readonly commandu s konstruktorem poslouží empty_data
callback, který instanci složí z odeslaných polí. Validace zůstává na
#[Assert\…] atributech commandu, formulář ji přebírá automaticky.
§
Poznámka
Symfony idiomy: #[AsAlias] pro repozitáře
Místo aliasování v services.yaml můžete od Symfony 6.3+ použít atribut
#[AsAlias] přímo na implementaci:
php
src/UserManagement/Infrastructure/Repository/DoctrineUserRepository.php (s AsAlias)
Kopírovat
1 <?php 2 3 use App \UserManagement \Domain \Repository \UserRepository ;4 use Symfony \Component \DependencyInjection \Attribute \AsAlias ;5 6 7 final class DoctrineUserRepository implements UserRepository 8 {9 10 }
DI Container automaticky zaregistruje DoctrineUserRepository jako alias na
rozhraní UserRepository. services.yaml zůstane čistý, závislosti zůstanou
v jednom souboru s implementací. Pro většinu projektů je to preferovaná cesta.
Kontroler je tenký, takže těžiště testů leží pod ním. Agregáty se testují jako
čistý PHP bez kernelu. Aplikační handlery, které se opírají o repozitář,
pokrývá kernel test s testovací databází – jen reálná DB ověří unique
constraint a transakční chování, in-memory mock je negarantuje. Konkrétní
testy po vrstvách rozebírá kapitola Testování DDD .
Mimo kontroler zůstává i autorizace; má vlastní kapitolu
Autorizace v DDD . Stručně:
otázku „smí tento uživatel vykonat tento use case na tomto objektu“ řeší
use-case vrstva přes Symfony Voter, zatímco doménové invarianty zůstávají
v agregátu. Kapitola zavádí čtyřvrstvý rámec od HTTP firewallu po pravidla
na úrovni polí a ukazuje, proč doménová pravidla do Voteru nepatří.
10.15 Dependency Injection a autowiring#
DI Container v Symfony 8 váže rozhraní z doménové vrstvy na konkrétní implementaci v infrastruktuře.
Konfigurace určuje, kterou třídu autowiring injektuje, když handler typuje na UserRepository:
◢
Vzor
Příklad: Konfigurace služeb v Symfony 8
yaml
config/services.yaml
Kopírovat
1 2 services: 3 _defaults: 4 autowire: true 5 autoconfigure: true 6 7 8 App\: 9 resource: '../src/' 10 exclude: 11 - '../src/Kernel.php' 12 - '../src/*/Domain/Model/' 13 - '../src/*/Domain/ValueObject/' 14 - '../src/*/Domain/Event/' 15 16 17 18 19 App\UserManagement\Domain\Repository\UserRepository: '@App\UserManagement\Infrastructure\Repository\DoctrineUserRepository' 20 App\OrderManagement\Domain\Repository\OrderRepository: '@App\OrderManagement\Infrastructure\Repository\DoctrineOrderRepository' 21 22 23 24 25 26 27 28 29 30 31 32
!
Pozor
Pozor: alias @... vs. nová služba class: ...
Drobný rozdíl v syntaxi services.yaml, dramatický rozdíl v chování:
App\…\UserRepository: '@App\…\DoctrineUserRepository' – alias .
Kontejner použije existující službu pod druhým jménem. Jedna instance, dvě jména.
App\…\UserRepository: { class: App\…\DoctrineUserRepository } – nová služba
pod klíčem rozhraní. Vznikne druhá instance DoctrineUserRepository – dva
EntityManagery, dvě sady listenerů, dva separátní stavy. Při autowiringu
může vznikat zmatek, kterou instanci kontejner injektuje do závislých služeb.
V Symfony 6.3+ je idiomatičtější forma atribut #[AsAlias] přímo na implementaci –
viz Symfony idiomy: #[AsAlias] . Konfigurace v YAML
se hodí, když implementace patří do jiného balíčku, který nemůžete upravit.
Alias zajistí, že Symfony DI Container injektuje stejnou instanci DoctrineUserRepository
všude, kde závislost typuje na UserRepository. Doménové modely, hodnotové objekty
a události z auto-registrace vylučujeme – nejsou to služby, ale data.
Autowiring s oddělenými Bounded Contexts
Ve větších projektech s více Bounded Contexts se autowiring konfiguruje pro každý kontext samostatně.
Každý kontext dostane vlastní blok v services.yaml – hranice se tak promítne i do service containeru.
◢
Vzor
Příklad: Samostatný autowiring pro každý Bounded Context
yaml
config/services.yaml
Kopírovat
1 2 services: 3 _defaults: 4 autowire: true 5 autoconfigure: true 6 7 8 9 10 App\UserManagement\: 11 resource: '../src/UserManagement/' 12 exclude: 13 - '../src/UserManagement/Domain/Model/' 14 - '../src/UserManagement/Domain/ValueObject/' 15 - '../src/UserManagement/Domain/Event/' 16 17 App\UserManagement\Domain\Repository\UserRepository: '@App\UserManagement\Infrastructure\Repository\DoctrineUserRepository' 18 19 20 21 22 App\OrderManagement\: 23 resource: '../src/OrderManagement/' 24 exclude: 25 - '../src/OrderManagement/Domain/Model/' 26 - '../src/OrderManagement/Domain/ValueObject/' 27 - '../src/OrderManagement/Domain/Event/' 28 29 App\OrderManagement\Domain\Repository\OrderRepository: '@App\OrderManagement\Infrastructure\Repository\DoctrineOrderRepository' 30 31 32 33 34 App\Shared\: 35 resource: '../src/Shared/' 36 exclude: 37 - '../src/Shared/Domain/ValueObject/'
§
Poznámka
Výhody odděleného autowiringu pro Bounded Contexts
Každý kontext má vlastní blok konfigurace, takže hranice jsou čitelné i na úrovni infrastruktury. Exclude pravidla se dají nastavit pro každý kontext zvlášť – jeden má doménové služby, jiný ne. Při přesunu kontextu do samostatného balíčku nebo microservice stačí odebrat příslušný blok z services.yaml, a nechtěný import třídy z cizího kontextu se pozná přímo v konfiguraci.
§
Poznámka
Co patří do sdílené složky (Shared)?
Do sdílené složky by měly patřit pouze skutečně sdílené komponenty, které nemají specifický doménový význam:
Abstraktní třídy pro ID, Entity, ValueObject
Utility pro práci s datem a časem
Obecné výjimky
Infrastrukturní komponenty používané napříč doménami
Doménové modely, hodnotové objekty a repozitáře patří do svých Bounded Contextů, ne do Shared/.
Časté otázky
Kam v Symfony projektu patří doménová vrstva a proč ji držet odděleně?
Doménová vrstva se umisťuje do samostatného adresáře – v tomto průvodci src/<BoundedContext>/Domain/, například src/UserManagement/Domain/ – odděleně od kontrolerů, Doctrine mapování a infrastruktury. Izolace umožňuje testovat a refaktorovat model bez závislosti na Symfony životním cyklu a dovoluje přenést doménu i do jiného technologického stacku. Viz sekci Struktura projektu .
Jak mapovat agregát v Doctrine bez toho, aby doména závisela na ORM?
V tomto průvodci používáme Doctrine atributy přímo na agregátu jako pragmatickou výchozí volbu – jsou to metadata, ne chování. Pokud trváte na čisté doméně bez stop ORM, korektní řešení je Persisted Object Pattern (Vladimir Khorikov; Vernon, IDDD , kap. 12): doménová třída zůstane POPO, vedle ní v infrastruktuře existuje samostatná persistence třída s atributy a mapper mezi nimi. Detail v sekci Persisted Object Pattern – čistá DDD varianta .
Jak odlišit Aplikační službu od Doménové služby?
Doménová služba drží čistou doménovou logiku, která přirozeně nepatří žádnému agregátu ani hodnotovému objektu – je bezstavová a nekomunikuje s infrastrukturou. Aplikační služba naopak orchestruje use case: přijme vstup z kontroleru, načte agregáty přes repozitář, zavolá doménovou logiku a předá výsledek k persistenci. Aplikační služba nikdy neobsahuje doménová pravidla, pouze posloupnost kroků. Podrobný rozbor v sekci Aplikační služby a Doménové služby .
Mají doménové operace vyhazovat výjimky, nebo vracet Result typ?
V PHP a Symfony ekosystému jsou výjimky dominantní cestou. Při porušení invariantu agregát vyhodí konkrétní doménovou výjimku (například InsufficientFundsException). Aplikační vrstva ji přeloží na HTTP odpověď nebo zprávu uživateli. Result/Either typ je v PHP možný, ale přidává složitost bez odpovídajícího přínosu. Kontrolery zachytávají jen doménové podtypy, nikdy obecnou Exception. Rozbor variant v sekci Strategie zpracování chyb .
Kdy použít Doctrine Custom Type pro Value Object?
Doctrine Custom Type se hodí tam, kde se hodnotový objekt ukládá jako jednoduchá hodnota v jednom sloupci – peněžní částka, e-mail, URL, vlastní identifikátor. Custom Type přeloží hodnotový objekt při zápisu do primitivu a při čtení ho zpět rekonstruuje. Doménový kód pak pracuje vždy s typovým objektem. Pro hodnotové objekty složené z více sloupců je vhodnější embeddable mapování. Detailní rozbor v sekci Doctrine custom types pro Value Objects .