DDD v praxi – kde to bolí
Katalog 20 reálných bolestivých míst při implementaci DDD v PHP a Symfony: transakce přes agregáty, Doctrine mapping, Outbox pattern, debugging Messengeru, validace, Anti-Corruption Layer, přesvědčení managementu a další.
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. V praxi se implementace DDD střetává s řadou problémů, na které standardní DDD literatura většinou neupozorňuje. Architektonické principy narážejí na realitu frameworku, databáze, asynchronní infrastruktury i týmové dynamiky.
Tato kapitola je katalog 20 reálných provozních problémů, se kterými se setkávají týmy implementující DDD v PHP a Symfony. Zaměřuje se na třenice s konkrétní technologií: Doctrine Unit of Work, Symfony Messenger, Outbox pattern, autorizace, race conditions. Pro každý problém najdete: popis situace, analýzu příčiny a doporučené řešení – tam kde je to výmluvné, s ukázkou kódu.
Pro úhel kódových a modelovacích anti-vzorů (anémický model, Primitive Obsession, God Aggregate, sdílená databáze napříč BC) viz Anti-vzory. Pro rozhodovací rámec, jestli DDD vůbec použít, viz Kdy DDD nepoužívat.
20.01 A – Doctrine vs. doménový model
Doctrine ORM má interní model (Unit of Work, Identity Map, lazy loading) stavěný pro jednoduchý CRUD. Doménový model s neměnnými konstruktory, privátními settery a invarianty na něj naráží na šesti místech, která následují.
A1. Transakce přes agregáty a Doctrine Unit of Work
Problém: DDD říká, že jedna transakce smí měnit nejvýše jeden agregát.
Praxe ale přináší situace, kde potřebujete atomicky uložit změny ve dvou agregátech
zároveň – například přesunout objednávku do stavu Transferred a zároveň
potvrdit skladovou rezervaci. Doctrine sdílí jeden EntityManager
(a tím jeden Unit of Work) přes celou aplikaci; jeden flush() commituje
vše, co EM sleduje.
Příčina: Doctrine Unit of Work je session-scoped – 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 to dává smysl, pro DDD to znamená,
že neúmyslně načtená entita z jiného agregátu může být commitnuta společně s vaší
záměrnou změnou.
Řešení: Application Service funguje jako explicitní transakční hranice.
Pokud váš use case vyžaduje změnu dvou agregátů atomicky a nemůžete použít
Outbox + Sagu, zavolejte
explicitně beginTransaction() / commit() v Application Service. Oba repozitáře
volejte v téže transakci. Toto je přijatelná výjimka z pravidla jeden agregát =
jedna transakce za předpokladu, že oba agregáty leží ve stejném Bounded Context
a stejné databázi.
A2. „Špinavý“ EntityManager a nechtěné změny
Problém: V read-heavy akcích (příprava dat pro API response, sestavení
read modelu) načtete entitu z databáze, provedete výpočet, ale neuložíte nic.
Přesto se při prvním flush() kdekoli v requestu (třeba v jiné části aplikace)
commitují změny do databáze. Důvod: nenápadně jste modifikovali entitu, kterou
Doctrine stále sleduje.
Příčina: Doctrine Identity Map zapamatuje každý načtený objekt
a při flush() porovnává aktuální stav se snapshoty uloženými při
načtení (change tracking). Volání getterů, které interně modifikují stav
(lazy-init kolekce, computed fields), může způsobit detekci „změny“.
Ř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 | Injektujte separátní EntityManager nakonfigurovaný jako read-only (second EM v Symfony) |
A3. Mapping složitých Value Objects
Problém: Doctrine #[Embedded] funguje dobře pro jednoduché
VO (jméno + příjmení → dva sloupce). Limity narazíte v několika případech:
polymorfní VO (různé typy cen), nullable VO v kolekcích, VO s vlastní serializační
logikou (Money = integer + string). Stejně tak u VO, které se mapují na jiný datový
typ než výchozí (enum, JSONB, custom SQL type).
Řešení – Custom Doctrine Type: Implementujte Type
z Doctrine\DBAL\Types. Typ definuje, jak se PHP objekt serializuje
do SQL hodnoty a zpět. Zaregistrujte typ v config/packages/doctrine.yaml.
Typ zaregistrujte v config/packages/doctrine.yaml:
doctrine:
dbal:
types:
money: App\SharedKernel\Infrastructure\Doctrine\Type\MoneyType
Poté ho použijte v entitě:
#[ORM\Column(type: 'money', nullable: true)]
private ?Money $price = null;
A4. Lazy loading a doménové metody
Doctrine ve výchozím nastavení načítá asociace lazy – do property vloží proxy třídu,
která se inicializuje až při prvním přístupu. Doménová metoda jako totalPrice()
nebo items() o tom nic neví a implicitně spoléhá na aktivní databázové připojení.
Když ji zavoláte mimo otevřenou transakci nebo po detach(), dostanete
UninitializedLazyObjectException (PHP 8.4 lazy objects) nebo
ORMInvalidArgumentException ve starších verzích Doctrine ORM.
Lazy proxy je infrastrukturní koncept. Doménový model o ní vědět nesmí, jenže ji v paměti nese. Volba načítání tedy musí přijít zvenčí – ze strany repozitáře nebo konkrétní query.
Řešení podle složitosti situace:
| Situace | Řešení |
|---|---|
| Kolekce vždy potřebná s agregátem | fetch: 'EAGER' na asociaci – načte v jednom JOIN |
| Kolekce potřebná jen někdy | Repozitář nabídne dvě metody: get() (lazy) a getWithItems() (EAGER JOIN) |
| Serializace / JSON response | Nikdy neserializujte agregát přímo – sestavte DTO z načtených dat uvnitř transakce |
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 není
persistován a flushed. Tím se porušuje doménový invariant: každý agregát musí
mít identitu od okamžiku vzniku.
Příčina: Databázové generování ID šetří jeden dotaz pro získání ID, 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í: Generujte UUID v doméně, v konstruktoru agregátu.
Doctrine nakonfigurujte s strategy: 'NONE' – ID předáváte sami,
Doctrine ho jen uloží.
Doctrine mapping pro UUID ID:
#[ORM\Id]
#[ORM\Column(type: 'string', length: 36)]
#[ORM\GeneratedValue(strategy: 'NONE')] // Doctrine ID nepřiřazuje
private 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. Jenže: přidání nového subtypu vyžaduje
úpravu anotace na rodičovské třídě, a discriminator map je zapsána v kódu
jako statický seznam – narušuje Open/Closed Principle.
Řešení – dvě alternativy:
| Přístup | Kdy použít | Nevýhoda |
|---|---|---|
| Value Object místo dědičnosti | Varianty se liší jen daty, ne chováním | Složitý switch pro chování |
| Flat table + Custom Type | Varianty mají odlišné chování | JSON sloupec pro detaily ztrácí typovou bezpečnost |
| Discriminator map (Doctrine default) | Málo variant, stabilní hierarchie | Rigidní, narušuje OCP |
Pro většinu DDD scénářů se osvědčuje Value Object s type fieldem: 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.
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í. Tato sekce pokrývá čtyři nejčastější bolesti.
B1. Outbox pattern – zaručené doručení doménových událostí
Problém: Uložíte agregát (flush() proběhne úspěšně),
ale před tím, než stihnete odeslat doménovou událost do Messengeru, server spadne.
Událost se ztratí – databáze je konzistentní, ale žádný subscriber ji nikdy
nezpracuje. Platba proběhla, ale sklad nebyl upozorněn.
Příčina: flush() a $bus->dispatch() jsou dvě separátní operace bez atomické záruky.
Neexistuje způsob, jak je zabalit do jedné transakce – databáze a message broker jsou různé systémy.
Řešení – Outbox pattern: Místo přímého odeslání do brokeru
uložte událost do outbox tabulky ve stejné databázové transakci
jako agregát. Separátní worker pak z tabulky čte a odešle zprávy do Messengeru.
Atomicita je garantována databázovou transakcí; at-least-once doručení zajišťuje worker.
Důležitý detail: outbox záznamy musí být persistovány uvnitř téže transakce
jako agregát. Listener musí reagovat na událost onFlush (ještě před
commitem) – nikoliv na postFlush, který se volá po commitu
transakce a tedy mimo ni. Použití postFlush s voláním dalšího
flush() by navíc způsobilo nekonečnou rekurzi.
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Events;
#[AsDoctrineListener(event: Events::onFlush)]
final class OutboxEventListener
{
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getEntityManager(); // getObjectManager() odstraněno v ORM 3.x
$uow = $em->getUnitOfWork();
// Projdeme nové i změněné entity a sebereme doménové události
foreach ([...$uow->getScheduledEntityInsertions(), ...$uow->getScheduledEntityUpdates()] as $entity) {
if (!$entity instanceof HasDomainEvents) {
continue;
}
foreach ($entity->releaseDomainEvents() as $event) {
$outbox = new OutboxEvent(get_class($event), $this->serializer->normalize($event));
$em->persist($outbox);
// Outbox entitu musíme ručně přidat do Unit of Work - jsme uvnitř onFlush
$uow->computeChangeSet($em->getClassMetadata(OutboxEvent::class), $outbox);
}
}
// Žádný další flush() - outbox záznamy jsou součástí probíhající transakce
}
}
B2. Debugging ztracené zprávy v Messengeru
Problém: Zpráva odešla do async fronty. Worker běží. Handler ale nikdy nezavolal. Jak zjistit, kde zpráva skončila?
Postup debuggingu:
1. Zkontrolujte failed transport:
php bin/console messenger:failed:show
Pokud je zpráva zde, zobrazí se s chybou. Znovu ji zpracujte:
php bin/console messenger:failed:retry
2. Zapněte verbose logging: V config/packages/monolog.yaml
přidejte handler pro messenger channel na úroveň debug.
Každý dispatch, receive a zpracování se zaloguje.
3. Correlation ID middleware: Přidejte vlastní middleware, který přiřadí každé zprávě UUID a loguje ho při dispatch i při receive. Pak hledáte v logu podle ID.
Zaregistrujte middleware v config/packages/messenger.yaml:
framework:
messenger:
buses:
command.bus:
middleware:
- App\SharedKernel\Infrastructure\Messenger\CorrelationIdMiddleware
B3. Idempotence handlerů
Problém: Messenger garantuje at-least-once doručení – 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ý email.
Řešení – Idempotency Middleware s deduplikační tabulkou:
Každá zpráva nese IdempotencyStamp s unikátním klíčem
(vygenerovaným při prvním odeslání). Middleware před zpracováním zkontroluje
databázovou tabulku – pokud klíč existuje, zprávu přeskočí.
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 jsou odeslány 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í výjimku → Messenger retry s DelayStamp |
| Jeden worker na agregát | Ordering je kritický | Nižší throughput, ale garantované pořadí per-aggregate |
| Inbox buffer | Komplexní závislosti | Handler uloží zprávu do „inbox“ tabulky a zpracuje ji až po splnění podmínek |
20.03 C – Modelování
Modelovací rozhodnutí se zdají triviální, dokud nezpůsobí problém v produkci. Čtyři pasti, které se vrací nejčastěji.
C1. Kde žije validace
Problém: Validace je rozeseta na třech místech: Symfony Validator (anotace 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átová validace | API / formulářová vrstva (Symfony Validator) | Email musí být validní formát, číslo musí být kladné |
| Doménový invariant | Konstruktor / metoda agregátu nebo VO | Množství nesmí být nulové, cena nesmí být záporná |
| Doménová politika | Domain Service nebo Application Service | Zákazník nesmí mít více než 5 otevřených objednávek |
| Databázová unikátnost | Databázový unique constraint + Application Service check | Email zákazníka musí být unikátní v systému |
Hlavní pravidlo: Doménový invariant vždy vynucujte v doméně. Nespoléhejte na validaci ve vyšší vrstvě – doménový objekt může být sestaven i z jiného místa (CLI command, test, import). Symfony Validator je první linie obrany pro uživatelský vstup, nikoli náhrada doménové validace.
C2. Stavový automat bez anémického modelu
Objednávka prochází stavy Draft → Placed → Paid → Shipped → Delivered → Cancelled.
Anémický přístup $order->setStatus('shipped') přepíše hodnotu bez guard conditions
a bez kontroly, jestli přechod dává smysl. Doména ztrácí pravidla, která ji definují.
Explicitní metoda pro každý přechod tento problém zavírá. Ověří, jestli je přechod validní, provede změnu stavu a zaregistruje doménovou událost. Tři kroky v jedné metodě, žádný setter navenek.
final class Order
{
private OrderStatus $status = OrderStatus::Draft;
public function place(): void
{
if ($this->status !== OrderStatus::Draft) {
throw new \DomainException("Objednávku lze odeslat pouze ve stavu Draft.");
}
$this->status = OrderStatus::Placed;
$this->record(new OrderPlaced($this->id));
}
public function ship(TrackingNumber $trackingNumber): void
{
if ($this->status !== OrderStatus::Paid) {
throw new \DomainException("Objednávku lze expedovat pouze po zaplacení.");
}
$this->status = OrderStatus::Shipped;
$this->trackingNumber = $trackingNumber;
$this->record(new OrderShipped($this->id, $trackingNumber));
}
}
C3. Anti-Corruption Layer k externím API
Problém: Stripe vrací \Stripe\Charge, Ares vrací
XML nebo pole, Fakturoid vrací vlastní DTO. Pokud tato data z externích systémů
prosakují přímo do doménového kódu, změna externího API = změna doménového modelu.
Řešení – Port & Adapter (Hexagonální architektura): Doménový model definuje Port (interface) popisující, co potřebuje od externího systému – v doménových pojmech. Infrastrukturní vrstva implementuje Adapter, který přeloží externí API do doménového rozhraní.
Doménový kód pracuje pouze s PaymentGateway rozhraním – nic neví
o Stripe. Výměna platební brány (Stripe → Adyen) vyžaduje pouze nový Adapter,
doménový kód se nemění.
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 čtvrtletní revize s doménovými experty.
-
Živá dokumentace přes testy – BDD-style popis v testech (
it_places_an_order_when_items_are_in_stock()) tvoří čitelnou dokumentaci aktuálního chování.
20.04 D – Symfony-specifické třenice
Symfony konvence cílí převážně na CRUD. Tři místa, kde framework-first přístup koliduje s doménovým modelem nejviditelněji.
D1. Symfony Form vs. Command
Problém: FormType ve Symfony chce mutable objekt,
který hydratuje daty z requestu. Application Command by naopak měl být immutable
DTO sestaven z validovaných dat. Tyto dva světy se obtížně kombinují bez toho,
aby FormInterface pronikl do aplikační vrstvy.
Řešení: Form mapuje na plain mutable DTO (formulářový objekt), Application Service pak sestaví immutable Command. Žádná ze dvou vrstev neví o existenci té druhé.
// 1. Formulářový objekt - mutable, kompatibilní s frameworkem
final class PlaceOrderFormData
{
public string $customerId = '';
public array $items = [];
}
// 2. FormType pracuje s formulářovým objektem
$form = $this->createForm(PlaceOrderType::class, new PlaceOrderFormData());
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
/** @var PlaceOrderFormData $data */
$data = $form->getData();
// 3. Controller sestaví Command - immutable, doménově typovaný
$command = new PlaceOrderCommand(
customerId: CustomerId::fromString($data->customerId),
items: array_map(
fn($i) => new OrderItemDto($i['productId'], (int) $i['quantity']),
$data->items,
),
);
$this->commandBus->dispatch($command);
}
PlaceOrderCommand je readonly PHP class – doménový kód s ní pracuje
bez jakékoli závislosti na Symfony Form komponentě.
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 a zapisuje je pomocí vestavěných Provider a Processor. Agregáty ale nechceme serializovat přímo (interní stav by pronikl do API) ani nechat API Platform je modifikovat bez 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: Business pravidla přístupu jsou součástí domény. Příklad: „objednávku může zrušit zákazník nebo admin, ale pouze do 24 hodin od vytvoření 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 ve Voteru, stane se netestovatelnou bez Symfony kontejneru.
Řešení: Voter funguje jako tenký adaptér, který deleguje rozhodnutí na doménovou metodu agregátu. Doménová metoda je čistá funkce – testovatelná bez frameworku.
20.05 E – Organizace a tým
Technické selhání DDD je vzácné. Mnohem častější bývá, že tým vzor nepochopí, management k němu nedá mandát nebo znalost zůstane v hlavě jednoho seniora.
E1. Business case pro DDD refaktoring
Problém: Management vidí náklady refaktoringu (čas, riziko), ale ne benefity. „Přepsat to do DDD“ zní jako technická čistota bez obchodní hodnoty. Vývojáři neumí výhody přeložit do jazyka, který rozhodující osoby slyší.
Jak argumentovat – měřitelné metriky:
| Metrika | Jak měřit | Co říká managementu |
|---|---|---|
| Time-to-feature | Průměrná doba od zadání po produkci (JIRA, Linear) | Refaktoring → kratší cyklus = rychlejší obchodní reakce |
| Bug rate per modul | Počet bugů na 1000 řádků kódu (SonarQube) | Moduly po DDD refaktoringu mají nižší bug rate |
| Onboarding time | Čas, než nový vývojář dělá první commit do modulu | Explicitní doménový model = kratší onboarding |
| Regression rate | % ticketů označených jako regression | Dobře ohraničené agregáty = méně neúmyslných vedlejších efektů |
Taktika: Nezačínejte argumentem „náš kód je špatný“. Začněte konkrétní obchodní bolestí: „Přidání nového způsobu platby trvá 3 týdny a vždy způsobí regression v objednávkovém modulu. Níže je uvedena příčina a způsob řešení.“
E2. Postupné zavedení – strangler fig pattern
Problém: Big-bang rewrite – přepsání celé aplikace do DDD najednou – selže ve většině týmů. Trvá déle než odhadnuto, tým ztrácí motivaci, byznys se nedočká nových funkcí. A přitom původní aplikace musí dál žít.
Řešení – strangler fig pattern: Identifikujte jeden modul s nejvyšší změnovou frekvencí (highest-churn), nejčastějšími bugy nebo největší obchodní hodnotou. Implementujte právě ten modul v DDD. Zbytek aplikace zůstane beze změny.
Postup v Symfony projektu:
-
Identifikujte modul:
git log --stat | grep "files changed" | sort -rn | head -20– soubory s nejvíce změnami za posledních 6 měsíců jsou nejlepší kandidáti. -
Vytvořte fasádu přes legacy kód: nový DDD kód volá legacy přes interface (ACL vzor). Legacy kód o novém DDD ví co nejméně.
-
Feature flag: Pro každý nový modul zapněte DDD implementaci pomocí feature flagu. Při problémech okamžitě rollback na legacy.
-
Opakujte pro další modul, dokud legacy nevyschne.
E3. Knowledge silos a bus factor
Problém: Doménový model je komplexní – a po roce vývoje mu rozumí dobře jen jeden člověk. Pokud tento člověk onemocní, odejde nebo je přetížen, tým stojí. Onboarding nového vývojáře trvá měsíce. Bus factor = 1 je pro projekt kritické riziko.
Opatření – čtyři praktiky:
-
Living documentation přes testy: Pojmenování testů ve stylu
it_cannot_ship_order_that_is_not_paid()tvoří čitelný katalog doménových pravidel. Kdo čte testy, pochopí doménový model bez vývojáře. -
Architecture Decision Records (ADR): Každé netriviální rozhodnutí (proč Saga místo 2PC, proč Value Object místo entity, proč tento Bounded Context takto ohraničený) zapište do
docs/adr/. Budoucí vývojář pochopí kontext bez „senior kolegy“. -
Event Storming jako týmová aktivita: Modelování domény musí probíhat v celém týmu, ne v hlavě jednoho architekta. Pravidelné (čtvrtletní) Event Storming sessions sdílejí znalosti a odhalují nekonzistence.
-
Doménový glosář v repozitáři: Živý dokument, kde každý vývojář může hledat, co
FulfillmentContextznamená, jaké jsou jeho agregáty a na jaké Bounded Contexts navazuje.
Časté otázky
Proč tradiční Doctrine mapování komplikuje čistý doménový model?
Doctrine očekává klasické PHP třídy s veřejnými nebo reflektovanými atributy, zatímco DDD agregát vyžaduje neměnnost, privátní settery a invarianty vynucené v konstruktoru. Konflikt zahrnuje identifikaci přes generované ID (Doctrine) oproti identitě v doméně (DDD), problém „špinavého“ EntityManageru při dlouhých transakcích 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. Pokud chcete striktně oddělenou doménu, jděte cestou Persisted Object Pattern – samostatný persistence model + mapper. Detail v sekci Doctrine vs. doménový model.
Jak řešit Outbox Pattern pro spolehlivé doručení doménových událostí?
Outbox ukládá doménové události do lokální tabulky ve stejné transakci jako změnu agregátu, čímž se zabrání ztrátě událostí při pádu mezi commitem a publikací. 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í. Praktický příklad v sekci Outbox Pattern.
Jak vysvětlit přínos DDD managementu, když první iterace zpomaluje?
Doporučený postup je přiznat krátkodobý náklad a explicitně vyčíslit dlouhodobý přínos: nižší počet regresních chyb, rychlejší onboarding, menší náklady na přidávání nových funkcí po překročení zlomu. Hodí se kombinovat s měřitelnými cíli (lead time, change failure rate) a s pilotním Bounded Contextem, který doručí první výsledky za 3–6 měsíců. Bez sponzorství na úrovni managementu investice do DDD zpravidla neprojde. Rozbor strategie komunikace v sekci Management.
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 pravidelný review 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á – v ten moment je nutné 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 je sdílená databáze, autentizace a uživatelský stav. Pragmatické řešení: postupně migrovat podle Bounded Contextu, ne podle modulu, a explicitně přijmout, že projekt bude mít smíšený stav po 12–24 měsíců. Viz sekci Strangler pattern.