Kapitola 08 · Vzory · Méně známé taktické vzory: Specifications, Domain Services, Factories, Modules

Méně známé 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 ovšem v částech II a III své knihy z roku 2003 věnuje hloubkovou pozornost ještě čtyřem dalším vzorům – a právě ty bývají v praktických průvodcích přehlíženy nebo zaměňovány s něčím jiným. Cílem této kapitoly je vrátit jim plný význam: kdy jsou skutečně užitečné, jak je zapsat v moderním PHP 8.4 a jaká rizika přinášejí, pokud je použijeme špatně.

Konkrétně si vysvětlíme: Specification Pattern jako kompozici doménových booleovských predikátů (Evans 2003, kap. 9), Domain Services jako stateless objekty pro logiku, jež nemá přirozeného vlastníka mezi Entitami a Value Objekty (Evans 2003, kap. 5), Factories pro zapouzdření vzniku agregátu se složitými invarianty (Evans 2003, kap. 6; Vernon 2013, kap. 11) a Modules jako prostředek vědomé organizace kódu podle ubiquitous language (Evans 2003, kap. 5).

08.01 Proč tyto vzory přehlížíme

Většina online průvodců o DDD končí někde u Aggregate. Programátor, 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“ – vždyť přece to, co dělají, lze napsat i jinak: if-em, statickou metodou nebo prostým balíčkem v src/. Tato intuice je ale klamná a kapitola ukáže, proč.

V malých projektech opravdu bez těchto vzorů přežijete. Jenže právě tam, kde je doména netriviální – tedy přesně v situacích, pro které byl DDD vytvořen – chybějící vzory způsobují bobtnání agregátů, anémii doménového modelu, duplikaci pravidel mezi vrstvami a nečitelnou organizaci kódu, která zatemňuje doménovou strukturu projektu. Evans těmto vzorům věnuje desítky stran z dobrého důvodu: jsou součástí ucelené sady, která drží pohromadě.

Začneme vzorem, který bývá v komunitě nejčastěji přehlížen, přestože Evans mu věnoval celou samostatnou kapitolu – 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
interface Specification
{
    public function isSatisfiedBy(mixed $candidate): bool;
}

Zdánlivě banální. Jenže právě tato banalita je síla: každé pravidlo doménového jazyka – „zákazník je premium“, „objednávka má nárok na dopravu zdarma“, „faktura je po splatnosti“ – dostane vlastní třídu s mluvícím jménem. Pravidlo přestává být pouhou kombinací if-ů uvnitř service vrstvy a stává se jmenovaným prvkem ubiquitous language.

Eric Evans vzor poprvé formálně popsal v Domain-Driven Design (2003), kapitole 9 s názvem Making Implicit Concepts Explicit. Evans a Fowler ho dříve rozpracovali v pracovním papíru Specifications [martinfowler.com]. Společný motiv: pravidla, která se v doméně objevují opakovaně, si zaslouží vlastní jméno a vlastní typ.

Kdy použít

  1. Komplexní doménová pravidla, která se mají skládat. Pokud máte v různých částech aplikace rekombinace téhož motivu – někde „premium AND v EU“, jinde „premium OR má slevový kód“ – kompozice pomocí Specification vám ušetří duplikaci a udrží pravidla konzistentní.
  2. Pravidla použitelná jak v doméně, tak v repozitáři. Jedna a tatáž specifikace musí umět odpovědět na otázku „splňuje tento konkrétní objekt pravidlo?“ (in-memory predikát) i „vrať mi z databáze všechny objekty, které pravidlo splňují?“ (query). Tomuto se říká double-dispatch a vyhnete se tím duplikaci pravidla mezi PHP kódem a SQL/Doctrine DQL.
  3. Pravidla, která se skládají za běhu. Promo kód, který má v admin UI podmínky „platí pro nákupy > 1000 Kč v ČR a SK, kromě výprodejového zboží“, se v doméně reprezentuje jako instance AndSpecification složená z N pod-pravidel čitelných z databáze.
  4. Pravidla 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.

Kdy NE

Specification je vzor s nezanedbatelnou cenou: každé pravidlo = nová třída, nový soubor, nový test. Nepoužívejte ho pro:

  • Triviální podmínky, které se vyskytují jednou a obsahují jeden if: if ($order->total->amount > 1000) 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é flagy – Specification má reprezentovat doménové pravidlo, ne podmínku „má feature flag enabled“.

Skladba pomocí kombinátorů

Vzor je silný tím, ž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
Abstraktní CompositeSpecification poskytuje implementaci kombinátorů, takže každá konkrétní doménová specifikace dědí and(), or() a not() zdarma.

Interface a abstraktní kompozit

Začneme rozhraním, které vystaví všechny tři kombinátory, a abstraktní třídou, která je implementuje pomocí AndSpecification, OrSpecification, NotSpecification:

php src/SharedKernel/Domain/Specification/Specification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

/**
 * Doménová specifikace – prvotřídní objekt zapouzdřující booleovský predikát.
 *
 * @template T
 */
interface Specification
{
    /** @param T $candidate */
    public function isSatisfiedBy(mixed $candidate): bool;

    /**
     * @param Specification<T> $other
     * @return Specification<T>
     */
    public function and(self $other): self;

    /**
     * @param Specification<T> $other
     * @return Specification<T>
     */
    public function or(self $other): self;

    /** @return Specification<T> */
    public function not(): self;
}

Aby každá konkrétní specifikace nemusela kombinátory implementovat sama, abstraktní třída je dodá zdarma:

php src/SharedKernel/Domain/Specification/CompositeSpecification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

/**
 * @template T
 * @implements Specification<T>
 */
abstract class CompositeSpecification implements Specification
{
    /** @param T $candidate */
    abstract public function isSatisfiedBy(mixed $candidate): bool;

    public function and(Specification $other): Specification
    {
        return new AndSpecification($this, $other);
    }

    public function or(Specification $other): Specification
    {
        return new OrSpecification($this, $other);
    }

    public function not(): Specification
    {
        return new NotSpecification($this);
    }
}
php src/SharedKernel/Domain/Specification/AndSpecification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

/**
 * @template T
 * @extends CompositeSpecification<T>
 */
final class AndSpecification extends CompositeSpecification
{
    /**
     * @param Specification<T> $left
     * @param Specification<T> $right
     */
    public function __construct(
        private readonly Specification $left,
        private readonly Specification $right,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            && $this->right->isSatisfiedBy($candidate);
    }
}
php src/SharedKernel/Domain/Specification/OrSpecification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

/**
 * @template T
 * @extends CompositeSpecification<T>
 */
final class OrSpecification extends CompositeSpecification
{
    /**
     * @param Specification<T> $left
     * @param Specification<T> $right
     */
    public function __construct(
        private readonly Specification $left,
        private readonly Specification $right,
    ) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return $this->left->isSatisfiedBy($candidate)
            || $this->right->isSatisfiedBy($candidate);
    }
}
php src/SharedKernel/Domain/Specification/NotSpecification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

/**
 * @template T
 * @extends CompositeSpecification<T>
 */
final class NotSpecification extends CompositeSpecification
{
    /** @param Specification<T> $inner */
    public function __construct(private readonly Specification $inner) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        return !$this->inner->isSatisfiedBy($candidate);
    }
}

Doménová specifikace

S kostrou hotovou ukážeme konkrétní doménové specifikace. Jedná se o skutečná doménová pravidla z Ordering kontextu – všimněte si, že každá z nich nese mluvící doménové jméno a dědí kombinátory and/or/not automaticky:

php src/Ordering/Domain/Specification/EligibleForFreeShipping.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Specification;

use App\Ordering\Domain\Order;
use App\SharedKernel\Domain\Money;
use App\SharedKernel\Domain\Specification\CompositeSpecification;

/**
 * Objednávka má nárok na dopravu zdarma, pokud její celková hodnota
 * dosahuje nebo přesahuje stanovený limit.
 *
 * @extends CompositeSpecification<Order>
 */
final class EligibleForFreeShipping extends CompositeSpecification
{
    public function __construct(private readonly Money $threshold) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        assert($candidate instanceof Order);

        return $candidate->total()->isGreaterThanOrEqual($this->threshold);
    }
}
php src/Ordering/Domain/Specification/InEUCountry.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Specification;

use App\Ordering\Domain\Order;
use App\SharedKernel\Domain\Country;
use App\SharedKernel\Domain\Specification\CompositeSpecification;

/**
 * Doručovací adresa objednávky se nachází v zemi EU.
 *
 * @extends CompositeSpecification<Order>
 */
final class InEUCountry extends CompositeSpecification
{
    public function __construct(private readonly Country $country) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        assert($candidate instanceof Order);

        return $this->country->isInEU()
            && $candidate->shippingAddress()->country()->equals($this->country);
    }
}
php src/Ordering/Domain/Specification/NotInBlacklist.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Specification;

use App\Ordering\Domain\Order;
use App\SharedKernel\Domain\CustomerId;
use App\SharedKernel\Domain\Specification\CompositeSpecification;

/**
 * Zákazník není uveden na doménovém blacklistu (např. fraud detection).
 *
 * @extends CompositeSpecification<Order>
 */
final class NotInBlacklist extends CompositeSpecification
{
    /** @param list<CustomerId> $blacklist */
    public function __construct(private readonly array $blacklist) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        assert($candidate instanceof Order);

        foreach ($this->blacklist as $blocked) {
            if ($blocked->equals($candidate->customerId())) {
                return false;
            }
        }

        return true;
    }
}

Kompozice v aplikační vrstvě

Tady se ukazuje skutečná hodnota vzoru. Komplexní marketingová akce „doprava zdarma pro nákupy nad 1000 Kč v EU, kromě zákazníků na blacklistu“ je pouze trojice atomických specifikací spojená kombinátorem and – jedna čitelná řádka místo trojnásobně vnořeného if-u:

php src/Ordering/Application/CommandHandler/ApplyFreeShippingHandler.php
<?php

declare(strict_types=1);

namespace App\Ordering\Application\CommandHandler;

use App\Ordering\Domain\Order;
use App\Ordering\Domain\Specification\EligibleForFreeShipping;
use App\Ordering\Domain\Specification\InEUCountry;
use App\Ordering\Domain\Specification\NotInBlacklist;
use App\SharedKernel\Domain\Country;
use App\SharedKernel\Domain\Money;

final class ApplyFreeShippingHandler
{
    public function __construct(private readonly BlacklistRegistry $blacklist) {}

    public function __invoke(Order $order): void
    {
        $promo = (new EligibleForFreeShipping(Money::czk(100_000))) // 1000 Kč v haléřích
            ->and(new InEUCountry($order->shippingAddress()->country()))
            ->and(new NotInBlacklist($this->blacklist->all()));

        if ($promo->isSatisfiedBy($order)) {
            $order->markEligibleForFreeShipping();
        }
    }
}

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.

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 DQL/SQL kritérium a vrátí výsledek. Tomuto přístupu se říká double-dispatch: specifikace nese pravidlo, repozitář ví, jak ho přeložit do persistence.

php src/SharedKernel/Domain/Specification/QuerySpecification.php
<?php

declare(strict_types=1);

namespace App\SharedKernel\Domain\Specification;

use Doctrine\ORM\QueryBuilder;

/**
 * Specifikace, která ví, jak se převést na Doctrine kritérium.
 * Implementuje double-dispatch: specifikace zná své pravidlo,
 * repozitář ví, jak ho aplikovat na QueryBuilder.
 *
 * @template T
 * @extends Specification<T>
 */
interface QuerySpecification extends Specification
{
    public function asDoctrineCriteria(QueryBuilder $qb, string $alias): void;
}
php src/Ordering/Domain/Specification/EligibleForFreeShipping.php (rozšířená verze)
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Specification;

use App\Ordering\Domain\Order;
use App\SharedKernel\Domain\Money;
use App\SharedKernel\Domain\Specification\CompositeSpecification;
use App\SharedKernel\Domain\Specification\QuerySpecification;
use Doctrine\ORM\QueryBuilder;

/**
 * @extends CompositeSpecification<Order>
 * @implements QuerySpecification<Order>
 */
final class EligibleForFreeShipping extends CompositeSpecification implements QuerySpecification
{
    public function __construct(private readonly Money $threshold) {}

    public function isSatisfiedBy(mixed $candidate): bool
    {
        assert($candidate instanceof Order);

        return $candidate->total()->isGreaterThanOrEqual($this->threshold);
    }

    public function asDoctrineCriteria(QueryBuilder $qb, string $alias): void
    {
        $qb->andWhere(sprintf('%s.totalAmount >= :threshold', $alias))
           ->setParameter('threshold', $this->threshold->amount());
    }
}

Repozitář pak vystaví obecnou metodu match(), která přijme jakoukoliv QuerySpecification a přeloží ji na DQL:

php src/Ordering/Infrastructure/Doctrine/DoctrineOrderRepository.php
<?php

declare(strict_types=1);

namespace App\Ordering\Infrastructure\Doctrine;

use App\Ordering\Domain\Order;
use App\Ordering\Domain\OrderRepository;
use App\SharedKernel\Domain\Specification\QuerySpecification;
use Doctrine\ORM\EntityManagerInterface;

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(private readonly EntityManagerInterface $em) {}

    /**
     * @param QuerySpecification<Order> $spec
     * @return list<Order>
     */
    public function match(QuerySpecification $spec): array
    {
        $qb = $this->em->createQueryBuilder()
            ->select('o')
            ->from(Order::class, 'o');

        $spec->asDoctrineCriteria($qb, 'o');

        return $qb->getQuery()->getResult();
    }
}

Tímto způsobem vyřešíte klasický problém „jak se má pravidlo aplikovat jen jednou – v PHP nebo v SQL?“. Specifikace je zdroj pravdy, obě její role (in-memory predikát i query překladač) sedí v jedné třídě a nelze je oddělit.

Pro hluboký teoretický základ vzoru: Evans, E., Domain-Driven Design (2003), kapitola 9 Making Implicit Concepts Explicit; Fowler, M., Patterns of Enterprise Application Architecture (2003), heslo Specification. Praktická aplikace na agregátech: Vernon, V., Implementing Domain-Driven Design (2013).

08.03 Domain Services

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) operuje nad více doménovými objekty a (3) nemá vlastní stav.

Jinými slovy: pokud existuje doménová operace X, kterou musíte umět provést, ale když si na chvíli položíte otázku „které z mých Entit/Value Objektů tato operace přirozeně patří?“, žádná odpověď není uspokojivá – protože by to znamenalo, že daná Entita musí znát příliš mnoho o druhé – pak je čas vytvořit Domain Service.

Kdy použít

Klasické příklady, na kterých Evans i Vernon vzor demonstrují:

  • Funds Transfer – převod peněz mezi dvěma účty. Patří do agregátu Account? Žádný z obou účtů ten druhý nezná, sám sebe by neměl převádět. Operace je sama o sobě doménový koncept.
  • Pricing engine – výpočet ceny objednávky. Závisí na pricing pravidlech, segmentu zákazníka, košíku, kupónu. Žádný z těchto entit není přirozeným vlastníkem výpočtu.
  • Credit scoring – výpočet skóre žadatele o úvěr. Hledá se odpověď na „má tento zákazník nárok na úvěr X?“ kombinací několika faktorů.
  • Coordinator 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 zároveň nejvíce zneužívaný taktický vzor v DDD. Programátoři navyklí na klasickou layered architecture mají sklon vytvořit 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 ve „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
<?php

declare(strict_types=1);

namespace App\Banking\Domain\Service;

use App\Banking\Domain\Account;
use App\Banking\Domain\Exception\InsufficientFunds;
use App\Banking\Domain\TransferReference;
use App\SharedKernel\Domain\Money;

/**
 * Domain Service – převod peněz mezi dvěma účty.
 *
 * Operace nepatří do žádného z účtů, protože jeden z nich nesmí znát
 * druhý: agregáty jsou autonomní. Jde o doménovou logiku (validace
 * dostupnosti prostředků, kontrola limitu), nikoliv o aplikační koordinaci.
 *
 * Stateless – bez instance variables, bez side-effectů na kolaborátorech.
 */
final class MoneyTransferService
{
    public function transfer(
        Account $from,
        Account $to,
        Money $amount,
        TransferReference $reference,
        \DateTimeImmutable $when,
    ): void {
        if (!$from->canWithdraw($amount, $when)) {
            throw InsufficientFunds::onAccount($from->id(), $amount);
        }

        if (!$from->currency()->equals($to->currency())) {
            throw new \DomainException(
                'Currency mismatch – use FxTransferService for cross-currency transfers.',
            );
        }

        $from->withdraw($amount, $reference, $when);
        $to->deposit($amount, $reference, $when);
    }
}

Všimněte si tří rysů, podle kterých poznáte „opravdovou“ Domain Service:

  1. Žádný stav – třída nemá konstruktorové závislosti na repozitářích ani EntityManager. Pracuje pouze 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.

Domain Service vs. Application Service vs. Infrastructure Service

V kódu se nějaká „Service“ třída vyskytne téměř vždy. Otázkou je, kterou ze tří odlišných rolí daná Service 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 Pouze doménové typy (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

Tabulka ukazuje, proč je problematické pojmenovat vše na *Service – a proč se v moderním DDD upouští od sufixu Service u Application vrstvy ve prospěch 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 dokonce i u Domain Service zvolíme přímo doménové jméno (FundsTransfer, PricingEngine) bez sufixu.

Tématicky 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

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) píše: „Vytváření složených objektů by mělo být odděleno od jejich provozu, tím spíše, když jejich vznik vyžaduje pravidla nebo polymorfismus.“

Standardní konstruktor je úplně dostatečný 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 nekonstantní, že by zaplevelilo konstruktor doménového objektu detaily transportní vrstvy.

Kdy NE

Většinu objektů můžete přímočaře vytvořit konstruktorem. Factory přidávejte teprve 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 moderním PHP 8.4 je nejčistší forma Factory statická pojmenovaná konstrukční metoda na samotném agregátu (named constructor). Konstruktor je privátní, publikujete pouze pojmenované entry pointy s jasnou doménovou sémantikou:

php src/Ordering/Domain/Order.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain;

use App\Ordering\Domain\Event\OrderPlaced;
use App\Ordering\Domain\Exception\EmptyOrder;
use App\SharedKernel\Domain\AggregateRoot;
use App\SharedKernel\Domain\CustomerId;

final class Order extends AggregateRoot
{
    /** @var list<OrderItem> */
    private array $items;

    /** @param list<OrderItem> $items */
    private function __construct(
        private readonly OrderId $id,
        private readonly CustomerId $customerId,
        array $items,
        private readonly OrderType $type,
        private readonly \DateTimeImmutable $placedAt,
    ) {
        $this->items = $items;
        $this->recordEvent(new OrderPlaced($id, $customerId, $placedAt));
    }

    /**
     * Standardní vznik objednávky se zbožím.
     *
     * @param list<OrderItem> $items
     */
    public static function place(
        CustomerId $customerId,
        array $items,
        \DateTimeImmutable $placedAt,
    ): self {
        if (count($items) === 0) {
            throw EmptyOrder::cannotBePlaced();
        }

        return new self(
            id: OrderId::generate(),
            customerId: $customerId,
            items: $items,
            type: OrderType::Physical,
            placedAt: $placedAt,
        );
    }

    /**
     * Polymorfní vznik – pouze digitální obsah, jiná pravidla
     * (žádná dopravní adresa, instantní doručení).
     *
     * @param list<DigitalItem> $items
     */
    public static function placeDigital(
        CustomerId $customerId,
        array $items,
        \DateTimeImmutable $placedAt,
    ): self {
        if (count($items) === 0) {
            throw EmptyOrder::cannotBePlaced();
        }

        return new self(
            id: OrderId::generate(),
            customerId: $customerId,
            items: array_map(static fn (DigitalItem $i): OrderItem => $i->toOrderItem(), $items),
            type: OrderType::Digital,
            placedAt: $placedAt,
        );
    }

    /**
     * Vznik z importu – odlišná validace, neidentifikuje zákazníka přes CustomerId,
     * ale přes externí key, který se uvnitř naváže na guest CustomerId.
     */
    public static function fromImport(
        ImportedOrderRow $row,
        CustomerLookup $lookup,
        \DateTimeImmutable $placedAt,
    ): self {
        $customerId = $lookup->byEmail($row->customerEmail) ?? CustomerId::guest();
        $items = ImportedItems::map($row->items);

        return self::place($customerId, $items, $placedAt);
    }
}

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). Statickou metodou nelze přijímat DI bez service locatoru, takže se přechází na samostatnou Factory class:

php src/Ordering/Domain/Factory/OrderFromCartFactory.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Factory;

use App\Ordering\Domain\Cart\CartId;
use App\Ordering\Domain\Cart\CartRepository;
use App\Ordering\Domain\Order;
use App\Ordering\Domain\Pricing\PricingService;
use App\SharedKernel\Domain\CustomerId;
use Psr\Clock\ClockInterface;

/**
 * Factory class – vznik objednávky z košíku vyžaduje
 * načtení košíku a aplikaci aktuálního pricingu.
 * Static method by tyto závislosti nemohla převzít.
 */
final class OrderFromCartFactory
{
    public function __construct(
        private readonly CartRepository $carts,
        private readonly PricingService $pricing,
        private readonly ClockInterface $clock,
    ) {}

    public function fromCart(CartId $cartId, CustomerId $customer): Order
    {
        $cart = $this->carts->getById($cartId);

        if ($cart->isEmpty()) {
            throw new \DomainException('Cannot place order from empty cart.');
        }

        $pricedItems = $this->pricing->priceItems($cart->items(), $customer);

        return Order::place(
            customerId: $customer,
            items: $pricedItems,
            placedAt: $this->clock->now(),
        );
    }
}

Všimněte si, že Factory class uvnitř volá Order::place() – 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.

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ý agregát už invariant prošel kdysi v minulosti):

php src/Ordering/Domain/Order.php (fragment)
/**
 * Rekonstituce ze stavu načteného z DB / event streamu.
 * Tento pojmenovaný konstruktor neaplikuje invarianty –
 * rekonstruovaný stav je z definice valid, jinak by se nedostal do persistence.
 *
 * @internal Smí volat pouze infrastruktura repozitáře.
 *
 * @param list<OrderItem> $items
 */
public static function reconstitute(
    OrderId $id,
    CustomerId $customerId,
    array $items,
    OrderType $type,
    \DateTimeImmutable $placedAt,
): self {
    return new self($id, $customerId, $items, $type, $placedAt);
}

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. 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 celou samostatnou pasáž v kapitole 5 Domain-Driven Design (2003). Citace: „Modules in DDD are a way of expressing the higher-level structure of a model... Modules should reflect the domain language, not the technical organization of code.“

V Symfony 8 a moderním PHP 8.4 to konkrétně znamená:

  • PSR-4 namespace + folder layout uspořádané podle Bounded Contextů.
  • composer.json autoload sekce, která mapuje namespace App\Ordering\ na src/Ordering/ – ne na src/ jako v default Symfony skeletonu.
  • Architecture testing, který zkontroluje, že žádný kód v App\Billing\ přímo nedotahuje do App\Ordering\.

Modul jako Bounded Context

Nejmocnější aplikace Modules vzoru je 1 modul = 1 Bounded Context. Projekt strukturovaný tímto způsobem vypadá takto:

bash Adresářová struktura podle Modules vzoru
src/
  Ordering/                                  ← MODULE = Bounded Context
    Domain/
      Order.php                              ← Aggregate Root
      OrderRepository.php                    ← Interface
      OrderItem.php
      Specification/
        EligibleForFreeShipping.php
        InEUCountry.php
      Service/
        PricingService.php                   ← Domain Service
      Factory/
        OrderFromCartFactory.php
      Event/
        OrderPlaced.php
      Exception/
        EmptyOrder.php
    Application/
      Command/
        PlaceOrderCommand.php
      CommandHandler/
        PlaceOrderHandler.php
      Query/
        ListOrdersQuery.php
      QueryHandler/
        ListOrdersHandler.php
    Infrastructure/
      Doctrine/
        DoctrineOrderRepository.php
        OrderMapping.orm.xml
      Http/
        OrderController.php
      Messenger/
        OrderPlacedSubscriber.php
  Billing/                                   ← Jiný BC = jiný modul
    Domain/
      Invoice.php
      ...
    Application/
      ...
    Infrastructure/
      ...
  SharedKernel/                              ← Sdílený jazyk a typy
    Domain/
      Money.php
      Currency.php
      Country.php
      AggregateRoot.php
      Specification/
        Specification.php
        CompositeSpecification.php
        AndSpecification.php
        OrSpecification.php
        NotSpecification.php
        QuerySpecification.php

Této organizaci se v komunitě říká také vertical slicing – viz kapitolu Horizontální vs. vertikální dělení, 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

composer.json autoload

Aby PSR-4 namespace odpovídala adresářové struktuře, je nutné upravit composer.json. Default Symfony nastavení mapuje App\ na src/, ale my chceme každý modul s vlastním kořenem:

json composer.json (fragment)
{
    "name": "your-org/your-app",
    "type": "project",
    "require": {
        "php": ">=8.4",
        "symfony/framework-bundle": "^8.0",
        "doctrine/orm": "^3.0"
    },
    "autoload": {
        "psr-4": {
            "App\\Ordering\\":     "src/Ordering/",
            "App\\Billing\\":      "src/Billing/",
            "App\\Inventory\\":    "src/Inventory/",
            "App\\Shipping\\":     "src/Shipping/",
            "App\\SharedKernel\\": "src/SharedKernel/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\Ordering\\":  "tests/Ordering/",
            "App\\Tests\\Billing\\":   "tests/Billing/"
        }
    }
}

Po úpravě je nutné spustit composer dump-autoload. Symfony skeleton očekává controllery v App\Controller\; pro Modules layout musíte buď přesunout controllery do App\Ordering\Infrastructure\Http\ a upravit config/services.yaml:

yaml config/services.yaml (fragment)
services:
    _defaults:
        autowire: true
        autoconfigure: true

    # Auto-registrace všech služeb v každém modulu, v jejich Infrastructure
    # a Application vrstvách. Doménová vrstva je zcela bez auto-konfigurace
    # – doménové objekty si žijí vlastním životem mimo container.
    App\Ordering\Application\:
        resource: '../src/Ordering/Application/'
    App\Ordering\Infrastructure\:
        resource: '../src/Ordering/Infrastructure/'
    App\Billing\Application\:
        resource: '../src/Billing/Application/'
    App\Billing\Infrastructure\:
        resource: '../src/Billing/Infrastructure/'
    # ...

    # Controllery z modulů – Symfony je standardně hledá v App\Controller\
    App\Ordering\Infrastructure\Http\:
        resource: '../src/Ordering/Infrastructure/Http/'
        tags: ['controller.service_arguments']

Architecture testing s phparkitect

Konvence sama o sobě nestačí – vývojáři pod tlakem rychle zapomenou, že App\Billing\ nesmí volat App\Ordering\. Řešení: vynutit pravidlo testem. Pro PHP existuje knihovna phparkitect, která spouští architektonické asercie v CI:

bash Instalace
composer require --dev phparkitect/phparkitect
php phparkitect.php
<?php

declare(strict_types=1);

use Arkitect\ClassSet;
use Arkitect\CLI\Config;
use Arkitect\Expression\ForClasses\NotDependsOnAnyOfTheseNamespaces;
use Arkitect\Expression\ForClasses\NotDependsOnTheseNamespaces;
use Arkitect\Expression\ForClasses\ResideInOneOfTheseNamespaces;
use Arkitect\Rules\Rule;

return static function (Config $config): void {
    $classSet = ClassSet::fromDir(__DIR__ . '/src');

    // Pravidlo 1: Ordering BC nesmí přímo závisět na Billing BC.
    // Integrace musí probíhat přes events (publish/subscribe),
    // nikdy přímým voláním třídy z druhého modulu.
    $orderingIsolated = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\\Ordering'))
        ->should(new NotDependsOnAnyOfTheseNamespaces([
            'App\\Billing',
            'App\\Inventory',
            'App\\Shipping',
        ]))
        ->because(
            'Ordering BC je autonomní – integrace s ostatními BC '
          . 'probíhá výhradně přes domain events (Outbox).',
        );

    // Pravidlo 2: Doménová vrstva nesmí znát infrastrukturu.
    // Žádný import z Doctrine, Symfony HTTP, Mailer, Messenger atd.
    $domainPure = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\\Ordering\\Domain'))
        ->should(new NotDependsOnAnyOfTheseNamespaces([
            'Doctrine',
            'Symfony',
            'App\\Ordering\\Infrastructure',
            'App\\Ordering\\Application',
        ]))
        ->because(
            'Domain layer musí být framework-agnostic; '
          . 'porty se definují jako interface a implementují v Infrastructure.',
        );

    // Pravidlo 3: Application vrstva nesmí znát Infrastructure detaily.
    $applicationCleanArch = Rule::allClasses()
        ->that(new ResideInOneOfTheseNamespaces('App\\Ordering\\Application'))
        ->should(new NotDependsOnTheseNamespaces([
            'App\\Ordering\\Infrastructure',
            'Doctrine\\ORM',
        ]))
        ->because(
            'Application orchestrace závisí na port (interface) z Domain, '
          . 'ne na adapteru z Infrastructure.',
        );

    $config
        ->add($classSet, $orderingIsolated, $domainPure, $applicationCleanArch);
};
bash CI run
# CI runner spustí pravidla a selže build, pokud došlo k porušení.
vendor/bin/phparkitect check --config=phparkitect.php

# Doporučujeme zařadit do CI workflow před fázi "tests":
#   - composer install
#   - vendor/bin/phparkitect check
#   - vendor/bin/phpstan analyse
#   - vendor/bin/phpunit

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, phparkitect.com.

08.06 Vztah těchto vzorů ke zbytku DDD

Čtyři vzory této kapitoly nejsou izolované; vzájemně se podporují a skládají s ostatními taktickými vzory do soudržného celku. Mind-mapa vztahů:

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) Žije uvnitř BC; obvykle se nesdílí mezi BC
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)

Hlavní vztah: Agregát uvnitř používá Specifications pro invarianty, vzniká přes Factory (named constructor), spolupracuje s 2+ jinými agregáty přes Domain Service, a celá ta skupina žije v jednom Module, který odpovídá Bounded Contextu. To je vzájemně provázaný design, který nelze správně používat po jednom – proto Evans věnuje všem čtyřem vzorům samostatné kapitoly.

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 Použijte double-dispatch (QuerySpecification); jedno pravidlo, dva výklady
„*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 za 6 měsíců 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í

Čtyři vzory této kapitoly – Specifications, Domain Services, Factories, Modules – bývají v praktických průvodcích přehlíženy. Evans jim ovšem věnuje desítky stran z dobrého důvodu: jsou součástí ucelené sady taktického DDD a bez nich agregáty bobtnají, doménový model anemizuje 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 eliminuje duplikaci pravidla mezi PHP a Doctrine.
  • 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 se realizují PSR-4 namespace + composer.json mapováním na adresáře. Vynucení hranic patří do CI přes phparkitect/deptrac.

Tyto čtyři vzory dohromady udrží agregát štíhlý, doménu výraznou a projekt čitelný i po roce vývoje. Neimplementují se najednou – nasazení je iterativní. 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 nasazujte tehdy, když vidíte druhou nebo třetí kombinaci téhož pravidla.

V další kapitole se podíváme na výkonové aspekty DDD: jak se agregáty chovají při tisících transakcí za sekundu, kde má DDD overhead a jak ho minimalizovat. 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 potřebujete 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, žádný interní cache, žádný čítač. Pokud by Domain Service držela stav, ztratí se idempotence a souběžnost. Jediné, co Domain Service smí mít v konstruktoru, jsou jiné stateless služby (typicky další Domain Service nebo immutable hodnota). Vše ostatní (repozitáře, ClockInterface, Mailer) ji posouvá do Application nebo Infrastructure vrstvy. Detail v sekci MoneyTransferService a srovnávací tabulce.

Factory metoda nebo Factory class – jak se rozhodnout?

Defaultně volte named constructor (statická metoda na agregátu). Vernon (2013) ho výslovně preferuje. 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 výrazná záruka, ž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ů. Cross-link 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, kde jedno z rozhodnutí je vyjádřeno jako 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ů.