Kapitola 08 · Taktika · Doplňující taktické vzory: Specifications, Domain Services, Factories, Modules

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.

Autor M. Katuščák
Doba čtení ≈ 28 min
Náročnost pokročilá
Publikováno · Aktualizováno ·
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:

php src/SharedKernel/Domain/Specification/Specification.php (jádro vzoru)
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:

  1. 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í.
  2. 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).
  3. 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 AndSpecification složená z N pod-pravidel čitelných z databáze.
  4. 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ě:

FIG. 08.2-A Specification Pattern: kompozice booleovské logiky

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:

php src/SharedKernel/Domain/Specification/Specification.php
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:

php src/SharedKernel/Domain/Specification/CompositeSpecification.php
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}
php src/SharedKernel/Domain/Specification/AndSpecification.php
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}
php src/SharedKernel/Domain/Specification/OrSpecification.php
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}
php src/SharedKernel/Domain/Specification/NotSpecification.php
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.

php src/Ordering/Domain/Specification/EligibleForFreeShipping.php
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}
php src/Ordering/Domain/Specification/InEUCountry.php
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}
php src/Ordering/Domain/Specification/NotInBlacklist.php
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:

php src/Ordering/Application/Service/FreeShippingPolicy.php + Application/BlacklistRegistry.php
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í.

php src/SharedKernel/Domain/Specification/Specification.php + CompositeSpecification.php (rozšíření o zbytkovou specifikaci)
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}
php src/SharedKernel/Domain/Specification/AndSpecification.php (doplněk)
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:

php src/SharedKernel/Domain/Specification/QuerySpecification.php
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}
php src/Ordering/Domain/Specification/EligibleForFreeShipping.php (rozšířená verze)
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:

php src/Ordering/Infrastructure/Repository/DoctrineOrderRepository.php (výřez: match přes specifikaci)
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í:

php src/Ordering/Infrastructure/Repository/DoctrineOrderRepository.php (fragment)
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:

php src/SharedKernel/Domain/Specification/AndSpecification.php (doplněk)
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:

  1. 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.
  2. 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.
  3. 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:

php src/Banking/Domain/Service/MoneyTransferService.php
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:

  1. Žádný stav – třída nedrží mezi voláními nic vlastního. Pracuje s objekty, které dostane v parametrech.
  2. Žá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().
  3. Vyhazuje doménové výjimkyInsufficientFunds, \DomainException – ne \RuntimeException nebo 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 CustomerId přes CustomerLookup.
  • 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í vznikOrderFactory::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:

php src/Ordering/Domain/Model/Order.php (varianta s továrnami)
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:

  1. Doménové jméno. Order::place() nebo Order::placeDigital() nese sémantiku, kterou new Order(...) postrádá.
  2. Privátní konstruktor. Žádný kód mimo agregát nesmí Order vytvořit cestou, která obejde validaci. Compiler-friendly invariant.
  3. Polymorfismus zdarma. Order::placeDigital() a Order::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:

php src/Ordering/Domain/Factory/OrderFromCartFactory.php
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):

php src/Ordering/Domain/Model/Order.php (fragment)
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\ na src/ 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 do App\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:

bash Adresářová struktura podle Modules vzoru
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:

json composer.json (fragment) – jen pro moduly mimo src/
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:

yaml config/services.yaml (fragment)
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:

  1. App\Ordering nesmí záviset na App\Billing, App\Inventory ani App\Shipping – integrace mezi BC probíhá výhradně přes domain events (Outbox).
  2. App\Ordering\Domain nesmí importovat nic z Doctrine, Symfony ani z vlastní Application a Infrastructure vrstvy – doména zůstává framework-agnostic.
  3. App\Ordering\Application nesmí znát App\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:

bash Instalace a spuštění
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, not umožňují skládání bez vnořených if-ů, 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\ na src/. 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ů.