Doplňující taktické vzory: Specifications, Domain Services, Factories, Modules
Evansova kniha má vedle entit, value objektů a agregátů další čtyři taktické vzory, které se v praxi často přeskakují: Specification pro pravidla vyjádřená jako samostatný predikát, Domain Service pro chování, které nepatří žádné entitě, Factory pro složitý vznik agregátu a Module pro organizaci kódu. Ke každému ukázky v PHP 8.4, anti-vzory a srovnání.
Obsah kapitoly
Kapitola ve videokurzu
Celý videokurzPřehrávač YouTube se načte až po kliknutí.
Kapitola Základní koncepty DDD probrala entity, hodnotové objekty, agregáty a jen stručně doménové služby a factories. Evans přitom službám, factories, specifikacím a modulům věnuje v částech II a III desítky stran. Průvodci je přeskakují nebo je zaměňují s jinými vzory. Zde se ukáže, kdy jsou užitečné, jak je zapsat v PHP 8.4 a co stojí jejich špatné použití.
Všechny čtyři vzory pocházejí přímo z Evansovy knihy. Specification (kap. 9) dělá z doménových predikátů skládatelné objekty. Domain Service (kap. 5) zachytává logiku bez přirozeného vlastníka mezi entitami a hodnotovými objekty. Factory zapouzdřuje vznik agregátů se složitými invarianty (kap. 6; u Vernona kap. 11). Module (rovněž kap. 5) organizuje kód podle Ubiquitous Language.
08.01 Proč tyto vzory přehlížíme#
Většina online průvodců o DDD končí u agregátu. Vývojář, který umí odlišit entitu
od hodnotového objektu a rozumí invariantům, má pocit, že taktický design ovládá.
Specification, Domain Service, Factory a Module mu pak připadají jako nadbytečná
abstrakce, protože totéž jde napsat if-em, statickou metodou nebo prostým balíčkem
v src/. Ta intuice klame.
Malý projekt se bez těchto vzorů obejde. V netriviální doméně, tedy tam, kde se DDD vyplácí, jejich absence vede k bobtnání agregátů, anémii modelu a duplikaci pravidel. Kód pak přestává odrážet strukturu domény. Vzory přitom na sebe navazují, jak ukazuje sekce 08.06.
08.02 Specification Pattern#
Co to je
Specification je samostatný objekt, který zapouzdřuje jeden booleovský predikát nad doménovým objektem – typicky odpověď na otázku „splňuje tento agregát konkrétní pravidlo?“. Minimální rozhraní:
1interface Specification2{3 public function isSatisfiedBy(mixed $candidate): bool;4}
Rozhraní je triviální, rozhodnutí za ním ne. 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-ů v service
vrstvě a stává se pojmenovaným prvkem Ubiquitous Language.
Vzor formálně popsali Evans a Fowler ve společném článku Specifications [martinfowler.com] z roku 1997; Evans ho později zařadil do Domain-Driven Design (2003), kapitoly 9 Making Implicit Concepts Explicit. Východisko je v obou textech stejné: pravidlo, které se v doméně opakuje, 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 části Building Blocks of a Model-Driven Design zařadil Layered Architecture, Entities, Value Objects, Domain Events, Services, Modules, Aggregates, Repositories a Factories. Vzor tedy nepřeskakuje jen praxe; vypadl i z autorova vlastního souhrnu.
Kdy použít
Původní článek 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 tato použití objevují 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á v doméně i v repozitáři. Tatáž specifikace odpoví na „splňuje tento konkrétní objekt pravidlo?“ (in-memory predikát) a zároveň vrátí z databáze všechny objekty, které pravidlo splňují (query). Obě podoby pravidla (PHP i dotaz přes Doctrine) drží pohromadě v jedné třídě; repozitář specifikaci dostane jako parametr dotazu (viz Specifikace jako dotaz 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. Agregát nekontroluje všechno sám v setterech, ale deleguje na specifikaci, kterou jde číst i testovat samostatně.
Článek k tomu přidává tři implementační strategie, které se liší cenou. 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ý bez spousty specializovaných tříd, ale vyžaduje investici
do frameworku. Zbytek sekce ten framework ukazuje, aby bylo vidět, co investice obnáší.
Kdy NE
Specification má nezanedbatelnou cenu: každé pravidlo znamená novou třídu, nový soubor a 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“.
Článek 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ů
Hodnotu vzoru přináší skládání přes booleovské kombinátory and, or a not.
Místo klubka if-ů a else-ů vznikne algebraický výraz nad pojmenovanými atomy.
Hierarchie tříd:
Interface a abstraktní kompozit
Plné rozhraní vystavuje všechny tři kombinátory. Rozšiřuje jednometodovou verzi
z úvodu sekce a ve zbytku kapitoly ji nahrazuje. Abstraktní třída pak kombinátory
implementuje přes AndSpecification, OrSpecification a 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}
Konkrétní specifikace kombinátory neimplementují, dodá je abstraktní třída:
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 stojí tři konkrétní pravidla z kontextu Ordering. Každé nese mluvící doménové
jméno a kombinátory and/or/not dědí.
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. Varianta s továrnami
v sekci 08.04 adresu nenese – každá ukázka rozšiřuje kanonický Order jen o to,
co její vzor potřebuje.
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 od 1000 Kč v EU, kromě zákazníků
na blacklistu“ je trojice atomických specifikací spojená kombinátorem and. Místo
trojnásobně vnořeného if-u vznikne jeden čitelný výraz:
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}
V testu jde pravidlo rozložit na atomy a ověřit každý zvlášť. Když produktový tým
rozhodne, že se blacklist nemá kontrolovat, z kompozice zmizí jeden řádek a nikdo
nemusí pročítat sevřený if hluboko v service vrstvě.
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 článku, které se neujaly
Článek 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}
Metodu musí implementovat každý kombinátor a u or a not už odpověď není
jednoznačná. Přínos je stejně zřejmý: formulář nebo API vrátí důvod zamítnutí odvozený
z pravidla, které rozhodlo, místo ručně psané hlášky, která se časem rozejde s logikou.
Specifikace jako dotaz do Doctrine
Specifikace slouží i ve druhé roli – jako parametr dotazu do repozitáře. Místo
metody findEligibleForFreeShippingInEU(): array, která by přibývala s každou novou
kombinací pravidel, dostane repozitář libovolnou specifikaci, převede ji na dotaz
a vrátí výsledek. Specifikace nese pravidlo, repozitář ví, jak ho přeložit do persistence.
Pro tuto spolupráci se často používá označení double dispatch, v přesném smyslu ale
sedí jen na Evansovu variantu z kapitoly 9. Tam repozitář předá specifikaci sám sebe
(satisfyingElementsFrom($this)) a ta si zpětně zavolá jeho specializovanou dotazovou
metodu. Varianta v této sekci zpětné volání nemá: repozitář si od specifikace vyžádá
výraz a sám ho spustí.
Rozhoduje jediná věc: co specifikace vrací. Mutovat předaný QueryBuilder se nabízí,
ale vede do slepé uličky: 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 * Specifikace zná pravidlo, repozitář ví, jak výraz spustit12 * 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}
Dotazová podoba předpokládá, že objednávka drží celkovou částku v mapovaných vlastnostech
totalAmount a totalCurrency. Kanonický Order součet počítá z položek za běhu
a Criteria pracuje jen s mapovanými vlastnostmi, takže bez takové denormalizace dotaz
nemá nad čím filtrovat.
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í. Join vede přes asociaci uvnitř agregátu; na zákazníka objednávka odkazuje jen
přes CustomerId:
1/**2 * @param QuerySpecification<Order> $spec3 * @return list<Order>4 */5public function matchWithItems(QuerySpecification $spec): array6{7 return $this->em->createQueryBuilder()8 ->select('o', 'i')9 ->from(Order::class, 'o')10 ->join('o.items', 'i')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. Soulad ale nic 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 Benjamin Eberlei v textu On Taming Repository Classes in Doctrine (2013). Jeho
specifikace upravují předaný QueryBuilder, tedy variantu, kterou tato sekce kvůli
skládání opouští; z jeho článku vychází i balíček happyr/doctrine-specification.
Kévin Gomez na něj navázal textem On Taming Repository Classes in Doctrine… Among
other things (7. 2. 2015).
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 na andX, OrSpecification na orX, NotSpecification na 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 'Only a composition of QuerySpecification can be translated into a query.',11 );12 }13 14 return Criteria::expr()->andX(15 $this->left->toExpression(),16 $this->right->toExpression(),17 );18}
Kombinátor implementuje QuerySpecification, jeho operandy ale query specifikacemi
být nemusí. Typová kontrola na vstupu match() proto do listů nedosáhne a rozpor se ozve
až běhovou výjimkou. Výměnou za to jde skládat i or a not, ne jen konjunkci.
Statická analýza je naopak přísnější než běh. Metoda and() v CompositeSpecification
deklaruje návratový typ Specification, takže PHPStan i Psalm složenou specifikaci
na vstupu match() odmítnou, přestože za běhu projde. Volající typ zúží sám, například
assert($promo instanceof QuerySpecification) před voláním match().
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.
Skutečné limity leží jinde. 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í.
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), což ruční kostra z této kapitoly neumí.
Ukázky výše vysvětlují, co balíček dělá uvnitř. 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, článek
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 zavádějí Základní koncepty a Implementace v Symfony ukazuje, jak ji zaregistrovat v containeru. Zde jde o rozhodovací kritéria: kdy má služba vzniknout, kdy jde jen o logiku vytrženou z entity a kudy vede hranice vůči Application vrstvě.
Co to je
Domain Service je bezstavový objekt s doménovou logikou, která nemá přirozeného vlastníka mezi entitami a hodnotovými objekty 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 hodnotového objektu jako jejich přirozená metoda, (2) její rozhraní tvoří jiné prvky doménového modelu a (3) nemá vlastní stav.
Signálem je operace, kterou by si žádná entita nemohla vzít na starost, aniž by musela příliš vědět o té druhé.
Kdy použít
Evansovým příkladem je Funds Transfer, převod peněz mezi dvěma účty. Ani jeden účet nezná ten druhý a ani jeden není přirozeným vlastníkem operace; převod je doménový pojem sám o sobě. Podobně pricing engine počítá cenu objednávky z cenových pravidel, segmentu zákazníka, košíku a kupónu a žádný z těchto objektů výpočet nevlastní. Stejnou povahu má credit scoring: odpověď na „má tento zákazník nárok na úvěr X?“ vzniká kombinací několika faktorů.
Čtvrtým typickým případem je koordinátor dvou agregátů – operace nad dvěma agregáty, z nichž žádný nesmí znát detaily druhého. Pokud oba mění, naráží na pravidlo „jeden agregát na transakci“ (viz poznámku za ukázkou níže).
Kdy NE
Domain Service je v DDD nejčastěji zneužívaný vzor. Vývojáři zvyklí na klasickou
vrstvenou architekturu reflexivně založí OrderService, CustomerService
a InvoiceService, přesunou do nich logiku z entit a vyrobí
anémický doménový model.
Než Domain Service vznikne, projděte tři kontrolní otázky:
- 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 v „doménové“ service museli
volat
EntityManager->flush(), jde o 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
Bankovní převod 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\InsufficientFundsException;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 * Bezstavová – mezi voláními nic nedrží, mění jen agregáty, které dostane.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 InsufficientFundsException::onAccount($from->id(), $amount);32 }33 34 if ($from->currency() !== $to->currency()) {35 // Holá \DomainException je zkratka; v projektu pojmenovaná výjimka.36 throw new \DomainException(37 'Currency mismatch – use FxTransferService for cross-currency transfers.',38 );39 }40 41 $from->withdraw($amount, $reference, $when);42 $to->deposit($amount, $reference, $when);43 }44}
„Opravdovou“ Domain Service poznáte podle tří rysů:
- Žá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 –
InsufficientFundsException,\DomainException– ne\RuntimeExceptionnebo HTTP status kódy.
Ukázka se drží Evansova výkladu: služba mění oba účty a handler, který ji volá, je uloží jednou transakcí. Kapitola Návrh agregátu tentýž převod uvádí jako anti-vzor, protože porušuje pravidlo „jeden agregát na transakci“. Obstojí tam, kde tým vědomě volí jednu z výjimek popsaných v sekci Kdy se vodítko poruší. Jinak si doménová služba ponechá jen rozhodnutí (dostatek prostředků, shoda měn) a samotný převod rozloží sága na dvě transakce.
První rys se často zpřísňuje na „doménová služba nesmí mít v konstruktoru repozitář“. Jako pravidlo to neobstojí a zdroje se v něm 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 proto není zákaz. Než služba dostane repozitář, vyplatí se zvážit, jestli jí data nemá dodat volající. Služba, která si data načítá sama, přebírá kus orchestrace a její test přestane být čistě jednotkový. Když data jinak získat nejde, typicky u pravidla 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 parametrů jejího konstruktoru.
Domain Service vs. Application Service vs. Infrastructure Service
Třída se sufixem Service se v kódu objeví téměř vždy. Liší se tím, kterou ze tří
rolí plní. Tabulka shrnuje rozdíly, na které se vyplatí ptát v code review:
| 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ář) | Jen adaptér perzistence (Doctrine repozitář); Mailer ani platební brána ne |
| Vyhazuje výjimky | Doménové (InsufficientFundsException) |
Aplikační (UnauthorizedException, validation) |
Infrastrukturní (ConnectionException) |
| Příklad jména | MoneyTransferService, PricingService |
PlaceOrderHandler, RegisterUserHandler |
SymfonyMailer, StripePaymentGateway |
| Test | Pure unit, bez Symfony kernel | Unit s InMemory fake repozitáři | Integrační (kontrakt s reálným systémem) |
| Sufix v PHP | *Service (volitelně) |
*Handler, *UseCase |
*Gateway, *Adapter, *Client |
Jednotný sufix *Service smaže rozdíl mezi třemi rolemi z tabulky. V Application vrstvě
se proto v praxi používá *Handler nebo *UseCase. Doménová služba nese sufix Service
jen tehdy, když zdůrazňuje „operaci bez vlastníka“; často je lepší přímo doménové jméno
bez sufixu (FundsTransfer, PricingEngine).
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 rekonstituce se objevují už v Základních konceptech jako součást výkladu agregátu. Zde se berou 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 zapouzdřuje složitou logiku vzniku agregátu nebo hodnotového objektu tam, kde 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.
Většině agregátů konstruktor stačí. Factory se hodí, když:
- Vznik agregátu vyžaduje validaci, kterou nelze provést až po konstrukci
(např. „objednávka založená rovnou s položkami (
placePhysical()níže) musí mít alespoň jednu, jinak nevznikne“). - Vznik je polymorfní – z různých vstupů vznikají různé pod-typy stejného agregátu
(například
Order::placePhysical()vs.Order::placeDigital()). - 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
Factory se nevyplatí tam, kde konstruktor zůstává př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 rozhodováním podle řetězce. Typová bezpečnost se tím ztrácí. - Factory pro každý objekt v doméně – over-engineering. Factory vzniká podle potřeby, ne plošně.
Vzor 1: Static method factory (preferovaný)
V PHP 8.4 má factory nejčastěji podobu statické pojmenované konstrukční metody na samotném agregátu (named constructor). Konstruktor je privátní a ven vedou jen pojmenované vstupní body s doménovým významem:
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 public readonly OrderId $id,21 public readonly CustomerId $customerId,22 array $items,23 private readonly OrderType $type,24 // Zde čas vzniku objednávky. V kanonickém modelu z Návrhu agregátu25 // nese placedAt čas potvrzení a plní ho až confirm().26 private readonly \DateTimeImmutable $placedAt,27 ) {28 $this->items = $items;29 // Konstruktor jen plní stav. Eventy zaznamenávají factory metody –30 // konstruktorem prochází i reconstitute(), která žádný event vyvolat nesmí.31 }32 33 // Továrna vedle kanonického Order::place(OrderId, CustomerId). Přebírá34 // rovnou seznam položek, aby invariant platil už při vzniku. Kanonická35 // placeWithItems() s primitivními řádky je v kapitole o outboxu.36 /**37 * Vznik objednávky s fyzickým zbožím – protějšek placeDigital() níže.38 *39 * @param list<OrderItem> $items40 */41 public static function placePhysical(42 CustomerId $customerId,43 array $items,44 \DateTimeImmutable $placedAt,45 ): self {46 if (count($items) === 0) {47 throw EmptyOrderException::cannotBePlaced();48 }49 50 $order = new self(51 id: OrderId::generate(),52 customerId: $customerId,53 items: $items,54 type: OrderType::Physical,55 placedAt: $placedAt,56 );57 $order->record(new OrderPlaced($order->id, $customerId));58 59 return $order;60 }61 62 /**63 * Polymorfní vznik – pouze digitální obsah, jiná pravidla64 * (žádná dopravní adresa, instantní doručení).65 *66 * @param list<DigitalItem> $items67 */68 public static function placeDigital(69 CustomerId $customerId,70 array $items,71 \DateTimeImmutable $placedAt,72 ): self {73 if (count($items) === 0) {74 throw EmptyOrderException::cannotBePlaced();75 }76 77 $order = new self(78 id: OrderId::generate(),79 customerId: $customerId,80 items: array_map(static fn (DigitalItem $i): OrderItem => $i->toOrderItem(), $items),81 type: OrderType::Digital,82 placedAt: $placedAt,83 );84 $order->record(new OrderPlaced($order->id, $customerId));85 86 return $order;87 }88 89 /**90 * Vznik z importu – odlišná validace, zákazníka neidentifikuje přes CustomerId,91 * ale přes externí klíč, který se uvnitř naváže na guest CustomerId.92 */93 public static function fromImport(94 ImportedOrderRow $row,95 CustomerLookup $lookup,96 \DateTimeImmutable $placedAt,97 ): self {98 $customerId = $lookup->byEmail($row->customerEmail) ?? $lookup->guestId();99 $items = ImportedItems::map($row->items);100 101 return self::placePhysical($customerId, $items, $placedAt);102 }103}
Signatura placePhysical() 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é; volba mezi nimi
rozhoduje, zda agregát smí 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 nevytvoří
Ordercestou, která obejde validaci. Invariant hlídá jazyk, ne disciplína. - Víc cest ke vzniku bez dědičnosti.
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 přestává stačit, když vznik agregátu potřebuje injektované
závislosti (repozitáře, externí služby, konfiguraci). Jednu službu jí volající
předá parametrem, jak ukazuje fromImport() s CustomerLookup. Jakmile jsou ale
závislosti tři a každý volající by je musel shánět sám, přehlednější je samostatná
Factory class, které je dodá container:
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\Service\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 * Závislosti dodá container, volající je nemusí sháně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->get($cartId);30 31 if ($cart->isEmpty()) {32 // Zkratka: v projektu pojmenovaná výjimka, např. EmptyCartException.33 throw new \DomainException('Cannot place order from empty cart.');34 }35 36 $pricedItems = $this->pricing->priceItems($cart->items(), $customer);37 38 return Order::placePhysical(39 customerId: $customer,40 items: $pricedItems,41 placedAt: $this->clock->now(),42 );43 }44}
Factory class uvnitř volá Order::placePhysical(). Invariant „aspoň 1 položka“
nepřebírá, ten zůstává v named constructoru agregátu. Factory řeší jen sestavení
vstupních dat.
Trojice CartRepository, PricingService a ClockInterface v konstruktoru vypadá jako
rozpor se sekcí 08.03, kde repozitář v konstruktoru služby vyžadoval zdůvodnění. 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ím typem factory je reconstitution – obnovení agregátu z perzistence. S Doctrine ji obstará hydrator. Event Sourcing nebo vlastní mapper ale potřebují factory, která invarianty nekontroluje, protože obnovovaný stav validací prošel už při vzniku:
1/**2 * Rekonstituce ze stavu načteného z DB / event streamu.3 * Tento pojmenovaný konstruktor nekontroluje invarianty –4 * obnovovaný stav je z definice platný, 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ávají factory metody (::place(), ::placePhysical()),
ne konstruktor. Rekonstituce nesmí mít vedlejší efekty: obnovuje stav, žádná doménová událost se
nestala. Doctrine při hydrataci konstruktor obchází, ::reconstitute() výše ho ale
volá. Kdyby event zaznamenával konstruktor, každé načtení agregátu přes vlastní mapper
nebo z event streamu 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 signalizují, že tato cesta vzniku
je vyhrazena infrastruktuře. Command handler, který by ji volal místo ::place(),
by obešel invarianty 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é členění kódu do balíčků pojmenovaných podle Ubiquitous Language. Namespace je jen nástroj; podstatou vzoru je princip, že struktura balíčků odráží doménový jazyk, ne technické vrstvy ani použité knihovny. Evans mu věnuje samostatnou pasáž v kapitole 5 Domain-Driven Design (2003) a moduly chápe jako vyjádření hrubší struktury modelu.
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\nesahá přímo doApp\Ordering\.
Modul jako Bounded Context
Nejčastěji se vzor uplatní jako 1 modul = 1 Bounded Context:
1src/2 Ordering/ ← MODULE = Bounded Context3 Domain/4 Model/5 Order.php ← Aggregate Root6 OrderItem.php7 Repository/8 OrderRepository.php ← Interface9 Specification/10 EligibleForFreeShipping.php11 InEUCountry.php12 Service/13 PricingService.php ← Domain Service14 Factory/15 OrderFromCartFactory.php16 Event/17 OrderPlaced.php18 Exception/19 EmptyOrderException.php20 Application/21 Command/22 PlaceOrder.php23 Handler/24 PlaceOrderHandler.php25 Query/26 ListOrders.php27 ListOrdersHandler.php28 Infrastructure/29 Repository/30 DoctrineOrderRepository.php31 Doctrine/32 Type/OrderIdType.php33 Http/34 OrderController.php35 Messenger/36 OrderPlacedSubscriber.php37 Billing/ ← Jiný BC = jiný modul38 Domain/39 Invoice.php40 ...41 Application/42 ...43 Infrastructure/44 ...45 SharedKernel/ ← Sdílený jazyk a typy46 Domain/47 Money.php48 Currency.php49 Country.php50 AggregateRoot.php51 Specification/52 Specification.php53 CompositeSpecification.php54 AndSpecification.php55 OrSpecification.php56 NotSpecification.php57 QuerySpecification.php
Příbuzné členění po funkcích rozebírá sekce Vertical Slice Architecture. Pro tuto kapitolu stačí jedno pozorování: nejvyšší úroveň adresářů ukazuje doménovou mapu projektu (Ordering, Billing, SharedKernel), ne technické složky 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 a factory třídy do containeru patří, zbytek doménové vrstvy ne.13 App\Ordering\Domain\Service\:14 resource: '../src/Ordering/Domain/Service/'15 16 App\Ordering\Domain\Factory\:17 resource: '../src/Ordering/Domain/Factory/'
Výluka drží agregáty, hodnotové objekty a specifikace mimo container. Nikdo je
neinjektuje; kdyby je někdo injektoval, zachází s nimi špatně. Doménové služby a factory
třídy typu OrderFromCartFactory se registrují zvlášť, protože ty se injektují.
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 nízké provázanosti mezi moduly. 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, co funkce potřebuje) a dobře definované rozhraní, přes které se do modulu vstupuje.
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.
Oproti pouhému zákazu importu tak refaktoring uvnitř modulu nikoho dalšího nezasáhne,
protože se nedotkne ničeho, co soused vidí.
Modulární monolit jako celek rozebírá kapitola DDD a microservices: kdy se vyplatí, jak z něj později odejít a jaká pravidla mu nastavit.
Architecture testing: hranice vynucené v CI
Konvence sama nestačí – vývojáři pod tlakem zapomenou, že App\Billing\ nesmí
volat App\Ordering\. Pravidlo proto vynucuje test, který běží v CI a při
porušení shodí build. Princip je u všech nástrojů stejný: pravidla závislostí leží
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 integrační události (Outbox). Výjimku tvoří identifikátory cizích kontextů (App\*\Domain\ValueObject\*Id): agregát na cizí agregát odkazuje právě přes ID (Návrh agregátu).App\Ordering\Domainnesmí importovat nic zeSymfony, z běhové části Doctrine ORM (EntityManager,QueryBuilder) ani z vlastní Application a Infrastructure vrstvy. Vědomé výjimky jsou tři. KomponentaSymfony\Component\Uidgeneruje identifikátory (Uuid::v7()). Mapovací atributyDoctrine\ORM\Mappingkniha dává přímo na doménové třídy (viz volba mappingu). Na knihovnědoctrine/collectionsstojí kolekce agregátu iCriteriave specifikacích.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. Porušení z vygenerovaného seznamu build neshodí, takže pravidla platí 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 konfiguračním souboru. Kompletní Deptrac konfiguraci pro DDD projekt včetně zapojení do CI ukazuje kapitola 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#
Tabulka shrnuje, jak čtyři vzory této kapitoly souvisejí s agregátem, doménovou událostí a Bounded Contextem:
| 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 / ságy |
| Factory | Tvoří agregát s validovaným počátečním stavem | Při vzniku obvykle nahraje první 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 si žádá vysvětlení. Kostru vzoru (rozhraní,
CompositeSpecification a tři kombinátory) sdílejí všechny kontexty přes SharedKernel.
Konkrétní pravidlo EligibleForFreeShipping naopak patří jednomu kontextu a jinde
by nemělo význam. Sdílí se mechanismus, ne pravidlo.
Agregát uvnitř používá specifikace pro invarianty, vzniká přes factory (named constructor) a s dalšími agregáty spolupracuje přes doménovou službu. Celá skupina žije v jednom modulu, který odpovídá Bounded Contextu.
08.07 Anti-vzory souhrn#
Rychlá reference pro code review. Nápravu každého anti-vzoru rozebírá příslušná sekce 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 – jedna 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 architektonických 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 integrační události (Outbox); v cílovém BC mapper na lokální typ |
Doménové anti-vzory – anémický model, transaction script, „Big Ball of Mud“ – podrobně rozebírá kapitola Anti-vzory a typické chyby.
08.08 Shrnutí#
Specifications, Domain Services, Factories a Modules jsou čtyři vzory z Evansova taktického katalogu, které praktičtí průvodci vynechávají. Bez nich agregáty bobtnají, doménový model chudne a struktura projektu zakrývá doménu.
- Specification Pattern proměňuje booleovská doménová pravidla
v prvotřídní objekty s mluvícími jmény. Kombinátory
and,or,notskládají pravidla bez vnořenýchif-ů a rozhraníQuerySpecificationdrží PHP i dotazovou podobu pravidla (DoctrineCriteria) v jedné třídě. - Domain Services zachytávají doménovou logiku, která nepatří do žádné entity ani hodnotového objektu. Jsou bezstavové, žijí v Domain vrstvě a nevolají perzistenci. Když se zamění s Application nebo Infrastructure Service, logika odteče z entit a model zanémií.
- 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.
Výčet přeskakovaných vzorů tím nekončí. Evansova kapitola 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. Kniha je systematicky nepokrývá, protože jde o vzory na úrovni jednotlivých metod a signatur, ne stavebních bloků modelu. Pro pokračování v taktickém designu jsou dalším čtením.
Čtyři vzory kapitoly společně drží agregát v rozumné velikosti, doménu oddělenou od infrastruktury a projekt čitelný i po roce vývoje. Nasazují se postupně. Na první iteraci stačí 1 modul = 1 BC, named constructor pro 2–3 hlavní agregáty a Domain Service tam, kde dosud byla „*Service“ bez vlastníka. Specifikace se vyplatí 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 a typické chyby 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 QuerySpecification. Pravidlo použité jednou a o jednom řádku kódu nepotřebuje samostatnou třídu, patří inline. 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 tomu jménu dá kód. Třída pojmenovaná OrderTotalGreaterThanSpec je jen operátor a patří zpět do 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?
Výchozí volbou je named constructor (statická metoda na agregátu). Vernon (2013) staví v kapitole 11 Factories do popředí factory metodu na kořeni agregátu 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). Samostatná Factory class přichází na řadu, když vznik agregátu vyžaduje DI závislosti – typicky CartRepository, PricingService, ClockInterface, externí lookup. Jednu službu jde statické metodě předat parametrem, s několika závislostmi by je ale musel shánět každý volající. Factory class bez jediné DI závislosti, která jen volá new Order(...), je redundantní vrstva a patří smazat. Detail v sekci Factory class.
Jak vynutit hranice mezi Moduly v PHP projektu?
Samotná konvence nevydrží – vývojář pod tlakem „udělej rychle“ přidá cross-BC import za pět minut. Spolehlivé vynucení vyžaduje nástroj v CI: phparkitect nebo deptrac. Pravidla typu „App\\Ordering nesmí závisět na App\\Billing“ nebo „App\\Ordering\\Domain nesmí volat EntityManager“ při porušení shodí CI build. Stojí to jeden konfigurační soubor a 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í. Takový případ signalizuje, ž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\\ValueObject\\CustomerId v Ordering kontextu, App\\Billing\\Domain\\ValueObject\\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 záměrně malý, stabilní a s dohodou všech týmů. Souvisí Modul jako Bounded Context.
Lze Specification a Domain Service kombinovat?
Ano, v praxi se kombinují často. 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ů.