Doplňující taktické vzory: Specifications, Domain Services, Factories, Modules
Vedle entit, value objektů a agregátů obsahuje Evansova kniha čtyři další taktické vzory, které programátoři často přeskočí: Specifications jako prvotřídní booleovská logika, Domain Services pro chování bez přirozeného vlastníka, Factories pro komplexní vznik agregátů a Modules jako vědomá organizace kódu. Tato kapitola je jejich detailní průvodce v Symfony 8 a PHP 8.4 – s ukázkami kódu, anti-vzory a srovnávacími tabulkami.
Obsah kapitoly
V kapitole Základní koncepty DDD jsme prošli čtyři pilíře taktického designu: Entity, Value Object, Aggregate a stručně i Domain Service a Factory. Eric Evans jim věnuje v částech II a III desítky stran. Vývojáři je v průvodcích přeskakují nebo si je pletou s jinými vzory. Tato kapitola vzorům vrací plný význam: kdy jsou užitečné, jak je zapsat v PHP 8.4 a jaká rizika přinášejí při špatném použití.
Čtyři vzory, všechny ukotvené přímo v Evansově knize: Specification Pattern (kap. 9) – kompozice doménových predikátů jako prvotřídních objektů. Domain Services (kap. 5) zachytávají logiku bez přirozeného vlastníka mezi Entitami a Value Objekty. Factories zapouzdřují vznik agregátů se složitými invarianty (kap. 6; u Vernona kap. 11). A Modules (rovněž kap. 5) – vědomá organizace kódu podle Ubiquitous Language.
08.01 Proč tyto vzory přehlížíme#
Většina online průvodců o DDD končí někde u Aggregate. Vývojář, který se právě naučil
odlišovat Entity od Value Objektu a chápe význam invariantů, má pocit, že už ovládá
„taktický design“. Specification, Domain Service, Factory a Module se mu pak jeví jako
„nadbytečná abstrakce“. To, co dělají, lze přece napsat i jinak: if-em,
statickou metodou nebo prostým balíčkem v src/. Intuice je to chybná.
V malých projektech bez těchto vzorů přežijete. Jenže tam, kde je doména netriviální, tedy přesně tam, kde DDD platí, způsobují chybějící vzory bobtnání agregátů, anémii modelu a duplikaci pravidel. Kód přestává odrážet doménovou strukturu projektu. Evansovy čtyři vzory tvoří provázanou sadu. Vyřazením jednoho oslabíte ostatní.
Začneme vzorem, který bývá v komunitě nejčastěji přehlížen, přestože mu Evans věnoval podstatnou část deváté kapitoly – Specification Pattern.
08.02 Specification Pattern#
Co to je
Specification je prvotřídní objekt, který zapouzdřuje jeden booleovský predikát nad doménovým objektem – typicky odpověď na otázku tvaru „splňuje tento agregát konkrétní pravidlo?“. Minimální rozhraní vypadá takto:
1interface Specification2{3 public function isSatisfiedBy(mixed $candidate): bool;4}
Rozhraní vypadá triviálně, ale stojí za ním celá architektonická volba. Každé pravidlo doménového jazyka dostane vlastní třídu s mluvícím jménem: „zákazník je
premium“, „objednávka má nárok na dopravu zdarma“, „faktura je po splatnosti“. Pravidlo
přestává být kombinací if-ů uvnitř service vrstvy a stává se
jmenovaným prvkem Ubiquitous Language.
Vzor formálně popsali Evans a Fowler v pracovním papíru Specifications [martinfowler.com] z roku 1997; Evans ho později zařadil do Domain-Driven Design (2003), kapitoly 9 Making Implicit Concepts Explicit. Společný motiv: pravidla, která se v doméně objevují opakovaně, si zaslouží vlastní jméno a vlastní typ.
Jedna poznámka ke zdrojům. V destilovaném DDD Reference (2015) už Specification není. Evans do něj z taktických stavebních bloků zařadil Entities, Value Objects, Domain Events, Services, Modules, Aggregates, Repositories a Factories. Vzor tedy nepřeskakuje jen praxe, vypadl i z autorova vlastního souhrnu – o důvod víc ho projít pořádně.
Kdy použít
Původní papír pojmenovává tři použití vzoru. Selection vybírá podmnožinu objektů podle kritéria a umí výběr kdykoliv obnovit. Validation ověřuje, že objekt je pro daný účel vhodný. Construction-to-order popisuje, jak má objekt vypadat, aniž řeší, jak takový objekt vyrobit; z popisu se dá kandidát sestavit na zakázku. V Symfony projektu se ta trojice potkává ve čtyřech typických situacích:
- Komplexní doménová pravidla, která se mají skládat. Pokud se v různých částech aplikace vrací tentýž motiv v jiné kombinaci – někde „premium AND v EU“, jinde „premium OR má slevový kód“ – kompozice pomocí Specification ušetří duplikaci a udrží pravidla konzistentní.
- Pravidla použitelná jak v doméně, tak v repozitáři. Jedna a tatáž specifikace musí zvládnout obojí: odpovědět na „splňuje tento konkrétní objekt pravidlo?“ (in-memory predikát) i vrátit z databáze všechny objekty, které pravidlo splňují (query). Obě podoby pravidla (PHP i SQL/Doctrine DQL) drží pohromadě v jedné třídě; double-dispatch přijde ke slovu při předání specifikace repozitáři (viz Double-dispatch do Doctrine).
- Pravidla, která se skládají za běhu. Promo kód má v admin UI
podmínky „platí pro nákupy > 1000 Kč v ČR a SK, kromě výprodejového zboží“.
V doméně se reprezentuje jako instance
AndSpecificationsložená z N pod-pravidel čitelných z databáze. - Pravidlo validace agregátu. Místo aby Aggregate sám kontroloval všechny invarianty v setterech, deleguje na specifikaci, která je čitelná samostatně i testovatelná v izolaci.
Papír k tomu přidává tři implementační strategie a vyplatí se je odlišit, protože každá
stojí jinak. Hard Coded Specification je jedna třída na jedno pravidlo, bez parametrů –
levná, ale roste s počtem pravidel. Parameterized Specification skládá pravidlo za běhu
z hodnot; přesně to dělá bod 3 s promo kódem čteným z databáze. Composite Specification
přidává uzly and, or, not a čte pravidlo jako výraz. Evans s Fowlerem u ní uvádějí
i cenu: kompozit je pružný, aniž byste psali spoustu specializovaných tříd, ale musíte
investovat do frameworku. Zbytek této sekce ukazuje právě ten framework, aby bylo vidět,
co ta investice obnáší.
Kdy NE
Specification je vzor s nezanedbatelnou cenou: každé pravidlo = nová třída, nový soubor, nový test. Nehodí se pro:
- Triviální podmínky, které se vyskytují jednou a obsahují
jeden if:
if ($order->totalAmount()->amountInCents > 100_000)nepotřebuje vlastní třídu. - Pravidla, která jsou ve skutečnosti součástí invariantu Aggregate (a tedy patří přímo do něj jako privátní metoda).
- Konfigurační a technické příznaky – Specification má reprezentovat doménové pravidlo, ne podmínku „má feature flag enabled“.
Papír Evanse a Fowlera má vlastní sekci When Not to Use Specification a její kritérium je ostřejší než výčet výše. Jestliže objekt reprezentuje skutečnou entitu domény místo toho, aby kladl podmínky na jinou, případně jen hypotetickou entitu, vzor tam nepatří. Autoři to ilustrují dvojicí Route a Route Specification: trasa je věc, kterou doména zná a která má identitu. Specifikace trasy jen popisuje, jaká trasa by vyhovovala – žádná taková zatím existovat nemusí.
Skladba pomocí kombinátorů
Vzor těží z toho, že specifikace lze skládat pomocí
booleovských kombinátorů and, or, not. Místo
klubka if-ů a else-ů zapíšete pravidlo jako algebraický
výraz nad pojmenovanými atomy. Třídní hierarchie vypadá následovně:
Interface a abstraktní kompozit
Začneme rozhraním, které vystaví všechny tři kombinátory – rozšiřuje tak minimální jednometodovou verzi z úvodu sekce a dále v kapitole ji nahrazuje. Abstraktní třída pak kombinátory implementuje pomocí AndSpecification, OrSpecification, NotSpecification:
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7/**8 * Doménová specifikace – prvotřídní objekt zapouzdřující booleovský predikát.9 *10 * @template T11 */12interface Specification13{14 /** @param T $candidate */15 public function isSatisfiedBy(mixed $candidate): bool;16 17 /**18 * @param Specification<T> $other19 * @return Specification<T>20 */21 public function and(self $other): self;22 23 /**24 * @param Specification<T> $other25 * @return Specification<T>26 */27 public function or(self $other): self;28 29 /** @return Specification<T> */30 public function not(): self;31}
Aby každá konkrétní specifikace nemusela kombinátory implementovat sama, abstraktní třída je dodá zdarma:
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7/**8 * @template T9 * @implements Specification<T>10 */11abstract class CompositeSpecification implements Specification12{13 /** @param T $candidate */14 abstract public function isSatisfiedBy(mixed $candidate): bool;15 16 public function and(Specification $other): Specification17 {18 return new AndSpecification($this, $other);19 }20 21 public function or(Specification $other): Specification22 {23 return new OrSpecification($this, $other);24 }25 26 public function not(): Specification27 {28 return new NotSpecification($this);29 }30}
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7/**8 * @template T9 * @extends CompositeSpecification<T>10 */11final class AndSpecification extends CompositeSpecification12{13 /**14 * @param Specification<T> $left15 * @param Specification<T> $right16 */17 public function __construct(18 private readonly Specification $left,19 private readonly Specification $right,20 ) {}21 22 public function isSatisfiedBy(mixed $candidate): bool23 {24 return $this->left->isSatisfiedBy($candidate)25 && $this->right->isSatisfiedBy($candidate);26 }27}
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7/**8 * @template T9 * @extends CompositeSpecification<T>10 */11final class OrSpecification extends CompositeSpecification12{13 /**14 * @param Specification<T> $left15 * @param Specification<T> $right16 */17 public function __construct(18 private readonly Specification $left,19 private readonly Specification $right,20 ) {}21 22 public function isSatisfiedBy(mixed $candidate): bool23 {24 return $this->left->isSatisfiedBy($candidate)25 || $this->right->isSatisfiedBy($candidate);26 }27}
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7/**8 * @template T9 * @extends CompositeSpecification<T>10 */11final class NotSpecification extends CompositeSpecification12{13 /** @param Specification<T> $inner */14 public function __construct(private readonly Specification $inner) {}15 16 public function isSatisfiedBy(mixed $candidate): bool17 {18 return !$this->inner->isSatisfiedBy($candidate);19 }20}
Doménová specifikace
Na kostře postavíme tři konkrétní pravidla z Ordering kontextu. Každé nese mluvící doménové
jméno a kombinátory and/or/not dědí automaticky.
Specifikace čtou z agregátu totalAmount(), customerId a shippingAddress. První dvě
má kanonický Order z Návrhu agregátu, třetí ne.
Tamní kapitola ukazuje ShippingAddress jen jako embeddable hodnotový objekt a agregát
ji nenese. Příklady zde počítají s objednávkou rozšířenou o vlastnost
public readonly ShippingAddress $shippingAddress.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Specification;6 7use App\Ordering\Domain\Model\Order;8use App\SharedKernel\Domain\Money;9use App\SharedKernel\Domain\Specification\CompositeSpecification;10 11/**12 * Objednávka má nárok na dopravu zdarma, pokud její celková hodnota13 * dosahuje nebo přesahuje stanovený limit.14 *15 * @extends CompositeSpecification<Order>16 */17final class EligibleForFreeShipping extends CompositeSpecification18{19 public function __construct(private readonly Money $threshold) {}20 21 public function isSatisfiedBy(mixed $candidate): bool22 {23 assert($candidate instanceof Order);24 25 $total = $candidate->totalAmount();26 27 return $total->currency === $this->threshold->currency28 && $total->amountInCents >= $this->threshold->amountInCents;29 }30}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Specification;6 7use App\Ordering\Domain\Model\Order;8use App\SharedKernel\Domain\Specification\CompositeSpecification;9 10/**11 * Doručovací adresa objednávky se nachází v členské zemi EU.12 * Seznam zemí je součástí pravidla – specifikace nepotřebuje13 * žádný vstup zvenčí.14 *15 * @extends CompositeSpecification<Order>16 */17final class InEUCountry extends CompositeSpecification18{19 private const array EU_COUNTRIES = [20 'AT', 'BE', 'BG', 'HR', 'CY', 'CZ', 'DK', 'EE', 'FI', 'FR',21 'DE', 'GR', 'HU', 'IE', 'IT', 'LV', 'LT', 'LU', 'MT', 'NL',22 'PL', 'PT', 'RO', 'SK', 'SI', 'ES', 'SE',23 ];24 25 public function isSatisfiedBy(mixed $candidate): bool26 {27 assert($candidate instanceof Order);28 29 return in_array(30 $candidate->shippingAddress->countryCode,31 self::EU_COUNTRIES,32 true,33 );34 }35}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Specification;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\ValueObject\CustomerId;9use App\SharedKernel\Domain\Specification\CompositeSpecification;10 11/**12 * Zákazník není uveden na doménovém blacklistu (např. fraud detection).13 *14 * @extends CompositeSpecification<Order>15 */16final class NotInBlacklist extends CompositeSpecification17{18 /** @param list<CustomerId> $blacklist */19 public function __construct(private readonly array $blacklist) {}20 21 public function isSatisfiedBy(mixed $candidate): bool22 {23 assert($candidate instanceof Order);24 25 foreach ($this->blacklist as $blocked) {26 if ($blocked->equals($candidate->customerId)) {27 return false;28 }29 }30 31 return true;32 }33}
Volání assert($candidate instanceof Order) v ukázkách zužuje typ pro statickou analýzu,
ne pro běh. S zend.assertions=-1 se v produkci vůbec nezkompiluje, takže runtime pojistka
to není. Skutečnou kontrolu dělá typový parametr @template T spolu s PHPStan nebo Psalm,
které kompozici hlídají staticky.
Kompozice v aplikační vrstvě
Marketingová akce „doprava zdarma pro nákupy nad 1000 Kč v EU, kromě zákazníků
na blacklistu“ je trojice atomických specifikací spojená kombinátorem and. Vznikne
jedna čitelná řádka místo trojnásobně vnořeného if-u:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Service;6 7use App\Ordering\Application\BlacklistRegistry;8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\Specification\EligibleForFreeShipping;10use App\Ordering\Domain\Specification\InEUCountry;11use App\Ordering\Domain\Specification\NotInBlacklist;12use App\SharedKernel\Domain\Currency;13use App\SharedKernel\Domain\Money;14 15final class FreeShippingPolicy16{17 public function __construct(private readonly BlacklistRegistry $blacklist) {}18 19 // Politika odpovídá, nemění stav. Co s nárokem udělat (nulové dopravné,20 // slevový řádek), rozhoduje handler checkoutu, který ji volá.21 public function isEligible(Order $order): bool22 {23 // 1000 Kč v haléřích – Money drží částku jako celé číslo.24 $promo = (new EligibleForFreeShipping(new Money(100_000, Currency::CZK)))25 ->and(new InEUCountry())26 ->and(new NotInBlacklist($this->blacklist->all()));27 28 return $promo->isSatisfiedBy($order);29 }30}31 32// --- src/Ordering/Application/BlacklistRegistry.php ---33namespace App\Ordering\Application;34 35use App\Ordering\Domain\ValueObject\CustomerId;36 37// Port: seznam zákazníků na blacklistu dodává Infrastructure vrstva38// (fraud detection, ručně vedený seznam). Specifikace dostává hotový list.39interface BlacklistRegistry40{41 /** @return list<CustomerId> */42 public function all(): array;43}
Pravidlo lze v testu rozložit na atomy a ověřit každý zvlášť. Když produktový tým
rozhodne, že na blacklist se nově dívat nemá, smažete jeden řádek z kompozice – bez
nutnosti pročítat sevřený if uvnitř komplexní service vrstvy.
Politika vrací bool a agregát nechává na pokoji. Výsledek spotřebuje handler
checkoutu: nulové dopravné dosadí do výpočtu ceny, nebo ho zapíše jako slevový řádek.
Kanonický Order tak nepotřebuje žádnou metodu navíc a specifikace zůstává čistým
dotazem nad stavem.
Dva vzory z papíru, které se neujaly
Papír Specifications obsahuje dva navazující vzory, které se do knih ani do PHP praxe
nedostaly. Oba přitom řeší otázku, na kterou isSatisfiedBy() odpovědět neumí.
Subsumption srovnává specifikace mezi sebou místo specifikace s kandidátem. Metoda
isGeneralizationOf() odpoví, zda je jedno pravidlo obecnější než druhé. Používá se
u párování nabídky s poptávkou, kde obě strany popisujete specifikací a konkrétní objekt
zatím neexistuje.
Partially Satisfied Specification přidává remainderUnsatisfiedBy(), která vrátí
zbytkovou specifikaci – tedy to, co ještě zbývá splnit. Uživatel místo false dostane
odpověď „chybí doručovací adresa v EU“:
Metoda patří do rozhraní Specification, ne jen do kompozitu: AndSpecification
ji volá na svých potomcích, které zná jen jako Specification. CompositeSpecification
dodá výchozí tělo, takže listové specifikace nic dopisovat nemusí.
1// Rozhraní si ponechává and/or/not ze sekce 08.02; přibývá jen pátá metoda.2interface Specification3{4 /** @param T $candidate */5 public function isSatisfiedBy(mixed $candidate): bool;6 7 /**8 * @param Specification<T> $other9 * @return Specification<T>10 */11 public function and(self $other): self;12 13 /**14 * @param Specification<T> $other15 * @return Specification<T>16 */17 public function or(self $other): self;18 19 /** @return Specification<T> */20 public function not(): self;21 22 /**23 * Vrátí specifikaci popisující, co kandidát ještě nesplnil,24 * nebo null, pokud splnil všechno.25 *26 * @param T $candidate27 * @return Specification<T>|null28 */29 public function remainderUnsatisfiedBy(mixed $candidate): ?Specification;30}31 32 33abstract class CompositeSpecification implements Specification34{35 // Výchozí implementace pro listy i kompozity36 public function remainderUnsatisfiedBy(mixed $candidate): ?Specification37 {38 return $this->isSatisfiedBy($candidate) ? null : $this;39 }40}
1public function remainderUnsatisfiedBy(mixed $candidate): ?Specification2{3 $left = $this->left->remainderUnsatisfiedBy($candidate);4 $right = $this->right->remainderUnsatisfiedBy($candidate);5 6 if ($left !== null && $right !== null) {7 return new self($left, $right);8 }9 10 return $left ?? $right;11}
Cena je zřejmá: metodu musí implementovat každý kombinátor a u or a not už odpověď
není jednoznačná. Přínos taky: formulář nebo API vrátí důvod zamítnutí odvozený z téhož
pravidla, které rozhodlo, místo ručně psané hlášky, která se rozejde s logikou.
Double-dispatch do Doctrine
Specifikace je užitečná i ve druhé roli – jako parametr query do
repozitáře. Místo metody findEligibleForFreeShippingInEU(): array, kterou
byste pro každou novou kombinaci pravidel přidávali, dostane repozitář jakoukoliv
specifikaci, převede ji na dotaz a vrátí výsledek. Tomuto přístupu se
říká double-dispatch: specifikace nese pravidlo, repozitář ví, jak ho
přeložit do persistence.
Rozhoduje se tu jedna věc: co přesně specifikace vrací. Mutovat předaný QueryBuilder se nabízí,
ale je to slepá ulička: metoda s návratovým typem void nejde skládat, takže or a not
se přeložit nedají. Doctrine na to má vlastní mezireprezentaci. Doctrine\Common\Collections\Criteria
staví výrazy přes Criteria::expr() a nabízí eq, neq, gt, gte, lt, lte, in,
notIn, contains, startsWith, endsWith, isNull, memberOf a kombinátory
andX, orX, not. Specifikace tedy vrací výraz, ne vedlejší efekt:
1<?php2 3declare(strict_types=1);4 5namespace App\SharedKernel\Domain\Specification;6 7use Doctrine\Common\Collections\Expr\Expression;8 9/**10 * Specifikace, která umí své pravidlo vyjádřit jako Doctrine výraz.11 * Implementuje double-dispatch: specifikace zná pravidlo,12 * repozitář ví, jak výraz spustit nad databází.13 *14 * @template T15 * @extends Specification<T>16 */17interface QuerySpecification extends Specification18{19 public function toExpression(): Expression;20}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Specification;6 7use App\Ordering\Domain\Model\Order;8use App\SharedKernel\Domain\Money;9use App\SharedKernel\Domain\Specification\CompositeSpecification;10use App\SharedKernel\Domain\Specification\QuerySpecification;11use Doctrine\Common\Collections\Criteria;12use Doctrine\Common\Collections\Expr\Expression;13 14/**15 * @extends CompositeSpecification<Order>16 * @implements QuerySpecification<Order>17 */18final class EligibleForFreeShipping extends CompositeSpecification implements QuerySpecification19{20 public function __construct(private readonly Money $threshold) {}21 22 public function isSatisfiedBy(mixed $candidate): bool23 {24 assert($candidate instanceof Order);25 26 $total = $candidate->totalAmount();27 28 return $total->currency === $this->threshold->currency29 && $total->amountInCents >= $this->threshold->amountInCents;30 }31 32 public function toExpression(): Expression33 {34 return Criteria::expr()->andX(35 Criteria::expr()->eq('totalCurrency', $this->threshold->currency->value),36 Criteria::expr()->gte('totalAmount', $this->threshold->amountInCents),37 );38 }39}
Repozitář pak vystaví obecnou metodu match(). Doctrine\ORM\EntityRepository implementuje
rozhraní Selectable, takže Criteria umí spustit přímo:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Repository;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\Repository\OrderRepository;9use App\SharedKernel\Domain\Specification\QuerySpecification;10use Doctrine\Common\Collections\Criteria;11use Doctrine\ORM\EntityManagerInterface;12 13final class DoctrineOrderRepository implements OrderRepository14{15 public function __construct(private readonly EntityManagerInterface $em) {}16 17 /**18 * @param QuerySpecification<Order> $spec19 * @return list<Order>20 */21 public function match(QuerySpecification $spec): array22 {23 $criteria = new Criteria($spec->toExpression());24 25 return array_values(26 $this->em->getRepository(Order::class)->matching($criteria)->toArray(),27 );28 }29}
Tatáž Criteria funguje i nad ArrayCollection v paměti, protože rozhraní Selectable
implementuje kolekce stejně jako repozitář. Když dotaz potřebuje join nebo řazení přes vazbu, výraz se vloží do QueryBuilderu
přes addCriteria() a zbytek dotazu zůstane ruční:
1/**2 * @param QuerySpecification<Order> $spec3 * @return list<Order>4 */5public function matchWithCustomer(QuerySpecification $spec): array6{7 return $this->em->createQueryBuilder()8 ->select('o', 'c')9 ->from(Order::class, 'o')10 ->join('o.customer', 'c')11 ->addCriteria(new Criteria($spec->toExpression()))12 ->getQuery()13 ->getResult();14}
Obě role specifikace (in-memory predikát i překlad do dotazu) sedí v jedné třídě.
Když se PHP a databázová podoba začnou rozcházet, je to při code review vidět
na jedné obrazovce.
Nic tento soulad ale nevynucuje – jde o dvě nezávislé implementace téhož pravidla. Pojistkou
je kontraktní test: nad stejnou sadou testovacích dat ověří, že isSatisfiedBy()
označí tytéž objekty, jaké match() vrátí z databáze. Když se obě verze rozejdou,
test selže dřív než produkce.
Cestu od repozitáře s příliš mnoha metodami přes Doctrine Criteria ke specifikacím popsal
Kévin Gomez v textu On Taming Repository Classes in Doctrine… Among other things
(7. 2. 2015). Pozor na záměnu se stejnojmenným článkem Benjamina Eberleie z roku 2013;
ten se Specification zabývá jen okrajově.
Limity: co Criteria unese a co ne
Protože specifikace vrací výraz, kombinátory se přeloží stejně přímočaře jako predikát.
AndSpecification složí andX, OrSpecification orX, NotSpecification not:
1// Deklarace třídy se rozšíří o rozhraní, jinak ji match() na vstupu odmítne:2// final class AndSpecification extends CompositeSpecification implements QuerySpecification3 4public function toExpression(): Expression5{6 if (!$this->left instanceof QuerySpecification7 || !$this->right instanceof QuerySpecification8 ) {9 throw new \LogicException(10 'Do dotazu lze přeložit jen kompozici QuerySpecification.',11 );12 }13 14 return Criteria::expr()->andX(15 $this->left->toExpression(),16 $this->right->toExpression(),17 );18}
Kombinátor implementuje QuerySpecification, ale jeho operandy být query specifikacemi
nemusí. Typová kontrola na vstupu match() proto neprojde až do listů a rozpor se ozve
až běhovou výjimkou. Za tu cenu dostanete skládání, které dřív končilo u konjunkce.
Zmizí i past s názvy parametrů: Criteria si placeholdery generuje sama, takže dvě
pod-specifikace se stejnou hodnotou prahu se navzájem nepřepíšou.
Limity leží jinde a jsou reálné. Criteria porovnává v eq a neq striktně, takže
srovnání instancí DateTimeImmutable se chová jinak než v SQL. Vlastní DQL funkce, joiny,
agregace ani poddotazy vyjádřit nejdou. Pole ve výrazu odkazují na vlastnosti entity, ne na
sloupce, takže pravidlo nad vazbou se do výrazu nedostane bez addCriteria() a ručního
joinu. Pro takový dotaz zůstává správnou volbou vlastní repozitářová metoda
(findOrdersEligibleForPromo()), která pravidlo zapíše v DQL přímo a kontraktním testem
se sváže s in-memory specifikací.
Poslední poznámka k rozsahu. Celý framework z této sekce existuje i hotový.
happyr/doctrine-specification má přes 900 tisíc instalací, podporuje Doctrine ORM 3
a repozitář rozšiřuje o match(); pravidla se skládají přes Spec::andX() a Spec::orX().
Filtrování navíc odděluje od modifikátorů výsledku (řazení, hydratace). Ruční
kostra z této kapitoly to neumí. Ukázky výše jsou tu proto, aby bylo vidět, co balíček uvnitř dělá.
V projektu, kde specifikace nejsou předmětem výuky, je balíček levnější volbou.
Pro hluboký teoretický základ vzoru: Evans, E., Domain-Driven Design (2003),
kapitola 9 Making Implicit Concepts Explicit; Evans & Fowler, pracovní
papír Specifications (1997), dostupný na martinfowler.com.
Praktická aplikace na agregátech: Vernon, V., Implementing Domain-Driven Design
(2013). K Doctrine části: Gomez, K., On Taming Repository Classes in Doctrine… Among
other things (2015) a dokumentace doctrine/collections k Criteria a Selectable.
08.03 Domain Services#
Doménovou službu zmiňují Základní koncepty jako jeden ze stavebních bloků a Implementace v Symfony ukazuje, jak ji zaregistrovat v containeru. Zde jde o rozhodovací kritéria: kdy služba vzniknout má, kdy je to jen přesunutá logika z entity a kudy vede hranice vůči Application vrstvě.
Co to je
Domain Service je stateless objekt obsahující doménovou logiku, která nemá přirozeného vlastníka mezi Entitami a Value Objekty daného modelu. Eric Evans v kapitole 5 Domain-Driven Design (2003) shrnuje kritérium do tří bodů: operace se týká doménového konceptu, ale (1) nepatří do žádné Entity ani Value Objektu jako její přirozená metoda, (2) její rozhraní je definováno pomocí jiných prvků doménového modelu a (3) nemá vlastní stav.
Existuje tedy operace X, ale žádná Entita ji nemůže vlastnit, aniž by musela znát příliš mnoho o druhé. To je signál pro Domain Service.
Kdy použít
Klasické příklady, na kterých Evans i Vernon vzor demonstrují:
Vezměme Funds Transfer, převod peněz mezi dvěma účty. Patří do agregátu
Account? Ani jeden z účtů nezná ten druhý a ani jeden není přirozeným vlastníkem
operace. Jde o doménový koncept sám o sobě. Podobně pricing engine počítá cenu
objednávky z pricing pravidel, segmentu zákazníka, košíku a kupónu – žádný z těchto
objektů není přirozeným vlastníkem výpočtu. Stejnou povahu má i credit scoring:
odpověď na „má tento zákazník nárok na úvěr X?“ vzniká kombinací několika faktorů.
A čtvrtý typický případ je koordinátor dvou agregátů – operace, která mění stav dvou agregátů zároveň, kde žádný z nich nesmí znát detaily druhého (autonomie agregátů).
Kdy NE
Domain Service je v DDD vzor, který se zneužívá nejčastěji.
Vývojáři navyklí na klasickou layered architecture vytvoří
OrderService, CustomerService, InvoiceService jako
první reflex – a všechnu logiku z Entit přesunou tam, čímž si vyrobí
anémický doménový model.
Pokud tedy uvažujete o Domain Service, vždy si nejdřív položte trojici kontrolních otázek:
- Patří tato operace přirozeně do nějaké Entity? (= je to chování nad jednou identitou, agregát ji může bez cizí pomoci provést) – pokud ano, nepatří do Domain Service.
- Je to skutečně doménová operace, nebo aplikační? Domain Service
obsahuje doménová pravidla. Application Service koordinuje
(transakce, autorizace, eventy). Pokud byste musel v „doménové“ service
volat
EntityManager->flush()– je to Application Service. - Není to spíš infrastrukturní detail? Posílání e-mailu, hash hesla, čtení z externího API – to nejsou doménové operace, ale infrastruktura.
Příklad: MoneyTransferService
Klasický bankovní příklad – převod peněz ze zdrojového účtu na cílový. Logika nepatří
do $from (nezná $to), ani do $to (nezná
$from). Je to doménová operace bez přirozeného vlastníka:
1<?php2 3declare(strict_types=1);4 5namespace App\Banking\Domain\Service;6 7use App\Banking\Domain\Account;8use App\Banking\Domain\Exception\InsufficientFunds;9use App\Banking\Domain\TransferReference;10use App\SharedKernel\Domain\Money;11 12/**13 * Domain Service – převod peněz mezi dvěma účty.14 *15 * Operace nepatří do žádného z účtů, protože jeden z nich nesmí znát16 * druhý: agregáty jsou autonomní. Jde o doménovou logiku (validace17 * dostupnosti prostředků, kontrola limitu), nikoliv o aplikační koordinaci.18 *19 * Stateless – bez instance variables, bez vedlejších efektů na kolaborátorech.20 */21final class MoneyTransferService22{23 public function transfer(24 Account $from,25 Account $to,26 Money $amount,27 TransferReference $reference,28 \DateTimeImmutable $when,29 ): void {30 if (!$from->canWithdraw($amount, $when)) {31 throw InsufficientFunds::onAccount($from->id(), $amount);32 }33 34 if ($from->currency() !== $to->currency()) {35 throw new \DomainException(36 'Currency mismatch – use FxTransferService for cross-currency transfers.',37 );38 }39 40 $from->withdraw($amount, $reference, $when);41 $to->deposit($amount, $reference, $when);42 }43}
Všimněte si tří rysů, podle kterých poznáte „opravdovou“ Domain Service:
- Žádný stav – třída nedrží mezi voláními nic vlastního. Pracuje s objekty, které dostane v parametrech.
- Žádné perzistenční volání –
$from->withdraw()a$to->deposit()mutují stav agregátů, ale ukládat je bude až Application Service nebo command handler. Domain Service nikdy nevolá$em->flush(). - Vyhazuje doménové výjimky –
InsufficientFunds,\DomainException– ne\RuntimeExceptionnebo HTTP status kódy.
První bod se často zpřísňuje na „doménová služba nesmí mít v konstruktoru repozitář“. Jako pravidlo to neobstojí a zdroje se rozcházejí. Vladimir Khorikov rozlišuje pure a impure doménovou službu: druhá sáhne do vnějšího systému, protože bez toho doménové rozhodnutí nepadne. Matthias Noback umísťuje rozhraní repozitáře do Domain vrstvy právě proto, že s ním doménový kód pracovat má. Vernonova námitka, na kterou se v této debatě odkazuje nejčastěji, navíc míří na injektování repozitáře do agregátu, ne do služby.
Praktické vodítko zní jinak než zákaz. Než služba dostane repozitář, stojí za zvážení, jestli jí data nemá dodat volající. Když si je služba načítá sama, přebírá kus orchestrace a její test přestane být čistě jednotkový. Když je ale jinak nezískáte, typicky u pravidla, které potřebuje odpověď nad celou kolekcí, je závislost na doménovém rozhraní přijatelná a služba zůstává doménová. Rozhoduje, jestli třída obsahuje doménové pravidlo, ne počet jejích konstruktorových parametrů.
Domain Service vs. Application Service vs. Infrastructure Service
V kódu se třída se sufixem Service vyskytne téměř vždy.
Liší se jen v tom, kterou ze tří rolí hraje. Následující srovnávací tabulka shrnuje
rozdíly, na které se v code review ptáme:
| Aspekt | Domain Service | Application Service | Infrastructure Service |
|---|---|---|---|
| Účel | Doménová logika bez přirozeného vlastníka | Koordinace use case (transakce, autorizace, eventy) | Technická integrace (DB, e-mail, externí API) |
| Vrstva | Domain | Application | Infrastructure |
| Závislosti | Doménové typy a doménová rozhraní (Entity, VO, jiné Domain Services) | Repozitáře, Event Bus, Domain Services, Authorization | HTTP klienti, knihovny (Mailer, Stripe SDK), filesystem |
| Stav | Stateless | Stateless (jednorázový handler) | Často stateless, ale může držet connection pool |
| Volá perzistenci? | Ne | Ano (přes repozitář) | Ano (sama je perzistencí) |
| Vyhazuje výjimky | Doménové (InsufficientFunds) |
Aplikační (UnauthorizedException, validation) |
Infrastrukturní (ConnectionException) |
| Příklad jména | MoneyTransferService, PricingService |
PlaceOrderHandler, RegisterUserHandler |
SymfonyMailer, StripePaymentGateway |
| Test | Pure unit, bez Symfony kernel | Unit s mockovanými repozitáři | Integrační (kontrakt s reálným systémem) |
| Sufix v PHP | *Service (volitelně) |
*Handler, *UseCase |
*Gateway, *Adapter, *Client |
Pojmenování všech tříd sufixem *Service smaže rozdíl mezi třemi rolemi z tabulky.
V Application vrstvě se proto v praxi přechází na *Handler nebo *UseCase. Doménová
Service má sufix Service jen tehdy, když pomáhá zdůraznit „operace bez vlastníka“.
V mnoha doménách i u Domain Service zvolíme přímo doménové jméno
(FundsTransfer, PricingEngine) bez sufixu.
Tematicky souvisí: Základní koncepty – Doménové služby, Anti-vzor: Anemic Domain Model, CQRS – Application Handler.
Citace: Evans, E., Domain-Driven Design (2003), kapitola 5 A Model Expressed in Software; Vernon, V., Implementing Domain-Driven Design (2013), kapitola 7.
08.04 Factories#
Named constructor a rekonstituci potkáte i v Základních konceptech, tam jako součást výkladu agregátu. Tato sekce je bere jako samostatný vzor: kdy stačí statická metoda, kdy je potřeba zvláštní třída a kam taková třída patří ve struktuře modulu.
Co to je
Factory v terminologii DDD je zapouzdření komplexní logiky vzniku agregátu nebo Value Objektu, kde standardní konstruktor nestačí. Eric Evans v kapitole 6 Domain-Driven Design (2003) doporučuje přesunout odpovědnost za vytváření složitých objektů a agregátů na samostatný objekt, zvlášť když vznik vyžaduje pravidla nebo polymorfismus.
Standardní konstruktor stačí pro většinu agregátů. Factory je řešení pro situace, kdy:
- Vznik agregátu vyžaduje validaci, kterou nelze provést až po konstrukci (např. „nový Order musí mít alespoň 1 položku, jinak agregát neexistuje“).
- Vznik je polymorfní – z různých vstupů vznikají různé pod-typy stejného agregátu
(například
Order::physical()vs.Order::digital()). - Vznik vyžaduje externí lookup – z REST API přijde surový e-mail, Factory ho převede
na
CustomerIdpřesCustomerLookup. - Mapování z DTO/raw payload je natolik spletité, že by zaplevelilo konstruktor doménového objektu detaily transportní vrstvy.
Kdy NE
Většinu objektů jde přímočaře vytvořit konstruktorem. Factory má smysl teprve tehdy, když konstruktor začne být nepřehledný:
- Triviální vznik –
OrderFactory::create($cust, $items), která interně volánew Order(...)a jinak nic. To není Factory, to je redundantní vrstva. - Service Locator pattern –
$factory->create('Order', [...])s magickým rozhodováním podle stringu. Ztrácíte typovou bezpečnost. - Factory pro každý objekt v doméně – over-engineering. DDD říká „Factory podle potřeby“, ne „Factory pro všechno“.
Vzor 1: Static method factory (preferovaný)
V PHP 8.4 je preferovanou formou Factory statická pojmenovaná konstrukční metoda na samotném agregátu (named constructor). Konstruktor je privátní, publikujete pouze pojmenované entry pointy s doménovou sémantikou:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Model;6 7use App\Ordering\Domain\Event\OrderPlaced;8use App\Ordering\Domain\Exception\EmptyOrderException;9use App\SharedKernel\Domain\AggregateRoot;10use App\Ordering\Domain\ValueObject\CustomerId;11use App\Ordering\Domain\ValueObject\OrderId;12 13final class Order extends AggregateRoot14{15 /** @var list<OrderItem> */16 private array $items;17 18 /** @param list<OrderItem> $items */19 private function __construct(20 private readonly OrderId $id,21 private readonly CustomerId $customerId,22 array $items,23 private readonly OrderType $type,24 private readonly \DateTimeImmutable $placedAt,25 ) {26 $this->items = $items;27 // Konstruktor jen plní stav. Eventy zaznamenávají factory metody –28 // konstruktorem prochází i reconstitute(), která žádný event vyvolat nesmí.29 }30 31 /**32 * Vznik objednávky s fyzickým zbožím – protějšek placeDigital() níže.33 *34 * @param list<OrderItem> $items35 */36 // Továrna vedle kanonického Order::place(OrderId, CustomerId). Přebírá37 // rovnou seznam položek, aby invariant platil už při vzniku. Kanonická38 // placeWithItems() s primitivními řádky je v kapitole o outboxu.39 public static function placePhysical(40 CustomerId $customerId,41 array $items,42 \DateTimeImmutable $placedAt,43 ): self {44 if (count($items) === 0) {45 throw EmptyOrderException::cannotBePlaced();46 }47 48 $order = new self(49 id: OrderId::generate(),50 customerId: $customerId,51 items: $items,52 type: OrderType::Physical,53 placedAt: $placedAt,54 );55 $order->record(new OrderPlaced($order->id, $customerId));56 57 return $order;58 }59 60 /**61 * Polymorfní vznik – pouze digitální obsah, jiná pravidla62 * (žádná dopravní adresa, instantní doručení).63 *64 * @param list<DigitalItem> $items65 */66 public static function placeDigital(67 CustomerId $customerId,68 array $items,69 \DateTimeImmutable $placedAt,70 ): self {71 if (count($items) === 0) {72 throw EmptyOrderException::cannotBePlaced();73 }74 75 $order = new self(76 id: OrderId::generate(),77 customerId: $customerId,78 items: array_map(static fn (DigitalItem $i): OrderItem => $i->toOrderItem(), $items),79 type: OrderType::Digital,80 placedAt: $placedAt,81 );82 $order->record(new OrderPlaced($order->id, $customerId));83 84 return $order;85 }86 87 /**88 * Vznik z importu – odlišná validace, neidentifikuje zákazníka přes CustomerId,89 * ale přes externí key, který se uvnitř naváže na guest CustomerId.90 */91 public static function fromImport(92 ImportedOrderRow $row,93 CustomerLookup $lookup,94 \DateTimeImmutable $placedAt,95 ): self {96 $customerId = $lookup->byEmail($row->customerEmail) ?? $lookup->guestId();97 $items = ImportedItems::map($row->items);98 99 return self::placePhysical($customerId, $items, $placedAt);100 }101}
Signatura placePhysical() zde přebírá rovnou seznam položek, aby šlo ukázat invariant „objednávka
bez položky nevznikne“ vynucený už při vzniku. Kanonický Order v této knize položky
přidává metodou addItem(ProductId $productId, int $quantity, Money $unitPrice) a prázdnou
objednávku dovolí; invariant pak hlídá confirm(). Stejnou cestou jde i kanonická továrna
placeWithItems(CustomerId $customerId, array $items) s primitivními řádky, kterou zavádí
kapitola Outbox Pattern: položky přidá přes
addItem() a objednávku hned potvrdí. Obě varianty jsou obhajitelné a volba
mezi nimi je rozhodnutí o tom, kde smí agregát existovat v rozpracovaném stavu.
Tři výhody static method factory oproti samostatné Factory class:
- Doménové jméno.
Order::place()neboOrder::placeDigital()nese sémantiku, kterounew Order(...)postrádá. - Privátní konstruktor. Žádný kód mimo agregát nesmí
Ordervytvořit cestou, která obejde validaci. Compiler-friendly invariant. - Polymorfismus zdarma.
Order::placeDigital()aOrder::fromImport()mají různé vstupy a různá pravidla, ale výstup je stejný typ.
Vzor 2: Factory class (když potřebujete DI)
Statická metoda nestačí v jediné situaci: když vznik agregátu potřebuje injektované závislosti (repozitáře, externí services, konfiguraci). Statická metoda nemůže DI přijímat bez service locatoru. Pak se přechází na samostatnou Factory class:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Factory;6 7use App\Ordering\Domain\Cart\CartId;8use App\Ordering\Domain\Cart\CartRepository;9use App\Ordering\Domain\Model\Order;10use App\Ordering\Domain\Pricing\PricingService;11use App\Ordering\Domain\ValueObject\CustomerId;12use Psr\Clock\ClockInterface;13 14/**15 * Factory class – vznik objednávky z košíku vyžaduje16 * načtení košíku a aplikaci aktuálního pricingu.17 * Static method by tyto závislosti nemohla převzít.18 */19final class OrderFromCartFactory20{21 public function __construct(22 private readonly CartRepository $carts,23 private readonly PricingService $pricing,24 private readonly ClockInterface $clock,25 ) {}26 27 public function fromCart(CartId $cartId, CustomerId $customer): Order28 {29 $cart = $this->carts->getById($cartId);30 31 if ($cart->isEmpty()) {32 throw new \DomainException('Cannot place order from empty cart.');33 }34 35 $pricedItems = $this->pricing->priceItems($cart->items(), $customer);36 37 return Order::placePhysical(38 customerId: $customer,39 items: $pricedItems,40 placedAt: $this->clock->now(),41 );42 }43}
Všimněte si, že Factory class uvnitř volá Order::placePhysical() –
nepřebírá zodpovědnost za invariant „aspoň 1 položka“, ten zůstává v named
constructor agregátu. Factory řeší pouze orchestraci vstupních dat.
Trojice CartRepository, PricingService a ClockInterface v konstruktoru vypadá jako
rozpor se sekcí 08.03, kde repozitář posouval třídu blíž k Application vrstvě. Rozřešení
dává DDD Reference: factory sama nemusí mít v modelu žádnou odpovědnost, a přesto je
součástí doménového návrhu. Nemodeluje doménový pojem, jen sestavuje agregát podle
doménových pravidel. Proto smí sáhnout po repozitáři a zůstat přitom v Domain/Factory/.
Hranice se posune jinam: jakmile by factory začala výsledek ukládat nebo publikovat
událost, je z ní command handler.
Reconstitution: zvláštní případ Factory
Třetí typ factory, s nímž se setkáte, je reconstitution – rekonstrukce agregátu z perzistence. Doctrine to dělá za vás (přes hydrator), ale pokud máte Event Sourcing nebo custom mapper, potřebujete factory, která nevolá invarianty (rekonstruovaný stav už validací prošel při vzniku):
1/**2 * Rekonstituce ze stavu načteného z DB / event streamu.3 * Tento pojmenovaný konstruktor neaplikuje invarianty –4 * rekonstruovaný stav je z definice valid, jinak by se nedostal do persistence.5 *6 * @internal Smí volat pouze infrastruktura repozitáře.7 *8 * @param list<OrderItem> $items9 */10public static function reconstitute(11 OrderId $id,12 CustomerId $customerId,13 array $items,14 OrderType $type,15 \DateTimeImmutable $placedAt,16): self {17 return new self($id, $customerId, $items, $type, $placedAt);18}
Proto také OrderPlaced zaznamenává factory metoda ::place(), ne konstruktor.
Rekonstituce nesmí mít vedlejší efekty: obnovuje stav, žádná doménová událost se
nestala. Kdyby event zaznamenával konstruktor, každé načtení agregátu z databáze
by znovu vyprodukovalo OrderPlaced a odběratelé by tutéž objednávku „umístili“
při každém čtení.
Pojmenování ::reconstitute() a PHPDoc @internal jasně
signalizují, že tato cesta vzniku je vyhrazena pro infrastrukturu. Doménový handler,
který by ji volal místo ::place(), by porušil invariant agregátu.
Pro detail: Evans, E., Domain-Driven Design (2003), kapitola 6 The Life Cycle of a Domain Object; Vernon, V., Implementing Domain-Driven Design (2013), kapitola 11 Factories; Verraes, M., Named Constructors in PHP (2014). Souvisejí kapitoly: Základní koncepty – Agregáty, Event Sourcing (reconstitution z event streamu).
08.05 Modules#
Co to je
Module je v Evansově terminologii vědomá organizace kódu do balíčků pojmenovaných podle Ubiquitous Language. Není to PHP feature, není to namespace – je to princip, který říká: „rozhraní balíčků vašeho kódu má odrážet doménový jazyk, ne technické vrstvy a ne použité knihovny.“
Evans věnoval Modules samostatnou pasáž v kapitole 5 Domain-Driven Design (2003). Moduly chápe jako vyjádření hrubší struktury modelu: členění balíčků má vycházet z doménového jazyka, ne z technické organizace kódu.
DDD Reference to formuluje ostřeji. Modul je součástí modelu, jeho jméno patří do Ubiquitous Language a má obsahovat kohezní sadu pojmů. Z toho plyne důsledek, který se v praxi přeskakuje: pokud modularita nevede k nízké provázanosti mezi moduly, řešením je změnit model, ne přitvrdit pravidla v phparkitectu. Vysoký coupling mezi moduly je nález o doméně, ne o konfiguraci nástroje.
V Symfony 8 a PHP 8.4 to konkrétně znamená:
- PSR-4 namespace + uspořádání složek podle
Bounded Contextů. Výchozí mapování
App\nasrc/na to stačí, viz PSR-4, autoload a services.yaml. - Publikované rozhraní modulu, tedy úzká množina typů, přes kterou do něj vstupuje okolí.
- Architecture testing, který zkontroluje, že žádný kód
v
App\Billing\přímo nedotahuje doApp\Ordering\.
Modul jako Bounded Context
Nejčastěji se vzor uplatní jako 1 modul = 1 Bounded Context. Projekt strukturovaný tímto způsobem vypadá takto:
1src/2 Ordering/ ← MODULE = Bounded Context3 Domain/4 Order.php ← Aggregate Root5 OrderRepository.php ← Interface6 OrderItem.php7 Specification/8 EligibleForFreeShipping.php9 InEUCountry.php10 Service/11 PricingService.php ← Domain Service12 Factory/13 OrderFromCartFactory.php14 Event/15 OrderPlaced.php16 Exception/17 EmptyOrderException.php18 Application/19 Command/20 PlaceOrderCommand.php21 CommandHandler/22 PlaceOrderHandler.php23 Query/24 ListOrdersQuery.php25 QueryHandler/26 ListOrdersHandler.php27 Infrastructure/28 Doctrine/29 DoctrineOrderRepository.php30 OrderMapping.orm.xml31 Http/32 OrderController.php33 Messenger/34 OrderPlacedSubscriber.php35 Billing/ ← Jiný BC = jiný modul36 Domain/37 Invoice.php38 ...39 Application/40 ...41 Infrastructure/42 ...43 SharedKernel/ ← Sdílený jazyk a typy44 Domain/45 Money.php46 Currency.php47 Country.php48 AggregateRoot.php49 Specification/50 Specification.php51 CompositeSpecification.php52 AndSpecification.php53 OrSpecification.php54 NotSpecification.php55 QuerySpecification.php
Této organizaci se v komunitě říká také vertical slicing – viz sekci Vertical Slice Architecture, která jí věnuje detailní rozbor. Pro účely této kapitoly stačí pozorování: shora vidíte doménovou mapu projektu (Ordering, Billing, SharedKernel), a ne technický chaos složek Twig/Doctrine/Service.
Anti-vzor: type packaging
PSR-4, autoload a services.yaml
Rozšířená představa je, že modulová struktura vyžaduje vlastní PSR-4 kořen pro každý modul.
Nevyžaduje. PSR-4 mapuje prefix namespace na základní adresář a zbytek namespace překládá
na podadresáře. Při výchozím symfonním mapování "App\\": "src/" se tedy třída
App\Ordering\Domain\Model\Order hledá v src/Ordering/Domain/Model/Order.php – přesně tam, kam ji
modulová struktura klade. Do composer.json sahat nemusíte a struktura z předchozí ukázky
funguje bez jediné změny.
Vlastní kořen na modul má smysl ve dvou situacích. Buď moduly nesedí pod src/, nebo se
z modulu má časem stát samostatný composer balíček s vlastním composer.json. Zápis pak
vypadá takto:
1{2 "autoload": {3 "psr-4": {4 "App\\Ordering\\": "modules/ordering/src/",5 "App\\Billing\\": "modules/billing/src/",6 "App\\SharedKernel\\": "src/SharedKernel/"7 }8 }9}
Po takové úpravě je potřeba spustit composer dump-autoload.
Podobný mýtus se drží u config/services.yaml. Výchozí konfigurace registruje App\
s resource: '../src/', takže služby uvnitř modulů se autoregistrují samy. Controller
dědící AbstractController dostane tag controller.service_arguments od autoconfigure: true,
takže ani ten se vypisovat nemusí. Výčet per modul je tedy volba, ne nutnost.
Vyplatí se ve chvíli, kdy modulům nastavujete jiná _defaults, nebo když chcete doménovou
vrstvu z containeru vyloučit:
1services:2 _defaults:3 autowire: true4 autoconfigure: true5 6 App\:7 resource: '../src/'8 exclude:9 - '../src/*/Domain/'10 - '../src/SharedKernel/Domain/'11 12 # Doménové služby do containeru patří, zbytek doménové vrstvy ne.13 App\Ordering\Domain\Service\:14 resource: '../src/Ordering/Domain/Service/'
Výluka drží agregáty, value objekty a specifikace mimo container. Nikdo je neinjektuje, takže jejich přítomnost v definicích služeb by jen svědčila o tom, že se s nimi zachází špatně. Doménové služby se registrují zvlášť, protože ty injektovat chcete.
Vyloučený adresář ale vypadne i z automatického aliasování rozhraní. Porty jako
OrderRepository leží právě tam, takže jejich alias na implementaci je nutné zapsat
ručně – rozebírá to kapitola o architektonických stylech.
Kontrakt modulu
Evansův Module z roku 2003 stojí na kohezi pojmů a na tom, že moduly nekoukají do sebe navzájem. Dnešní praxe pod hlavičkou modulárního monolitu přidává třetí požadavek. Kamil Grzybek popisuje modul jako vertikální řez byznysem se třemi vlastnostmi: nezávislost a zaměnitelnost, úplnost (obsahuje vše potřebné k dodání funkce) a dobře definované rozhraní, přes které se do modulu vstupuje.
Ta třetí vlastnost je posun oproti roku 2003. Nestačí zakázat cizí import; modul má
vystavit úzkou množinu typů, které smí volat okolí, a zbytek nechat interní. Prakticky
to znamená složku Ordering/PublicApi/ s command a query rozhraními plus publikované
události, a architektonické pravidlo, že z jiného modulu se smí importovat jedině odtud.
Rozdíl proti pouhému zákazu importu: refaktoring uvnitř modulu pak nikoho nezajímá,
protože se nedotkne ničeho, co soused vidí.
Modulární monolit jako celek rozebírá kapitola [DDD a microservices](/ddd-a- microservices#modular-monolith): kdy se vyplatí, jak z něj později odejít a jaká pravidla mu nastavit.
Architecture testing: hranice vynucené v CI
Konvence sama o sobě nestačí – vývojáři pod tlakem zapomenou, že
App\Billing\ nesmí volat App\Ordering\. Řešení: vynutit
pravidlo testem, který běží v CI a při porušení shodí build. Princip
je u všech nástrojů stejný: pravidla závislostí zapíšete jako definice
verzované vedle kódu a pipeline je kontroluje při každém commitu.
Pro modulový projekt z této kapitoly jde typicky o tři pravidla:
App\Orderingnesmí záviset naApp\Billing,App\InventoryaniApp\Shipping– integrace mezi BC probíhá výhradně přes domain events (Outbox).App\Ordering\Domainnesmí importovat nic zDoctrine,Symfonyani z vlastní Application a Infrastructure vrstvy – doména zůstává framework-agnostic.App\Ordering\Applicationnesmí znátApp\Ordering\Infrastructure– orchestrace závisí na rozhraní z Domain, ne na adaptéru.
Pro PHP existují dva zavedené nástroje.
phparkitect zapisuje pravidla
jako PHP definice (fluent API nad množinou tříd) v souboru
phparkitect.php v kořeni projektu:
1composer require --dev phparkitect/phparkitect2vendor/bin/phparkitect check
Do zavedeného projektu se nástroj nasazuje přes baseline. Vygenerovaný seznam existujících porušení build neshodí, takže pravidla mohou začít platit hned a starý dluh se odbourává postupně. Bez baseline skončí první spuštění stovkami chyb a tým nástroj vypne.
Druhou možností je Deptrac, který vrstvy a povolené závislosti popisuje v YAML souboru. Kompletní Deptrac konfiguraci pro DDD projekt včetně zapojení do CI najdete v kapitole Testování DDD. Zápis pravidel se mezi nástroji liší, tři pravidla výše vyjádří oba.
Souvisí: Horizontální vs. vertikální dělení, Context Mapping, Implementace v Symfony, Outbox Pattern (komunikace mezi moduly přes events).
Citace: Evans, E., Domain-Driven Design (2003), kapitola 5 A Model Expressed in Software, sekce Modules; Vernon, V., Implementing Domain-Driven Design (2013), kapitola 9 Modules; phparkitect dokumentace, github.com/phparkitect/arkitect.
08.06 Vztah těchto vzorů ke zbytku DDD#
Čtyři vzory této kapitoly se prolínají s ostatními taktickými vzory. Tabulka shrnuje, jak každý z nich sedí do triády Aggregate / Domain Event / Bounded Context:
| Vzor | Vztah k Aggregate | Vztah k Domain Event | Vztah k Bounded Context |
|---|---|---|---|
| Specification | Validuje invariant agregátu nebo filtruje seznam agregátů | Pravidlo, které spustí event (např. OrderEligibleForFreeShipping) | Pravidlo žije uvnitř BC; sdílí se jen kostra vzoru v SharedKernelu |
| Domain Service | Koordinuje 2+ agregáty bez toho, aby je propojila závislostí | Volá agregáty, které pak emitují events | Žije uvnitř BC; cross-BC koordinace patří do Application Service / Saga |
| Factory | Tvoří agregát s validovaným počátečním stavem | Při vzniku obvykle emituje first event (OrderPlaced) | Žije uvnitř BC; Factory pro cross-BC objekty neexistuje |
| Module | Seskupuje všechny agregáty BC do jednoho balíčku | Definuje hranici, přes kterou putují events (Outbox) | 1 modul = 1 BC (preferovaná aplikace) |
Poslední sloupec u Specification stojí za rozvedení. Kostra vzoru leží v SharedKernel a používají ji všechny kontexty: rozhraní,
CompositeSpecification a tři kombinátory. Konkrétní pravidlo EligibleForFreeShipping naopak patří jednomu kontextu a jinde
by nedávalo smysl. Sdílí se mechanismus, ne pravidlo.
Hlavní vztah: agregát uvnitř používá Specifications pro invarianty, vzniká přes Factory (named constructor) a spolupracuje s 2+ jinými agregáty přes Domain Service. Celá ta skupina pak žije v jednom Module, který odpovídá Bounded Contextu. Provázanost celé sady popsala už sekce 08.01.
08.07 Anti-vzory souhrn#
Pro rychlou referenci v code review zde shrneme nejčastější anti-vzory, které v týmu uvidíte. Každý z nich má protilék uvedený v příslušné sekci výše.
| Anti-vzor | Symptom | Náprava |
|---|---|---|
| Specification jako 1-line if | OrderTotalGreaterThanSpecification s jediným porovnáním |
Inlinujte podmínku; Specification má reprezentovat celou doménovou otázku |
| Specification reimplementující SQL | Specifikace má dvě nezávislé verze pravidla – jedno v PHP, druhé v DQL, každé jinde | Držte obě podoby v jedné třídě (QuerySpecification) a jistěte je kontraktním testem |
| „*Service“ všude | OrderService, CustomerService obsahuje doménovou logiku, kterou by měla obsahovat Entity |
Přesuňte logiku do Entity; Domain Service jen pro operace bez vlastníka |
| Application Service vydávaný za Domain Service | Doménová Service má v konstruktoru EntityManager a volá flush() |
Rozdělte na Domain Service (logika) + Application Handler (orchestrace) |
| Factory pro každý objekt | U každé třídy v doméně existuje samostatná Factory class | Static method (named constructor) v agregátu; Factory class jen pokud nutně potřebujete DI |
| Veřejný konstruktor agregátu | Vně agregátu lze volat new Order(...) a obejít validaci |
Privátní konstruktor + ::place() / ::reconstitute() |
Type packaging (src/Entity/, src/Service/) |
Adresářová struktura ukazuje technologii, ne doménu | Přejděte na 1 modul = 1 BC; vynuťte phparkitect |
| Modules bez architektury testů | Konvence existují, ale nikdo je nekontroluje – eroze při prvním hot-fix tlaku | Nasaďte phparkitect/deptrac do CI od prvního commitu |
| Cross-BC import bez ACL | App\Billing\Invoice přímo importuje App\Ordering\Order |
Integrace přes domain events (Outbox); v cílovém BC mapper na lokální typ |
Detailní rozbor doménových anti-vzorů – anémický model, transaction script, „Big Ball of Mud“ – najdete v kapitole Anti-vzory v DDD.
08.08 Shrnutí#
Specifications, Domain Services, Factories a Modules jsou čtyři vzory z Evansova taktického katalogu, které praktické průvodce vynechávají. Bez nich agregáty bobtnají, doménový model upadá do anémie a organizace projektu zatemňuje doménovou strukturu.
- Specification Pattern proměňuje booleovská doménová pravidla
v prvotřídní objekty s mluvícími jmény. Kombinátory
and,or,notumožňují skládání bez vnořenýchif-ů, double-dispatch drží PHP i DQL podobu pravidla v jedné třídě. - Domain Services zachytávají doménovou logiku, která nepatří do žádné Entity ani Value Objektu. Jsou stateless, žijí v Domain vrstvě a nesmí volat perzistenci. Jejich častá záměna s Application a Infrastructure Service je nejčastější příčinou anémického modelu.
- Factories řeší komplexní vznik agregátu. Preferovaná forma je named constructor (statická metoda na agregátu) s privátním konstruktorem. Samostatná Factory class přichází na řadu, jen když potřebujete DI závislosti.
- Modules organizují kód podle Ubiquitous Language, ne podle
technických vrstev. V Symfony 8 na to stačí výchozí PSR-4 mapování
App\nasrc/. Skutečnou cenu má až publikované rozhraní modulu a vynucení hranic v CI přes phparkitect nebo deptrac.
Přeskočená vrstva tím ale nekončí. Evansova část III, Supple Design, obsahuje dalších osm vzorů: Intention-Revealing Interfaces, Side-Effect-Free Functions, Assertions, Standalone Classes, Closure of Operations, Declarative Design, Drawing on Established Formalisms a Conceptual Contours. Tato kniha je systematicky nepokrývá – jde o vzory na úrovni jednotlivých metod a podpisů, ne na úrovni stavebních bloků modelu. Kdo chce v taktickém designu pokračovat dál, má je jako další čtení.
Společně drží agregát v rozumné velikosti, doménu oddělenou od infrastruktury a projekt čitelný po roce vývoje. Nasazují se postupně, po jednom. První iterace stačí: 1 modul = 1 BC, named constructor pro 2–3 hlavní agregáty, Domain Service tam, kde jste dosud měli „*Service“ bez vlastníka. Specifications dávají smysl ve chvíli, kdy se objeví druhá nebo třetí kombinace téhož pravidla.
Jak se agregáty chovají při tisících transakcí za sekundu, kde má DDD overhead a jak ho minimalizovat, ukazuje kapitola Read modely, projekce a výkon. Kapitola Anti-vzory v DDD doplňuje detail u anémického modelu, který v sekci 08.03 padl jen krátce.
Časté otázky
Kdy přesně se vyplatí Specification Pattern?
Vyplatí se, když stejné nebo příbuzné pravidlo potřebujete na nejméně dvou místech, případně ho uplatňujete v doméně i v repozitáři přes double-dispatch. Pokud pravidlo používáte jednou a obsahuje jeden řádek kódu, je samostatná třída over-engineering – inlinujte ho. Hlavní test: má pravidlo doménové jméno, které tým používá v debatách (premium customer, eligible for free shipping)? Pokud ano, Specification jeho jménu dá kód. Pokud byste třídu pojmenovali OrderTotalGreaterThanSpec, je to jen operátor – vraťte se k inline ifu. Detail v sekci Specification – Kdy použít.
Má Domain Service mít stav?
Ne. Domain Service je z definice stateless – žádné instance variables měnící se mezi voláními, žádný interní cache, žádný čítač. Se stavem se ztrácí idempotence a bezpečnost při souběhu. Závislosti jsou ale jiné téma než stav a odpověď na ně kategorická není: Mailer nebo HTTP klient službu skutečně posouvají do Application či Infrastructure vrstvy, u repozitáře se zdroje rozcházejí. Khorikov připouští impure doménovou službu, Noback umísťuje rozhraní repozitáře přímo do Domain vrstvy. Vodítko: nejdřív zvažte, jestli data nemá dodat volající; když je jinak nezískáte, závislost na doménovém rozhraní je přijatelná. Detail v sekci MoneyTransferService a srovnávací tabulce.
Factory metoda nebo Factory class – jak se rozhodnout?
Standardně volte named constructor (statická metoda na agregátu). Vernon (2013) staví v kapitole 11 Factories do popředí factory metodu na agregátním kořeni a samostatnou factory řeší až jako druhou možnost na úrovni service. PHP podobu s privátním konstruktorem popsal Mathias Verraes v textu Named Constructors in PHP (2014). K samostatné Factory class přejděte teprve tehdy, když vznik agregátu nutně vyžaduje DI závislosti – typicky CartRepository, PricingService, ClockInterface, externí lookup. Statická metoda totiž tyto závislosti nemůže přijímat bez service locatoru, který je sám anti-vzor. Pokud Factory class neobsahuje žádnou DI závislost a jen volá new Order(...), je to redundantní vrstva – smazat. Detail v sekci Factory class.
Jak vynutit hranice mezi Moduly v PHP projektu?
Konvence sama o sobě se rozpadá – vývojáři pod tlakem „udělej rychle“ přepíšou cross-BC import za 5 minut. Spolehlivé vynucení vyžaduje nástroj v CI: phparkitect nebo deptrac. Definujete pravidla typu „App\\Ordering nesmí závisět na App\\Billing“, „App\\Ordering\\Domain nesmí znát Doctrine“, a CI build selže při porušení. Náklad je jeden konfigurační soubor, zisk je jistota, že modulární organizace přežije i pátého nového vývojáře. Detail v sekci Architecture testing.
Jak má vypadat namespace třídy, která sedí na hranici dvou Bounded Contextů?
V čistém DDD žádná třída na hranici dvou BC nesedí. Pokud objevíte takový případ, je to signál, že hranice je špatně nakreslená nebo že potřebujete Anti-Corruption Layer (ACL). Konkrétní řešení: v každém BC žije vlastní typ s vlastním namespace. App\\Ordering\\Domain\\CustomerId v Ordering kontextu, App\\Billing\\Domain\\CustomerId v Billing kontextu, případně mapování přes events. Pokud opravdu existuje univerzální koncept (Money, Currency, Country), patří do SharedKernel – ale tento balíček musí být explicitně malý, stabilní a s dohodou všech týmů. Souvisí Modul jako Bounded Context.
Můžu Specification a Domain Service kombinovat?
Ano, a v praxi to často děláte. Domain Service obvykle koordinuje 2+ agregáty a jedno z rozhodnutí přitom nese Specification – typicky „může tato objednávka projít k expedici?“ = kompozice HasBeenPaid AND ItemsInStock AND NotInBlacklist. Domain Service tu specifikaci instancuje a volá isSatisfiedBy(), podle výsledku zavolá metodu na agregátu. Vzory se vzájemně doplňují: Specification je pravidlo, Domain Service je akce, která pravidlo aplikuje na 2+ agregáty. Detail v sekci 08.06 Vztah těchto vzorů.