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í.
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:
1doctrine:2 dbal:3 types:4 money: App\SharedKernel\Infrastructure\Doctrine\Type\MoneyType
Poté ho použijte v entitě:
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:
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:
1php bin/console messenger:failed:show
Pokud je zpráva zde, zobrazí se s chybou. Znovu ji zpracujte:
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:
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:
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.
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.
-
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í. -
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č
ContractnahradilOrder. -
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.
-
Ž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.
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í:
-
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.
-
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í.