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

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ší.

Autor M. Katuščák
Doba čtení ≈ 35 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. 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:

yaml config/packages/doctrine.yaml
doctrine:
    dbal:
        types:
            money: App\SharedKernel\Infrastructure\Doctrine\Type\MoneyType

Poté ho použijte v entitě:

php snippet.php
#[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:

php snippet.php
#[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.

php src/OutboxEventListener.php
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:

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

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

bash snippet.sh
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:

bash snippet.sh
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.

php src/SharedKernel/Infrastructure/Messenger/IdempotencyStamp.php
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:

  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 čtvrtletní revize s doménovými experty.

  4. Ž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é.

php src/PlaceOrderFormData.php
// 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:

  1. 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.

  2. 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ě.

  3. Feature flag: Pro každý nový modul zapněte DDD implementaci pomocí feature flagu. Při problémech okamžitě rollback na legacy.

  4. 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:

  1. 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.

  2. 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“.

  3. 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.

  4. Doménový glosář v repozitáři: Živý dokument, kde každý vývojář může hledat, co FulfillmentContext znamená, 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.