Kapitola 20 · Praxe · DDD v praxi – kde to bolí

DDD v praxi – kde to bolí

Dvacet míst, kde DDD v PHP a Symfony bolí: transakce přes více agregátů, Doctrine mapping, Outbox, ladění Messengeru, validace, Anti-Corruption Layer, přesvědčování managementu a další. U každého problém a řešení.

Autor M. Katuščák
Doba čtení ≈ 28 min
Náročnost náročná
Publikováno · Aktualizováno ·
Obsah kapitoly

Předchozí kapitoly pokryly teorii i pokročilé vzory: od základních stavebních bloků přes CQRS a Event Sourcing až po Ságy a Process Managery. Implementace DDD naráží na problémy, které učebnice zpravidla nechávají stranou: architektonické principy se střetávají s realitou frameworku, databáze, asynchronní infrastruktury i týmové dynamiky.

Následuje katalog 20 problémů, na které narážejí týmy zavádějící DDD v PHP a Symfony. Většinou jde o třenice s konkrétní technologií: Doctrine Unit of Work, Symfony Messenger, Outbox pattern, autorizace, race conditions. U většiny problémů najdete popis situace, příčinu a doporučené řešení, kde to pomůže, i s ukázkou kódu.

Kódové a modelovací anti-vzory (anémický model, Primitive Obsession, God Aggregate, sdílená databáze mezi kontexty) rozebírá kapitola Anti-vzory a typické chyby, rozhodovací rámec, jestli DDD vůbec použít, kapitola Kdy DDD nepoužívat.

20.01 A – Doctrine vs. doménový model#

Doctrine ORM má vlastní interní model: Unit of Work, Identity Map, lazy loading. Doménový model s neměnnými hodnotovými objekty, privátními konstruktory a invarianty na něj naráží na šesti místech.

A1. Transakce přes agregáty a Doctrine Unit of Work

Problém: Vernonovo vodítko říká, že jedna transakce mění nejvýše jeden agregát. Praxe ale přináší situace, kdy je potřeba atomicky uložit změny dvou agregátů. Například uzavřít skladový převod (stav Transferred) a zároveň potvrdit rezervaci zboží, kterou převod naplňuje. Doctrine sdílí jeden EntityManager (a tím jeden Unit of Work) v celé aplikaci; jeden flush() zapíše vše, co EM sleduje.

Příčina: Doctrine Unit of Work žije po celý request (nebo zpracování jedné zprávy). Drží identity map všech načtených entit a při flush() uloží všechny změny najednou v jediné databázové transakci. Pro CRUD je to pohodlné. V DDD to znamená, že neúmyslně změněná entita z jiného agregátu se zapíše spolu s vaší záměrnou změnou.

Řešení: Potřeba změnit dva agregáty atomicky je nejdřív diagnóza. Obvykle ukazuje na špatně vedenou hranici agregátu. Náprava pak hranici posune, nebo druhou změnu provede ve vlastní transakci přes doménovou událost, případně ságu. Společný Bounded Context a společná databáze jsou jen technický předpoklad jedné transakce, ne její ospravedlnění. Obhajitelná je jen některá z výjimek, které rozebírá sekce Kdy se vodítko poruší v kapitole Návrh agregátu.

Když tým takovou výjimku vědomě přijme, funguje Application Service jako explicitní transakční hranice. Doctrine k tomu nabízí wrapInTransaction(), které dokumentace doporučuje před ručním beginTransaction() / commit() právě proto, aby vývojář nezapomněl na rollback.

A2. „Špinavý“ EntityManager a nechtěné změny

Problém: V akcích, které převážně čtou (příprava dat pro odpověď API, sestavení read modelu), načtete entitu z databáze, spočítáte nad ní hodnotu, ale neuložíte nic. Přesto první flush() kdekoli v requestu (třeba v jiné části aplikace) zapíše změny do databáze. Entitu, kterou Doctrine stále sleduje, jste totiž nenápadně změnili.

Příčina: Doctrine Identity Map si pamatuje každý načtený objekt a Unit of Work při flush() porovnává aktuální stav se snapshoty uloženými při načtení (change tracking). Getter, který interně mění stav (například uloží dopočítanou hodnotu do mapované vlastnosti), Doctrine vyhodnotí jako změnu.

Řešení: tři přístupy podle situace.

Situace Řešení
Read model v jednom requestu $em->detach($entity) po načtení – EM přestane entitu sledovat (dostupné v ORM 2.x i 3.x; pozn.: merge() bylo naopak v ORM 3.x odstraněno)
Komplexní read queries Použijte HYDRATE_ARRAY nebo raw SQL přes $em->getConnection() – EM nehydratuje objekty
Celý controller je read-only Dotazy s hintem Query::HINT_READ_ONLY – takto načtené entity UnitOfWork při flush() nekontroluje. Mapování #[ORM\Entity(readOnly: true)] dělá totéž, ale pro třídu v celé aplikaci: hodí se jen pro entitu, kterou nic nemění. Read-only EntityManager Doctrine nemá

ORM 3 přitom zrušil obvyklý únikový manévr. Argumenty flush($entity) a clear($entityName) jsou pryč a obě metody je tiše ignorují, protože přebytečný argument uživatelské metody PHP nehlásí. To je horší než chyba: clear('Order') vypadá jako cílené odpojení, ale odpojí celou Identity Map. „Uložím jen tenhle agregát“ vyjádřit nelze: flush() vždy zapíše celý Unit of Work, včetně každé nechtěně sledované entity.

A3. Mapping složitých Value Objects

Problém: Doctrine #[Embedded] funguje dobře pro jednoduché VO (jméno + příjmení → dva sloupce). Na limity narazí polymorfní VO (různé typy cen), nullable VO v kolekcích, VO s vlastní serializační logikou a VO mapované na jiný datový typ než výchozí (enum, JSONB, custom SQL type).

Řešení: Custom Doctrine Type. Odvoďte třídu od abstraktní Type z Doctrine\DBAL\Types; typ definuje, jak se PHP objekt převede na SQL hodnotu a zpět.

Typ zaregistrujte v config/packages/doctrine.yaml:

yaml config/packages/doctrine.yaml (výřez: custom typ)
1doctrine:2    dbal:3        types:4            money: App\SharedKernel\Infrastructure\Doctrine\Type\MoneyType

Poté ho použijte v entitě:

php snippet.php
1#[ORM\Column(type: 'money', nullable: true)]2private ?Money $price = null;

A4. Lazy loading a doménové metody

Doctrine ve výchozím nastavení načítá asociace lazy: do property vloží proxy, která se inicializuje až při prvním přístupu. Doménová metoda jako totalAmount() nebo items() o tom nic neví a implicitně spoléhá na aktivní databázové připojení. Když záznam, na který proxy ukazuje, mezitím z databáze zmizel, inicializace selže s EntityNotFoundException. Odpojení entity samo chybu nevyvolá. Inicializátor proxy stav v UnitOfWork nekontroluje a data dočte přes persister, dokud je spojení otevřené. Platí to pro klasické proxy třídy i pro nativní lazy objekty PHP 8.4.

Nativním lazy objektům se přitom nevyhnete. Od ORM 3.5 je jejich vypnutí na PHP 8.4+ deprecated a ve verzi 4.0 zmizí úplně; Configuration::enableNativeLazyObjects(true) je cílový stav. Kdo dnes staví chování na detailech vygenerovaných proxy tříd, opírá se o odcházející implementaci.

Lazy proxy je infrastrukturní koncept. Doménový model o ní vědět nesmí, jenže ji v paměti nese. O způsobu načtení proto rozhoduje až repozitář nebo konkrétní query.

Řešení podle složitosti situace:

Situace Řešení
Kolekce potřebná jen někdy Repozitář nabídne dvě metody: get() (lazy) a getWithItems() s fetch joinem v DQL – ten JOIN skutečně vydá
Kolekce vždy potřebná s agregátem fetch: 'EAGER' na asociaci – u OneToMany druhý dotaz po dávkách (výchozí dávka 100 rodičů), ne JOIN. ManyToMany a OneToMany s indexBy dávky nedostanou a N+1 zůstává
Serializace / JSON response Nikdy neserializujte agregát přímo – sestavte DTO z načtených dat uvnitř transakce

Pořadí řádků v tabulce není náhodné. Fetch join v DQL je první volba, protože platí jen pro konkrétní dotaz. fetch: 'EAGER' v mapování působí globálně a u to-many asociací nedělá to, co většina lidí čeká. JOIN vydává jen u to-one asociací, a i tam si Doctrine vyhrazuje volbu mezi LEFT JOIN a druhým dotazem. Podrobněji k volbě strategie viz Read modely, projekce a výkon.

A5. Identity generation – kdy a kde

Problém: Doctrine standardně generuje ID v databázi (SEQUENCE, AUTO_INCREMENT). Nově vytvořený agregát nemá ID, dokud ho Doctrine nepersistuje (SEQUENCE), případně neflushne (AUTO_INCREMENT). Tím padá doménový invariant: každý agregát musí mít identitu od okamžiku vzniku.

Příčina: Databázové generování ID je pohodlné, ale váže vznik identity na infrastrukturu. Doménový model by neměl vědět o databázi; identita patří do domény.

Řešení: Identitu vyrobte v PHP kódu, ne v databázi, a to dřív, než se agregát uloží. Kanonická továrna má tvar Order::place(OrderId $id, CustomerId $customerId): identitu vyrobí volající přes OrderId::generate() a agregát ji jen přijme. Doctrine se nakonfiguruje bez generátoru, ID mu předáváte hotové. Tři kapitoly přidávají na téže třídě další továrnu, protože bez ní by nešlo ukázat jejich téma. Návrh agregátu má placeWithFirstItem(), kde invariant „objednávka má aspoň jednu položku“ vymáhá už signatura. Outbox Pattern má placeWithItems(), protože seznam položek potřebuje v payloadu události. Doplňující taktické vzory mají placePhysical() a placeDigital() se stejným principem, jen pod jmény, která říkají, jaký druh objednávky továrna vyrábí. Vždycky jde o jiné jméno, ne o jinou verzi place(). Dvě neslučitelné signatury jedné metody by v reálném projektu vedle sebe neobstály. Odvozené továrny identitu nepřijímají, ale vyrobí si ji samy přes OrderId::generate(). Pravidlo platí pro obě cesty: identita vzniká v PHP kódu před uložením, nikdy v databázi.

Konstruktor zůstává čistý kvůli rekonstituci. Doctrine ho při hydrataci obchází, vlastní mapper nebo event stream ho ale volat může. Událost proto vzniká v továrně, viz životní cyklus kořene agregátu.

Mapování identifikátoru v Doctrine. Ukázka mapuje primitivní string; hodnotový objekt OrderId se na sloupec převádí custom Doctrine typem, viz sekci A3:

php snippet.php
1#[ORM\Id]2#[ORM\Column(type: 'string', length: 36)]3// Bez #[ORM\GeneratedValue] Doctrine ID nepřiřazuje;4// strategy: 'NONE' je podle dokumentace totéž, jen upovídaněji5private string $id;

A6. Polymorfismus a discriminator map

Problém: Potřebujete modelovat hierarchii, například různé typy doručení (HomeDelivery, PickupPoint, LockerDelivery). Doctrine nabízí InheritanceType::SINGLE_TABLE nebo JOINED s discriminator map. Cena za to je konkrétní, ne principiální. Explicitní mapa se zapisuje na kořenovou entitu, takže nový subtyp znamená zásah do třídy, která o něm nemá důvod vědět. Bez mapy ji Doctrine vygeneruje sama z krátkých názvů tříd; přejmenování třídy pak změní očekávanou hodnotu ve sloupci diskriminátoru. U SINGLE_TABLE navíc každý sloupec specifický pro jednu variantu musí být nullable pro všechny ostatní, u JOINED platíte JOIN při každém čtení. Na jeho dopad na výkon dokumentace výslovně upozorňuje. A protože jde o schéma, každý přírůstek hierarchie znamená migraci databáze, ne jen novou třídu.

Řešení: dvě alternativy k výchozí discriminator map.

Přístup Kdy použít Nevýhoda
Value Object s typovým polem Varianty se liší hlavně daty, chování je málo Každé chování závislé na typu znamená switch nad enumem
Flat table + Custom Type Varianty mají odlišné chování Custom Type musí z JSON sestavit třídu správné varianty; JSON sloupec ztrácí typovou bezpečnost
Discriminator map (Doctrine default) Málo variant, stabilní hierarchie Migrace schématu při každé variantě, nullable sloupce

Ve většině DDD scénářů se osvědčuje Value Object s typovým polem: jeden enum sloupec pro typ, jeden JSON sloupec pro specifická data varianty. Logika se přesouvá do doménových metod, které přijímají VO jako parametr, ne do dědičnosti.

Ani tahle volba není zadarmo. Switch nad enumem nezmizí, jen se přestěhuje z dědičnosti do doménové metody. A co uložíte do JSON sloupce, tím přestanete filtrovat, indexovat a agregovat v SQL. Volba tedy zní: platit migrací schématu, nebo dotazovatelností.

20.02 B – Asynchronní infrastruktura#

Symfony Messenger a asynchronní fronty přinášejí distribuovanou komunikaci a s ní distribuované problémy: zprávy se ztrácejí, doručují dvakrát, přicházejí v nesprávném pořadí. Následují čtyři nejčastější bolesti.

B1. Outbox pattern – zaručené doručení integračních událostí

Problém: Handler uloží agregát a v téže metodě odešle integrační událost do brokeru. Pod kanonickým middlewarem doctrine_transaction je flush() jen zápis SQL, commit přijde až po návratu handleru. Událost tedy odejde dřív, než je jisté, že změna v databázi zůstane. Když commit selže, dostanou subscribeři phantom event: Payment strhne peníze za objednávku, která neexistuje. Bez middlewaru hrozí opak. flush() commitne sám, server spadne před odesláním a událost se ztratí. Platba proběhla a sklad se o ní nedozví.

Příčina: Commit v databázi a $bus->dispatch() do brokeru jsou dvě samostatné operace bez atomické záruky. Do jedné transakce je zabalit nelze, protože databáze a message broker jsou různé systémy. Oba scénáře podrobně rozebírá naivní implementace publikování.

Řešení: událost uložit do outbox tabulky ve stejné transakci jako agregát a odeslání nechat na odděleném procesu. Atomicitu pak drží databázová transakce. Doménové události pro posluchače uvnitř kontextu outbox nepotřebují: synchronní event.bus je doručí v téže transakci.

Schéma tabulky, dvě varianty relay procesu, idempotentní inbox na straně příjemce, provozní metriky i postup migrace existujícího projektu rozebírá samostatná kapitola Outbox Pattern.

B2. Debugging ztracené zprávy v Messengeru

Problém: Zpráva odešla do asynchronní fronty a worker běží, handler se ale nezavolal. Kde zpráva skončila, ukáže následující postup.

Postup debuggingu:

1. Zkontrolujte failed transport:

bash snippet.sh
1php bin/console messenger:failed:show

Pokud je zpráva zde, zobrazí se s chybou. Znovu ji zpracujte:

bash snippet.sh
1php bin/console messenger:failed:retry

2. Zapněte verbose logging: V config/packages/monolog.yaml přidejte handler pro kanál messenger na úrovni debug. Každé odeslání, přijetí i zpracování se pak zaloguje.

3. Correlation ID middleware: Přidejte vlastní middleware, který spouštěcí zprávě přiřadí UUID a zaloguje ho při odeslání i při přijetí. Zprávy odeslané během jejího zpracování (odvozené události, další příkazy) ID zdědí, takže v logu najdete celý tok podle jednoho ID. Causation ID, tedy bezprostřední příčina konkrétní zprávy, je jiný údaj a middleware ho neřeší.

Zaregistrujte middleware v config/packages/messenger.yaml na všech sběrnicích, kterými tok prochází. Klíč middleware přepíše seznam z konfigurace Messengeru, proto výřez opakuje i validation a doctrine_transaction:

yaml config/packages/messenger.yaml (výřez: correlation ID)
1framework:2    messenger:3        buses:4            command.bus:5                middleware:6                    - App\SharedKernel\Infrastructure\Messenger\CorrelationIdMiddleware7                    - validation8                    - doctrine_transaction9            event.bus:10                default_middleware:11                    enabled: true12                    allow_no_handlers: true13                middleware:14                    - App\SharedKernel\Infrastructure\Messenger\CorrelationIdMiddleware

B3. Idempotence handlerů

Problém: Messenger garantuje doručení at-least-once, nikoli exactly-once. Pokud worker zprávu zpracuje, ale před potvrzením (ack) spadne, broker zprávu znovu doručí. Handler ji zpracuje podruhé. Výsledkem může být dvojitá platba, duplicitní objednávka nebo zdvojený e-mail.

Řešení: Idempotency Middleware s deduplikační tabulkou. Každý příkaz nese IdempotencyStamp s klíčem odvozeným z byznys operace, například payment.capture:{orderId}. Middleware před zpracováním zkontroluje databázovou tabulku. Když klíč existuje, zprávu přeskočí.

Middleware sedí na sběrnici, ne u handleru, a hodí se proto jen pro příkazy s jediným handlerem. U události s více posluchači by zapsaný klíč přeskočil všechny, i ty, které ji ještě nezpracovaly. Události deduplikuje inbox s klíčem (event_id, consumer) z kapitoly Outbox Pattern.

Na slově „odvozeným“ celý mechanismus stojí. UUID vygenerované při odeslání se jako idempotency klíč nehodí: dvojí odeslání téhož logického příkazu vyrobí dva různé klíče a obě zpracování proběhnou. Klíč musí být stejný při každém odeslání téhož logického příkazu. Rozdíl je praktický: náhodné UUID ošetří duplicitu z retry brokeru, ne ale dvojklik uživatele, který přijde častěji.

Podmínka na ReceivedStamp nese celou ukázku. Asynchronní příkaz projde middlewarem dvakrát: při dispatchi, kdy ho SendMessageMiddleware jen odešle do transportu, a znovu ve workeru, který obálku před zpracováním označí ReceivedStamp. Bez podmínky by se klíč zapsal už při odeslání. Worker by pak vlastní zprávu přeskočil jako duplikát a handler by neproběhl nikdy. Na synchronní sběrnici bez transportu ReceivedStamp nevznikne, takže tam middleware nededuplikuje; míří na asynchronní příkazy.

Odesílatel stamp připojí z dat, která má v ruce:

php snippet.php
1$this->commandBus->dispatch(2    new CapturePayment($orderId),3    [IdempotencyStamp::forOperation('payment.capture', $orderId->value)],4);

B4. Ordering zpráv – zpráva B dorazí před A

Problém: Máte dva workery zpracovávající stejnou frontu paralelně. Obě události OrderPlaced a OrderShipped odejdou za sebou, ale OrderShipped zpracuje jiný worker rychleji. Handler se pokusí označit objednávku jako odeslanou, jenže objednávka ještě neexistuje (nebo je ve špatném stavu).

Řešení: tři přístupy podle kontextu.

Přístup Kdy použít Kompromis
Optimistický retry Závislost je krátkodobá (ms) Handler hodí RecoverableMessageHandlingException s retryDelay → Messenger zprávu odloží; max_retries se na ni nevztahuje
Jeden worker na agregát Pořadí je kritické Nižší propustnost, ale zaručené pořadí pro každý agregát
Inbox buffer Komplexní závislosti Handler uloží zprávu do „inbox“ tabulky a zpracuje ji až po splnění podmínek

RecoverableMessageHandlingException přijímá parametr retryDelay (v milisekundách) a přebije tím prodlevu z nakonfigurované strategie. Pro chybějící závislost je to přesnější nástroj než obecná výjimka: čekáte řádově stovky milisekund, ne exponenciální backoff počítaný pro výpadek externí služby.

Výjimka ale obchází i max_retries. Podle dokumentace Symfony se taková zpráva opakuje donekonečna a do failed transportu sama nedojde. Když předpoklad nedorazí nikdy (OrderPlaced skončil ve failed transportu), worker bude OrderShipped zkoušet bez konce. Strop proto hlídá handler sám, třeba podle stáří zprávy: po jeho překročení hodí běžnou výjimku, na kterou už se max_retries vztahuje.

Garance pořadí ale nakonec drží transport, ne kód handleru. FIFO nabízí jen některé brokery a zpravidla za cenu propustnosti nebo omezení na jednu skupinu zpráv. Ověřte, co váš transport skutečně slibuje, dřív než na pořadí postavíte doménovou logiku. Zdravější cesta je pořadí nepotřebovat. Handler, který snese zprávy v libovolném sledu, nemá co rozbít.

Zpoždění se nezastaví na hranici workeru. Uživatel, který právě odeslal objednávku a hned nato vidí přehled bez ní, čte důsledek téhož mechanismu. Co s tím v rozhraní, rozebírá Eventual Consistency v praxi: potvrzení akce místo čtení z read modelu, optimistické vykreslení a viditelný stav „zpracovává se“ jsou tři obvyklé odpovědi. Rozhodnutí patří do návrhu obrazovky, ne do handleru.

20.03 C – Modelování#

Modelovací rozhodnutí se zdají triviální, dokud nezpůsobí problém v produkci. Následují čtyři pasti, které se vracejí nejčastěji.

C1. Kde žije validace

Problém: Validace je rozesetá na třech místech: Symfony Validator (atributy na DTO), Application Service (doménové podmínky) a doménový konstruktor (invarianty). Výsledkem je buď duplicita (stejná pravidla na dvou místech), nebo díry (pravidlo chybí na jednom místě).

Typ validace Kde patří Příklad
Formát hodnoty Konstruktor hodnotového objektu; Symfony Validator na vstupu kontrolu opakuje kvůli čitelné hlášce E-mail má platný formát, cena nesmí být záporná
Doménový invariant Metoda agregátu Množství nesmí být nulové, potvrdit jde jen objednávku s položkami
Doménová politika Agregát nebo doménová služba; aplikační služba jen dodá vstupní data Zákazník nesmí mít více než 5 otevřených objednávek
Databázová unikátnost Databázový unique constraint + kontrola v Application Service E-mail zákazníka je v systému unikátní

Hlavní pravidlo: Formát i doménový invariant se vynucují vždy v doméně; validace ve vyšší vrstvě je nenahradí. Doménový objekt vzniká i jinde: v CLI příkazu, v testu, při importu. Symfony Validator je první linie obrany pro uživatelský vstup, viz Kde validovat.

C2. Stavový automat bez anémického modelu

Objednávka prochází stavy Draft → Confirmed → Paid → Shipped → Delivered, s možností zrušení do určitého bodu. Anémický přístup $order->setStatus('shipped') přepíše hodnotu bez ochranných podmínek a bez kontroly, jestli je přechod povolený. Pravidla, která doménu definují, se tím ztratí.

Každý přechod má vlastní metodu, která ověří jeho platnost, změní stav a zaznamená doménovou událost; setter navenek nezbude. Holý setter je typickým projevem anémického modelu; obecný rozbor najdete v Anti-vzorech.

php snippet.php
1// Zkrácená podoba Order z kapitoly Návrh agregátu: továrnu place(),2// položky a idempotentní větve výřez vynechává.3final class Order extends AggregateRoot4{5    private OrderStatus $status = OrderStatus::Draft;6 7    public function __construct(8        public readonly OrderId $id,9        public readonly CustomerId $customerId,10    ) {11    }12 13    public function confirm(): void14    {15        if ($this->status !== OrderStatus::Draft) {16            throw InvalidOrderStateTransitionException::cannotTransition(17                $this->status->value,18                OrderStatus::Confirmed->value,19            );20        }21        $this->status = OrderStatus::Confirmed;22        $this->record(new OrderConfirmed($this->id, $this->customerId, new \DateTimeImmutable()));23    }24 25    public function ship(ShipmentId $shipmentId): void26    {27        if ($this->status !== OrderStatus::Paid) {28            throw InvalidOrderStateTransitionException::cannotTransition(29                $this->status->value,30                OrderStatus::Shipped->value,31            );32        }33        $this->status = OrderStatus::Shipped;34        $this->record(new OrderShipped($this->id, $shipmentId, new \DateTimeImmutable()));35    }36}

C3. Anti-Corruption Layer k externím API

Problém: Stripe vrací \Stripe\PaymentIntent, ARES vrací JSON, Fakturoid vlastní DTO. Když data externích systémů prosakují přímo do doménového kódu, každá změna externího API mění i doménový model. Vzor jako takový, včetně jeho místa na Context Mapě, rozebírá Anti-Corruption Layer; zde jde o jeho podobu v PHP.

Řešení: Ports & Adapters, tedy hexagonální architektura. Doménový model definuje Port (interface), který v doménových pojmech popisuje, co od externího systému potřebuje. Infrastrukturní vrstva k němu doplní Adapter, který přeloží externí API do doménového rozhraní.

Doménový kód pracuje jen s rozhraním PaymentGateway a o Stripe neví nic. Kapitola o ságách ukazuje port stejného jména s primitivními parametry; jde o jiný, zjednodušený výřez. Výměna platební brány (Stripe → Adyen) si vyžádá jen nový Adapter. Adapter volá Payment Intents API. Starší Charges API se source tokenem Stripe pro nové integrace nepovoluje. Ověření 3D Secure, které si banka může vyžádat, ukázkový adapter nezvládne a platbu odmítne. Plná integrace ho řeší na klientovi přes Stripe.js; pro ilustraci ACL to podstatné není.

C4. Ubiquitous Language drift

Problém: Po šesti měsících vývoje kód mluví jiným jazykem než doménový expert. V kódu je Invoice, zákazník říká „faktura“, účetní systém zná „Bill“. Třída Order pokrývá pojmy, které doména rozděluje na „nabídku“, „objednávku“ a „smlouvu“. Vývojáři si přestávají být jisti, co třída modeluje.

Příčina: Ubiquitous Language se vyvíjí s pochopením domény, není to jednou zapsaný artefakt. Bez aktivní správy kód zaostává za aktuálním chápáním.

Opatření: čtyři praktiky.

  1. Doménový glosář v repozitáři (docs/glossary.md) – živý dokument, kde každý pojem má definici, synonyma a odkaz na třídu v kódu. Aktualizuje se při každém přejmenování.

  2. Architecture Decision Records (ADR) – při každém záměrném přejmenování konceptu zapište ADR s důvodem. Budoucí vývojář pochopí, proč Contract nahradil Order.

  3. Event Storming jako pravidelná aktivita – ne jednorázový workshop na začátku projektu, ale revize s doménovými experty jednou za šest měsíců až za rok, v rychle se měnícím produktu častěji. Kadenci rozebírá sekce Doporučená frekvence.

  4. Živá dokumentace přes testy – popis chování v názvech testů ve stylu BDD (it_places_an_order_when_items_are_in_stock()) tvoří čitelnou dokumentaci aktuálního chování.

20.04 D – Symfony-specifické třenice#

Konvence Symfony cílí převážně na CRUD. S doménovým modelem se nejviditelněji střetávají na třech místech.

D1. Symfony Form vs. Command

Problém: FormType ve výchozím nastavení plní existující objekt přes settery nebo veřejné vlastnosti. Application Command má být readonly DTO s povinnými argumenty konstruktoru. Tvrzení „Symfony Form immutable objekty neumí“ je ale nepřesné: dokumentace popisuje volbu empty_data jako closure, která objekt vyrobí a předá mu odeslané hodnoty do konstruktoru. Command tedy jde naplnit přímo z formuláře.

Zbývá otázka, kde má vzniknout. Když Command vzniká přímo ve formuláři, začne tvar aplikačního příkazu kopírovat tvar obrazovky. S druhým vstupním kanálem (API, CLI, import) se to projeví.

Řešení: Formulář plní obyčejné měnitelné DTO (formulářový objekt), controller pak z validovaných dat sestaví neměnný Command. Formulář o Commandu neví a Command o formuláři také ne. Cesta přes empty_data se hodí tam, kde je formulář jediný vstup a mezikrok by byl jen opisem.

php snippet.php
1// 1. Formulářový objekt - mutable, kompatibilní s frameworkem2final class PlaceOrderFormData3{4    public string $customerId = '';5    public array  $items      = [];6}7 8// 2. FormType pracuje s formulářovým objektem9$form = $this->createForm(PlaceOrderType::class, new PlaceOrderFormData());10$form->handleRequest($request);11 12if ($form->isSubmitted() && $form->isValid()) {13    /** @var PlaceOrderFormData $data */14    $data = $form->getData();15 16    // 3. Controller sestaví Command - immutable, doménově typovaný17    $command = new PlaceOrder(18        // Identitu přiděluje aplikace, ne databáze - viz sekce A5.19        orderId: OrderId::generate(),20        customerId: CustomerId::fromString($data->customerId),21        items: array_map(22            fn($i) => new OrderItemDto($i['productId'], (int) $i['quantity']),23            $data->items,24        ),25    );26 27    $this->commandBus->dispatch($command);28}

PlaceOrder je readonly PHP třída. Handler s ní pracuje bez jakékoli závislosti na komponentě Symfony Form.

D2. API Platform vs. doménové agregáty

Problém: API Platform ve výchozím nastavení očekává přímý přístup k Doctrine entitám: čte je a zapisuje vestavěnými providery a processory. Agregát se ale nemá serializovat přímo (interní stav by pronikl do API) a API Platform ho nemá měnit mimo Application Service.

Řešení: Vystavte API Platform API Resource DTO (ne agregát) a implementujte vlastní StateProvider a StateProcessor, které fungují jako adaptéry k Application Services.

D3. Security Voter vs. doménová oprávnění

Problém: Pravidla přístupu, která určuje byznys, jsou součástí domény. Příklad: „objednávku může zrušit zákazník, ale pouze do 24 hodin od potvrzení a pouze pokud ještě nebyla expedována“. Symfony Security Voter žije v infrastrukturní vrstvě a závisí na frameworku. Pokud logiku napíšete přímo do Voteru, její test potřebuje bezpečnostní token a další objekty frameworku.

Řešení: Voter funguje jako tenký adaptér, který deleguje rozhodnutí na doménovou metodu agregátu. Doménová metoda na frameworku nezávisí a testuje se bez něj.

Kde přesně která kontrola leží, rozebírá kapitola Autorizace v DDD, včetně toho, proč Voter sám nestačí a co patří přímo do agregátu.

20.05 E – Organizace a tým#

Projekty, které DDD opustí, málokdy ztroskotají na technice. Evans to v DDD Reference shrnuje bez příkras: řada projektů modeluje, a přesto z toho nakonec nic nemá. Důvody, které tomu obvykle předcházejí, jsou organizační: tým vzor nepochopí, management k němu nedá mandát, znalost zůstane v hlavě jednoho seniora. Následující tři sekce vycházejí ze zkušenosti, ne z měření; citovatelná data o opuštění DDD neexistují.

E1. Business case pro DDD refaktoring

Problém: Management vidí náklady refaktoringu (čas, riziko), ale ne přínosy. „Přepsat to do DDD“ zní jako technická čistota bez obchodní hodnoty. Vývojáři přínosy často neumějí přeložit do jazyka, kterému rozhodující lidé rozumějí.

Jak argumentovat: metrikami.

Metrika Jak měřit Proč ji sledovat
Change lead time Doba od commitu po nasazení do produkce (definice DORA) Srovnatelná mezi týmy, management ji zná
Change fail rate % nasazení, která si vyžádají hotfix nebo rollback Ukazuje, jestli změny v modulu drží
Regression rate % ticketů označených jako regrese Nejblíž bolesti „opravíme jedno, rozbije se druhé“
Onboarding time Čas, než nový vývojář dělá první commit do modulu Měří srozumitelnost modelu, ne jeho čistotu

Dvě z těch metrik (change lead time a change fail rate) pocházejí ze sady DORA, která se z původních čtyř metrik rozrostla na pět a slouží jako sdílený slovník pro dodávku softwaru. Měřte je před refaktoringem a po něm, ale zdržte se slibu, že klesnou kvůli DDD. Žádná studie souvislost mezi architektonickým stylem a chybovostí nedoložila a metrika „bugů na tisíc řádků“ je u refaktoringu zavádějící sama o sobě: mění se jí jmenovatel. Čísla tedy nesou váhu jako společný jazyk s byznysem, ne jako důkaz.

Taktika: Nezačínejte argumentem „náš kód je špatný“, ale konkrétní obchodní bolestí. Ilustrativní scénář: „Přidání nového způsobu platby trvá tři týdny a pokaždé způsobí regresi v objednávkovém modulu.“ Následuje příčina a návrh řešení. Čísla si dosaďte vlastní; půjčené odhady rozhodovatel prohlédne.

E2. Postupné zavedení – Strangler Fig Pattern

Problém: Přepis celé aplikace do DDD najednou zpravidla selže: trvá déle, než se odhadovalo, tým ztrácí motivaci a byznys se nedočká nových funkcí. Původní aplikace přitom musí dál žít. Proč big-bang rewrite končí špatně, rozebírá varování v kapitole Migrace z CRUD na DDD.

Řešení: Strangler Fig Pattern. Vyberte jeden modul, který se mění nejčastěji (highest-churn), má nejvíc chyb nebo největší obchodní hodnotu a implementujte v DDD právě ten. Zbytek aplikace zůstává beze změny. S novým kódem komunikuje přes fasádu (ACL vzor) a feature flag umožňuje okamžitý rollback na legacy. Po stabilizaci se postup opakuje s dalším modulem, dokud legacy nevyschne.

Kompletní postup popisuje kapitola Migrace z CRUD na DDD: analýzu domény, extrakci doménové vrstvy, charakterizační testy i odhady náročnosti.

E3. Knowledge silos a bus factor

Problém: Doménový model je komplexní a po roce vývoje mu dobře rozumí jen jeden člověk. Když onemocní, odejde nebo se přetíží, tým stojí. Onboarding nového vývojáře trvá měsíce. Bus factor = 1 je pro projekt kritické riziko.

Opatření: Proti nízkému bus factoru pomáhají dvě praktiky cílené přímo na sdílení vlastnictví:

  1. Párové programování nad doménovým modelem: Změny v agregátech a doménových pravidlech procházejí ve dvojici. Znalost se přenáší průběžně, ne až při odchodu autora.

  2. Rotace vlastnictví modulů: Žádný Bounded Context nemá trvale jen jednoho správce. Periodická rotace nutí tým rozumět více částem systému.

Ostatní nástroje jsou tytéž jako proti driftu Ubiquitous Language: doménový glosář v repozitáři, ADR u netriviálních rozhodnutí, pravidelný Event Storming a živá dokumentace přes testy (viz Ubiquitous Language drift).

Časté otázky

Proč tradiční Doctrine mapování komplikuje čistý doménový model?

Doctrine hydratuje objekty reflexí a změny sleduje přes Unit of Work, zatímco DDD agregát chrání invarianty v konstruktoru a doménových metodách a hodnotové objekty jsou neměnné. Konflikt zahrnuje identifikaci přes generované ID (Doctrine) oproti identitě v doméně (DDD), problém „špinavého“ EntityManageru během requestu a omezení typů pro hodnotové objekty. Pragmatická výchozí volba je nechat atributy přímo na agregátu (jsou to metadata, ne chování) a používat Doctrine custom typy pro hodnotové objekty. Striktně oddělenou doménu nabízí Persisted Object Pattern: samostatný persistence model a mapper. Detail v sekci Doctrine vs. doménový model.

Jak řešit Outbox Pattern pro spolehlivé doručení integračních událostí?

Outbox ukládá integrační události do lokální tabulky ve stejné transakci jako změnu agregátu. Událost se tak neztratí při pádu mezi commitem a publikací a neodejde ani ven dřív, než transakce skutečně projde. Samostatný proces (relay) pak outbox tabulku čte a publikuje události do message busu nebo externího systému. Kombinace s idempotentními konzumenty zajišťuje at-least-once doručení bez duplicit na straně zpracování. Shrnutí v sekci Outbox pattern, plný výklad v kapitole Outbox Pattern.

Jak vysvětlit přínos DDD managementu, když první iterace zpomaluje?

Doporučený postup je přiznat krátkodobý náklad a přínos opřít o konkrétní obchodní bolest, třeba o to, kolik týdnů trvá přidat nový způsob platby. Metriky (change lead time, change fail rate, podíl regresí, doba onboardingu) se měří před refaktoringem a po něm jako společný jazyk s byznysem, ne jako slib, že je DDD zlepší. Hodí se začít pilotním Bounded Contextem. Kdy přijdou první výsledky, závisí na velikosti kontextu a zkušenosti týmu; řádově jde o měsíce, ne o týdny, a slibovat konkrétní číslo dopředu se nevyplácí. Bez sponzorství na úrovni managementu investice do DDD zpravidla neprojde. Rozbor strategie komunikace v sekci Business case pro DDD refaktoring.

Jak udržet Ubiquitous Language, aby časem neutrpěl drift?

Ubiquitous Language zaniká, když se kód a řeč doménových expertů začnou rozcházet: v kódu je „Invoice“, zákazník říká „faktura“. Prevence vyžaduje pravidelnou revizi kódu proti slovníku, ADR při jeho změně a glosář v repozitáři jako živý dokument. Drift se projeví, jakmile nová funkce zavádí pojem, který doménový expert nezná. Tehdy je namístě buď ustoupit, nebo jazyk společně upravit. Detailní rozbor v sekci Ubiquitous Language drift.

Jak přežít paralelní existenci staré CRUD části a nové DDD vrstvy?

Strangler Fig Pattern umožňuje oba stavy držet v jedné aplikaci: staré CRUD moduly zůstávají v provozu, nové funkce vznikají v DDD stylu a propojení řeší Anti-Corruption Layer. Výzvou jsou sdílená databáze, autentizace a uživatelský stav. Pragmatické řešení: migrovat postupně po jednotlivých Bounded Contextech a explicitně přijmout, že smíšený stav vydrží dlouho. U netriviálního systému jde řádově o roky, ne o jedno kvartální plánování. Viz sekci Postupné zavedení.