TL;DR: Aplikace, která roky roste bez přesně definovaných hranic, skončí nakonec jako propletenec, kde každá úprava rozbije něco jiného. V tomto článku sepisuju postup, jak hranice nakreslit: event storming za 90 minut, pojmenování bounded contexts, implementace v Symfony – a taky kdy to celé nechat být.
Ne úplně netypická situace. Přijdu na roky vyvíjený e-shop, kde se na něm vystřídalo několik vývojářů, a než otevřu první soubor, někdo mi s klidnou tváří řekne: „OrderController má dva tisíce řádků, ale jinak je to v pohodě.“
Pak otevřu User entitu. Sedmdesát, osmdesát polí (fakt se mi to nechce ani počítat). Jméno, e-mail, heslo – to tam dává smysl. Ale taky last_crm_sync_at, preferred_invoice_language, warehouse_region_override, gdpr_consent_marketing_emails_v3… Zkrátka jedna taková entita, která nese historii celého vývoje a každé rychlé opravy.
Jenže přijde klient, že chce VIP slevu pro vybrané zákazníky. Přidat pole, zobrazit jiný ceník, aplikovat při placení, odhad na cca pár dní práce. Jenže „přidat VIP slevu“ znamená sáhnout do User entity, OrderControlleru, CartService, PriceCalculatoru, InvoiceGeneratoru a ještě dvou deprecated služeb, které se nikdo neodvážil smazat. Takže z toho jsou týdny a k tomu obvykle nějaký ten výpadek checkoutu. Všechno je propojené se vším a hranice prostě neexistují.
Tenhle článek je o tom, jak takové situaci předejít (nebo aspoň jak z ní ven, pokud v ní už jste): event storming workshop, pojmenování bounded contexts a implementace v Symfony. Pokud jste o DDD zatím jen slyšeli (a nebo třeba vůbec), doporučuju začít průvodcem DDD v Symfony – tenhle článek na něj navazuje z praktičtějšího úhlu.
Co to vlastně je bounded context?
Pojem zavedl Eric Evans v knize Domain-Driven Design: bounded context je explicitní hranice, uvnitř které platí jeden konzistentní doménový model. Uvnitř hranice má každý pojem jednoznačný, dohodnutý význam, ale mimo ni může tentýž pojem znamenat něco úplně jiného.
V e-shopu slovo „objednávka“ neznamená jedno a to samé všude. Pro zákazníka je objednávka seznam zboží, které zaplatil, s adresou doručení a stavem „doručeno“. Pro sklad je to seznam položek k vyskladnění, s pozicemi v regálech a vahou balíku. Pro účetnictví je to daňový doklad s DIČ, sazbami DPH a číslem faktury. Tři různé světy, tři různé modely a v projektu z úvodu se všechny tři jmenovaly Order a žily v jedné třídě hezky pospolu.
Bounded context říká: pojmenujme tyhle světy explicitně a dejme každému vlastní model. Konkrétně je to:
- explicitní hranice s dohodnutým jazykem (ubiquitous language)
- vlastní doménový model platný uvnitř té hranice
- vlastní pravidla a invarianty
- vědomá volba, kde jedna „věc“ končí a druhá začíná
Co to naopak není: složka souborů ani namespace (App\Order\), modul ve smyslu „dali jsme to jinam“, ani technická vrstva typu controller nebo repository. Což se občas (spíše častěji) děje: tým přejmenuje složky, namespace hezky sedí, a doménový model zůstane přesně tak propletený jako předtím.
V praxi to znamená pár docela konkrétních věcí. Ta osmdesátipolová User entita se rozpadne na několik menších, každá ve svém kontextu a každá jen s tím, co opravdu potřebuje. Slovo „objednávka“ přestane být jedna třída a stane se z něj Sales\Order, Fulfillment\Shipment a Billing\Invoice – tři modely s různou odpovědností. Změna ceníku se pak dotkne Sales contextu a ostatní se to vlastně nedozví. A nový vývojář nemusí znát celý systém, aby mohl začít psát kód, stačí mu pochopit jeden context.
Jak hranice najít? Event storming za 90 minut
Event storming je workshopová technika, kterou vymyslel Alberto Brandolini. Cíl je jednoduchý: dostat doménové znalosti z hlav odborníků na zeď a pak z té zdi vyčíst, kde jsou přirozené hranice.
Nepotřebujete na to UML ani architektonický dokument. Stačí klasická nástěnka (nebo Miro), barevné lepící papírky a lidi, kteří doméně rozumí (ne nutně vývojáři, ale třeba lidi z oddělení – účetní, skladník atd.).
Barvy jsou konvence. Držte se jich, ušetří vám hodinu debat:
- Oranžová – doménové události / domain events (věci, které se staly: „Objednávka vytvořena“, „Platba přijata“)
- Modrá – commands (příkazy, které event spustí: „Vytvoř objednávku“, „Zpracuj platbu“)
- Malá žlutá – aktéři (kdo příkaz vydává: zákazník, admin, operátor skladu)
- Fialová – pravidla (ve tvaru „kdykoliv se stane X, udělej Y“: „Když platba selže, zruš objednávku“)
- Velká bledě žlutá – agregáty (to, na čem se command vykonává:
Cart,Shipment) - Zelená – read models (co uživatel vidí, když se rozhoduje: seznam objednávek, stav platby)
- Růžová – externí systémy (platební brána, ERP, dopravce)
- Červená – hotspoty (místa, kde si nikdo není jistý, kde jsou konflikty)
Fáze 1: Big picture (20 minut)
Začněte zleva a jděte doprava v čase. Každý účastník píše doménové události na oranžové papírky a lepí je na tabuli. Žádná diskuze, žádné opravování. Díky tomu zjistíte: „Co se v systému děje?“
Po deseti minutách máte klidně stovku nalepených papírků a chaos. To je ale správně.
Pak začněte řadit. Duplikáty sloučte, events seřaďte chronologicky. Odhalí se první zajímavosti: lidé z různých oddělení pojmenují stejnou věc jinak. Sklad řekne „zásilka expedována“, zákaznická péče řekne „objednávka odeslána“. Tentýž event, dva různé pohledy a to je první signál bounded contextu.
Fáze 2: Process modeling (30 minut)
Teď ke každému eventu přidejte command (modrá), který ho spustil, a aktéra (malá žlutá), který command vydal. K událostem, které spouštějí automatická pravidla, přidejte pravidla (fialová). Kde do hry vstupuje cizí systém (platební brána, ERP, dopravce) přilepte růžovou.
Příklad sekvence:
Zákazník → command „Zaplať objednávku“ → event „Platba přijata“
→ pravidlo „Pokud platba OK, expeduj“ → event „Zásilka připravena k expedici“
Hotspoty (červené) lepte všude, kde skupina diskutuje déle než dvě minuty, kde si odborníci odporují, nebo kde nikdo přesně neví, jak to funguje. Tahle místa jsou nejcennější výstup celého workshopu. Buď odhalují architektonický chaos, nebo byznysovou komplexitu, kterou zatím doteď všichni ignorovali.
Fáze 3: Design-level (30 minut)
Teď identifikujte agregáty (skupiny příkazů a událostí, které přirozeně patří k sobě a dějí se na jedné „věci“). Příkaz „Přidej do košíku“ a event „Položka přidána do košíku“ jsou kandidáti na agregát Cart. Příkazy „Vytvoř zásilku“ a „Přiřaď kurýra“ spolu s eventem „Zásilka expedována“ ukazují na Shipment. Agregáty vám pak naznačí, co patří do jakého bounded contextu.
Ke commandům si zároveň přilepte zelené read modely (to, na co se aktér dívá ve chvíli, kdy se rozhoduje command vydat). Před „Přidej do košíku“ je to detail produktu s dostupností, před „Zaplať objednávku“ rekapitulace košíku. Read modely bývají to, co po workshopu nejdřív potřebuje frontend, a navíc pěkně ukazují, který context si data jenom čte a nevlastní je.
Tady se nezdržujte hledáním dokonalých hranic agregátů, na to je čas až u návrhu, na workshopu stačí hrubý odhad. Jak ty hranice kreslím, jsem rozepsal v článku Agregáty v DDD: Kde nakreslit hranici?.
Fáze 4: Context map (10 minut)
Na závěr pojmenujte bounded contexts, tedy oblasti, kde agregáty a eventy přirozeně tvoří ucelenou doménu se sdíleným jazykem. Nakreslete obrysy a pojmenujte vztahy mezi nimi.
Vztahy mezi kontexty mají svá jména (i ta jsou od Evanse). Nejdůležitější tři:
| Vztah | Kdy se použije | Implikace |
|---|---|---|
| Shared Kernel | Dva kontexty sdílejí malou část modelu (např. identifikátory entit) | Sdílený kód, změny vyžadují dohodu obou stran |
| Customer/Supplier | Jeden kontext dodává data druhému a downstream má slovo při plánování | Upstream bere požadavky downstreamu jako vstup, ideálně jištěné akceptačními testy |
| Anti-corruption Layer | Integrace s legacy systémem nebo třetí stranou | Ochranná vrstva překládá cizí model na váš |
Bacha na jednu věc: Customer/Supplier není totéž co „downstream se prostě přizpůsobí“. Jakmile upstream na požadavky druhé strany kašle a vy se jen vezete s tím, co pošle, je to vzor Conformist a to je jiný vztah s jinými důsledky. Rozdíl je přesně v tom, jestli má downstream „slovo“ (vliv na upstream), nebo ne. (BTW: Mně samotnému trochu dává zabrat si pamatovat, co je downstream a co upstream a pomáhá mi, že „upstream je ten, jehož changelog musím sledovat / downstream je ten, kdo musí sledovat můj“.)
Deset minut je pro tuhle fázi málo a je to tak schválně. Jde o první nástřel, ne o hotovou mapu. Nad vztahy mezi kontexty se dá strávit klidně další hodina (i mnohem víc) a většinou se to i vyplatí, jen ne ve chvíli, kdy jsou po hodině a půl všichni vyšťavení. Vraťte se k tomu třeba druhý den.
Výstup po 90 minutách: vizuální mapa domény, pojmenované bounded contexts, identifikované hranice a vztahy. Není to vždy perfektní, ale je sdílená mezi všemi a to je přesně to, co pro architektonická rozhodnutí je potřeba.
A hlavně to nenechte viset na zdi. Screenshot z Mira za měsíc nikoho nezajímá a za půl roku už si nikdo nevzpomene, proč jsou hranice nakreslené zrovna takhle. Jména kontextů, jejich vztahy a hlavně důvody patří do ADR nebo do README v repozitáři – jinak se celý workshop bude za rok opakovat od nuly. Jak píšu ADR, mám v samostatném článku.
Jak to vypadá v praxi?
Ukážu to na e-shopu, protože ten si představí každý. Řekněme český obchod s vlastní sítí dodavatelů, desetitisíce produktů, menší tým vývojářů. Samotný problém je stejný jako jsem psal v úvodu, jen se projeví jinde – napojit nového dodavatele trvá týdny, protože se to dotkne skoro všeho.
Z workshopu (nějaké tři hodiny, lidi z byznysu i z techniky) obvykle vypadnou čtyři přirozené bounded contexts. Devadesát minut z předchozího textu je minimum na jednu doménu. Na e-shop se čtyřmi kontexty jsou vhodnější spíš tak tři hodiny nebo dvě kratší sezení.
Na nástěnce takového e-shopu visí nějakých šedesát eventů. Kontexty z nich nevypadnou samy a nevypadnou ani z toho, že se na ně budu dívat dost dlouho.
První řez vedu podle mluvčího. Projdu eventy a u každého se ptám, kdo o něm na workshopu mluvil a jakým slovníkem. Ono se to obvykle shlukne skoro samo. Čáry tady neznamenají tok dat, jen ukazují, ke kterým shlukům se sporný event hlásil.
Zjednodušeně:
flowchart TB
subgraph SA["Obchod a marketing"]
S1["Objednávka vytvořena"]:::event
S2["Slevový kód uplatněn"]:::event
S3["Objednávka potvrzena"]:::event
end
subgraph FU["Sklad a logistika"]
F1["Zásilka vyskladněna"]:::event
F2["Kurýr přiřazen"]:::event
F3["Dodávka od dodavatele přijata"]:::event
end
subgraph BI["Účtárna"]
B1["Faktura vystavena"]:::event
B2["Platba spárována"]:::event
B3["Dobropis vystaven"]:::event
end
subgraph CU["Péče o zákazníky a právo"]
C1["Zákazník registrován"]:::event
C2["Souhlas s marketingem odvolán"]:::event
end
H1["Zboží rezervováno"]:::hotspot
H2["Platba přijata"]:::hotspot
H3["Potvrzení odesláno e-mailem"]:::hotspot
H1 -.- SA
H1 -.- FU
H2 -.- SA
H2 -.- BI
H3 -.- SA
H3 -.- CU
SA ~~~ FU ~~~ BI ~~~ CU
linkStyle default stroke:#94A3B8,stroke-width:1.5px
classDef event fill:#FFB300,stroke:#B37700,color:#1A1A1A
classDef hotspot fill:#E53935,stroke:#A32320,color:#FFFFFF
Čtyři shluky a k tomu tři červené eventy, které visí mezi nimi a nikdo neví, kam patří. To je v pořádku, ty jsou vlastně nejzajímavější – k nim se vrátím za chvíli.
Pak řez ověřuju. To, že hranice mezi dvěma shluky je reálná, poznávám podle těchhle signálů:
- Tentýž pojem znamená něco jiného. „Objednávka“ jako seznam zaplaceného zboží, jako seznam položek k vyskladnění a jako daňový doklad. Nejsilnější signál, jaký na workshopu dostanete.
- Mění se mluvčí a s ním slovník. Sklad říká „zásilka expedována“, péče o zákazníky „objednávka odeslána“.
- Jiný životní cyklus. Objednávka končí doručením, faktura žije dál kvůli reklamacím, storno a archivaci na deset let.
- Data, která druhá strana nepotřebuje. Sklad nikdy nepotřeboval ceny, účtárna nikdy nepotřebovala pozici v regálu.
- Na hraně leží shluk červených hotspotů. Kde se skupina hádala nejdéle, tam většinou vede hranice, kterou zatím nikdo nepojmenoval.
Proč zrovna čtyři? Protože v tomhle e-shopu jsou čtyři skupiny lidí, které mluví jinak a vlastní jinou část procesu. Čtyřka není cíl, ke kterému se propracovávám, ale vyšla z toho, kolik různých „jazyků“ na té zdi reálně je. Kontrolu na to mám jednoduchou: u každého kontextu musím umět jmenovat konkrétního člověka, který je za tu oblast v byznysu zodpovědný. Když ho nejmenuju, není to kontext, ale jen skupina tabulek, která se mi líbí. E-shop s B2B i B2C jich má klidně sedm, interní nástroj na evidenci docházky jeden. Ale bacha na opačný extrém: když vám z e-shopu vypadne dvanáct kontextů, řezali jste podle entit, ne podle jazyka.
Co se pátým kontextem nestalo. Teď k těm třem červeným a k jednomu kandidátovi navrch. Zamítnuté varianty stojí za zápis do ADR, protože přesně na ně se za rok někdo zeptá:
- Skladové zásoby („Zboží rezervováno“). Nejnapjatější hranice z celého workshopu. Sales potřebuje dostupnost, aby ji ukázal zákazníkovi, ale vlastní ji sklad. Nechal jsem ji ve Fulfillmentu a Sales si ji čte jako read model. Jakmile přibude víc skladů, dropshipping a rezervace s vlastní expirací a pravidly, Inventory se odštěpí jako pátý kontext a bude to pak správně.
- Platby („Platba přijata“). Jako pátý context je navrhne skoro každý tým. Jenže jakmile se skupina začala hádat, vyšlo najevo, že „platba“ znamená dvě různé věci: pro obchod okamžik, kdy může objednávku potvrdit a pustit do skladu, pro účtárnu spárování příchozí částky s konkrétní fakturou. Ten jeden červený lísteček se rozpadl na dva eventy ve dvou kontextech – „Platba přijata“ v Sales a „Platba spárována“ v Billingu a na samostatný kontext nezbylo nic. Platební brána je pak externí systém za anti-corruption layerem, ne další kontext.
- Notifikace („Potvrzení odesláno e-mailem“). Nemají vlastní jazyk ani vlastní invarianty, jen rozesílají to, co se stalo někde jinde. To není doména, to je schopnost – odběratel eventů, který si žije technicky vedle. Do Customeru patří jen preference komunikace a souhlasy, tedy jestli se ten e-mail vůbec smí poslat.
- Katalog produktů. Ten na zdi jako sporný lísteček ani nevisel, ale stejně se o něm hádáme skoro pokaždé. V tomhle e-shopu mluví přesně stejným jazykem jako obchod (produkt, cena, dostupnost), takže zůstal v Sales. Odštěpí se ve chvíli, kdy přibude PIM a lidi, co se produktovými daty zabývají na plný úvazek. Ti pak mluví většinou o atributech, variantách a lokalizacích, což už je jiný slovník.
Poslední kontrola, kterou dělám vždycky: za každý kontext dám dohromady větu v jeho vlastním jazyce (u Sales o zákazníkovi, produktu a slevovém kódu kousek níž) a přečtu ji nahlas. Když u ní někdo z jiného oddělení řekne „aha, tak tomu my říkáme jinak“, hranice vede špatně a jsem zpátky u prvního řezu.
Sales – svět zákazníka
Co sem patří: katalog produktů tak, jak ho vidí zákazník, košík, objednávka z pohledu zákazníka, slevové akce, věrnostní program.
Klíčový agregát: Sales\Domain\Order. Drží položky s cenami, slevový kód, adresu doručení a stav objednávky z pohledu zákazníka (pending, confirmed, delivered). Zákazníka nedrží jako objekt, jen jeho CustomerId – jinak by si Sales přitáhl půlku Customer contextu a hranice by byla k ničemu.
Ubiquitous language: „zákazník objedná produkt za cenu s DPH, aplikuje slevový kód, dostane potvrzení.“
Fulfillment – svět skladu a logistiky
Co sem patří: fyzické zpracování zásilky, skladové zásoby a jejich rezervace, komunikace s dodavateli, přidělení kurýra, sledování zásilky.
Klíčový agregát: Fulfillment\Domain\Shipment. Drží seznam položek k vyskladnění (bez cen – ceny nejsou starost skladu), hmotnost, pozice v regálech, kurýra, tracking číslo a stav zásilky (picking, packed, dispatched, delivered).
Všimněte si: Fulfillment\Shipment neví nic o cenách, slevách ani zákazníkovi jako takovém – zná jen adresu doručení. To je záměr.
Billing – svět účetnictví
Fakturace, DPH, párování plateb, storna. Agregát Billing\Domain\Invoice drží číslo faktury, DIČ zákazníka, položky se sazbami DPH, splatnost a stav platby. Pro Billing je „objednávka“ daňový doklad s vlastními invarianty – číslo faktury musí být unikátní a sekvenční, DPH musí sedět, storno musí mít vlastní dobropis.
Customer – svět vztahu se zákazníkem
Registrace, přihlášení, profil, GDPR souhlasy. Agregát Customer\Domain\Customer drží e-mail, jméno, preference komunikace a souhlasy – o objednávkách neví nic. Historie kontaktu do něj nepatří, je to neohraničeně rostoucí kolekce, kterou by musel načítat každý, kdo si sáhne pro e-mail. Žije tedy vedle jako read model. Sales téhož člověka zná jen jako CustomerId. Různé projekce téže osoby a ani jedna z nich není ta „správná“.
Context mapa těch čtyř kontextů
Když se to nakreslí, vypadá výsledek fáze 4 nějak takhle. Šipka ukazuje směr závislosti – vede od downstreamu k tomu, na kom závisí, tedy k upstreamu. Typ čáry říká, jak ta závislost vypadá: plná je domain event, přerušovaná odkaz přes identifikátor, tlustá integrace přes anti-corruption layer.
flowchart LR
F[Fulfillment] -->|čte OrderPlaced| S[Sales]
B[Billing] -->|čte OrderConfirmed| S
S -->|čte StockChanged| F
S -.->|drží CustomerId| C[Customer]
B -.->|drží CustomerId| C
F ==>|překládá přes ACL| E[Systém dodavatele]
Sales je upstream pro Fulfillment i Billing – oba se dozvědí, že se něco stalo, ale ani jeden Sales nesahá do modelu. Customer je zase upstream pro Sales i Billing, protože vlastní identitu zákazníka, a oba si od něj berou jen CustomerId. Směrem k dodavateli sedí anti-corruption layer, protože jeho model si do domény pouštět nechceme. A je tu vidět i ta bolest z úvodu: napojení dalšího dodavatele se odehraje na jediné hraně grafu.
Mezi Sales a Fulfillment vede šipka na obě strany a není to chyba. Fulfillment se potřebuje dozvědět, že padla objednávka, a Sales potřebuje vědět, kolik je toho na skladě, aby to ukázal zákazníkovi – to je ta sporná hranice ze začátku kapitoly. Vzájemná závislost je v pořádku, dokud jde oběma směry přes eventy: dostupnost si Sales z StockChanged skládá do vlastního read modelu a nevlastní ji. Bacha ale na to, že tohle je jediná obousměrná hrana v grafu, takže je to první místo, kde se hranice začne rozmazávat – a taky přesně to místo, kde jednou vznikne pátý context.
Struktura složek v Symfony
src/
├── Sales/
│ ├── Domain/
│ │ ├── Order.php // agregát
│ │ ├── OrderItem.php
│ │ ├── CustomerId.php // jen odkaz do Customer contextu
│ │ ├── OrderRepository.php // interface
│ │ └── Event/
│ │ ├── OrderPlaced.php // domain event
│ │ └── OrderConfirmed.php
│ ├── Application/
│ │ ├── PlaceOrderCommand.php
│ │ ├── PlaceOrderHandler.php
│ │ ├── GetOrderQuery.php
│ │ └── StockChangedHandler.php // projekce dostupnosti do read modelu
│ └── Infrastructure/
│ └── DoctrineOrderRepository.php
│
├── Fulfillment/
│ ├── Domain/
│ │ ├── Shipment.php // jiný agregát, jiný model
│ │ ├── ShipmentItem.php
│ │ ├── Carrier.php
│ │ ├── ShipmentRepository.php
│ │ └── Event/
│ │ └── StockChanged.php // dostupnost ven, sklad ji vlastní
│ ├── Application/
│ │ ├── CreateShipmentCommand.php
│ │ ├── CreateShipmentHandler.php
│ │ └── OrderPlacedHandler.php // vezme event ze Sales, pošle command
│ └── Infrastructure/
│ └── DoctrineShipmentRepository.php
│
├── Billing/
│ ├── Domain/
│ │ ├── Invoice.php
│ │ ├── InvoiceLineItem.php
│ │ └── InvoiceRepository.php
│ ├── Application/
│ │ ├── GenerateInvoiceCommand.php
│ │ ├── GenerateInvoiceHandler.php
│ │ └── OrderConfirmedHandler.php
│ └── Infrastructure/
│ └── DoctrineInvoiceRepository.php
│
└── Customer/
├── Domain/
│ ├── Customer.php
│ └── CustomerRepository.php
├── Application/
│ ├── RegisterCustomerCommand.php
│ └── RegisterCustomerHandler.php
└── Infrastructure/
└── DoctrineCustomerRepository.php
Bundly na to nepotřebujete. Symfony v Best Practices přímo doporučuje vlastní aplikační logiku do bundlů nebalit a strukturovat ji jmennými prostory pod App\ – bundle si nechte na to, co budete opravdu sdílet mezi projekty. Stačí tedy složka na context a jedno pravidlo: kód ze Sales/ nesmí přímo importovat třídy z Fulfillment/ nebo Billing/. Komunikace jde výhradně přes domain events nebo dedikovaná rozhraní, nikdy přímým voláním repozitáře jiného kontextu.
A protože na tohle pravidlo se za půl roku zapomene, nechte si ho hlídat strojem. Deptrac umí zakázané závislosti mezi contexty detekovat a shodit build. Bez něčeho takového vám hranice vydrží tak do třetího spěchajícího hotfixu, kdy někdo „jen rychle“ sáhne z jednoho kontextu do druhého.
Mezi Sales a Fulfillment to pak vypadá takhle. Sales uloží objednávku a vypustí domain event, který nese jen primitivní hodnoty – žádnou třídu ze svého modelu:
// src/Sales/Domain/Event/OrderPlaced.php
namespace App\Sales\Domain\Event;
final readonly class OrderPlaced
{
public function __construct(
public string $orderId,
public string $customerId,
public string $deliveryAddress, // schválně string, ne Sales\Domain\Address
public array $items, // jen to, co sklad potřebuje: sku + množství
public \DateTimeImmutable $placedAt,
) {}
}
Ve Fulfillmentu na event poslouchá vlastní handler. Pozor, jde o Messenger, ne o EventDispatcher, takže #[AsMessageHandler], ne #[AsEventListener]. Nesahá Sales ani do modelu, ani do repozitáře, jen si z dat eventu složí svůj vlastní command:
// src/Fulfillment/Application/OrderPlacedHandler.php
namespace App\Fulfillment\Application;
use App\Sales\Domain\Event\OrderPlaced;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
final readonly class OrderPlacedHandler
{
public function __construct(private MessageBusInterface $bus) {}
public function __invoke(OrderPlaced $event): void
{
$this->bus->dispatch(new CreateShipmentCommand(
orderId: $event->orderId,
deliveryAddress: $event->deliveryAddress,
items: $event->items,
));
}
}
Jediné, co Fulfillment ze Sales importuje, je třída eventu. To je záměrně ta nejtenčí možná vazba – kontrakt mezi kontexty, ne sdílený model. CreateShipmentHandler si pak z těch dat postaví Shipment po svém.
U Customeru je to jinak: CustomerId v Sales je vlastní typ Sales, ne třída importovaná z Customeru. Ta přerušovaná hrana na mapě je konceptuální závislost, v kódu žádná není a Deptrac tam nic nenajde. Je to v pořádku – hranice kontextu duplikaci typu pro ID snese, výměnou za to, že se kontexty dají hýbat nezávisle.
Co se tím získá? Napojení nového dodavatele se smrskne na práci uvnitř Fulfillmentu a dělá ji jeden člověk – ten, který tomu kontextu rozumí. Sales ani Billing číst nemusí. V tom je celý ten rozdíl.
Ještě poznámka k pojmenování, ta je vlastně důležitější, než se na první pohled zdá. Každý kontext pojmenovává stejnou realitu jinak. Sales řeší toho, kdo kupuje a kam se to má doručit. Customer se stará o vztah se zákazníkem. Billing vidí daňový subjekt s DIČ. Kdybyste to všechno nacpali do jedné třídy User, skončíte přesně u té entity z úvodu. Ubiquitous language tady dělá docela praktickou službu – fyzicky brání tomu, aby do jedné třídy přibývala nesouvisející pole.
Co s transakcemi a daty přes hranice?
Tohle je první otázka, kterou dostanu pokaždé, když někdo tuhle strukturu zkusí. A je oprávněná, protože přesně tady se DDD v PHP nejčastěji láme.
Kdy se event vypustí. Nejjednodušší start je synchronně a v jedné transakci: Sales uloží objednávku, handler ve Fulfillmentu se zpracuje v témže procesu a buď se commitne objednávka i zásilka, nebo nic. V Symfony to zařídí doctrine_transaction middleware:
# config/packages/messenger.yaml
framework:
messenger:
buses:
messenger.bus.default:
middleware:
- doctrine_transaction
Jakmile ale event pošlete přes async transport, tahle jistota končí. Zápis do databáze a odeslání zprávy do fronty nejsou jedna atomická operace. Když to mezi nimi spadne, objednávka existuje a zásilka nikdy nevznikne. Řeší se to outboxem: událost se uloží do tabulky ve stejné transakci jako objednávka a odešle ji až samostatný proces. Než na tohle dojde, držte se synchronní varianty. Je poctivější než async bez outboxu.
Duplicitní data jsou v pořádku. Ve stromu výš si toho možná někdo všiml: adresu doručení drží Sales\Order i Fulfillment\Shipment. Není to přehlédnutí. Fulfillment si adresu zkopíroval ve chvíli, kdy objednávka vznikla, a od té chvíle je to jeho adresa. Když si ji zákazník příští měsíc změní v profilu, už expedovaná zásilka se měnit nemá. Kopie dat v okamžiku události není totéž co sdílený zdroj pravdy a tenhle rozdíl je vlastně to hlavní, co odděluje pochopené DDD od překopírované struktury složek.
Cizí klíče přes hranice ne. Jedno databázové schéma pro monolit úplně stačí, dělit ho nemusíte. Ale Shipment nemá mít FK na Order. Vím, že to pro většinu PHP vývojářů zní jako kacířství (i mně samotnému chvíli trvalo si na to zvyknout). Jenže ve chvíli, kdy tam ten klíč dáte, jste hranici zrušili: databáze začne vynucovat vazbu, kterou má hlídat doména, a jakékoli pozdější rozdělení kontextů do vlastních schémat je najednou migrace přes celou aplikaci. Uvnitř kontextu cizí klíče používejte normálně, přes hranici jen CustomerId nebo OrderId jako obyčejný sloupec.
Jak se chránit před cizím modelem?
Hranice uvnitř aplikace jsou půlka práce. Druhá půlka jsou hranice směrem ven (k ERP, ke státním systémům, k API třetích stran). Tam patří anti-corruption layer: překladová vrstva, která cizí model převede na váš, na jednom místě a s doménovou terminologií jako výstupem.
Bez ní se cizí pojmy, struktury a chybové kódy postupně protlačí do vašeho kódu, až váš doménový model odráží omezení cizího ERP místo byznysové reality. V context mapě z fáze 4 je to ten třetí vztah – a protože je to téma na samostatný text, napsal jsem o něm celý článek s konkrétní implementací v PHP.
Kdy bounded contexts nepotřebujete?
DDD a bounded contexts přidávají komplexitu – víc tříd, víc abstrakce, víc struktury. To se vyplatí jen tehdy, když je složitá sama doména. A to zdaleka není vždycky.
Nechte to být u malých CRUD aplikací (administrační rozhraní, interní nástroje, dashboardy…). Pokud byznysová logika spočívá v „ukládej a zobrazuj data“, použijte Doctrine entity přímo a agregáty nepřidávejte. Totéž platí pro věci s předem daným koncem, ať už jde o MVP, nebo o jednorázovou migraci; investice do doménového modelování se vrátí jen tam, kde se systém roky udržuje. A pokud polovina týmu neví, co je agregát, vznikne hybridní kód, kde někdo pravidla dodržuje a někdo ne – to je horší než žádný model.
Jednoduchá heuristika:
| Signál | Co to znamená |
|---|---|
| Doménový expert mluví o věcech jinak než vývojář | Bounded contexts pravděpodobně pomohou |
| Změna jedné „věci“ rozbije jinou nesouvisející část | Hranice chybí, DDD pomůže |
| Entita má víc než 30 polí a pořád roste | Jeden model dělá práci více kontextů |
| Nový vývojář neví, kam přidat kód | Chybí explicitní struktura |
| Vše je CRUD, doménová logika je minimální | DDD je přestřelené |
| Projekt trvá méně než rok a nepočítáte s rozvojem | DDD se nevyplatí |
Pragmatický přístup: začněte bez bounded contexts a mapujte doménu, až začnete narážet na problémy z levého sloupce. Retroaktivní zavedení je pracnější, ale jde to a pozdě je pořád lepší než zbytečně brzo.
Shrnutí
Bounded contexts řeší jednu konkrétní bolest: aplikaci, kde každá změna táhne za sebou řetěz side efektů, protože nikdo nikdy nerozhodl, kde jedna věc končí a druhá začíná.
Event storming vám za 90 minut dá sdílenou mapu domény. Na ní najdete přirozené hranice, pojmenujete je a implementujete jako oddělené komponenty s vlastními modely, vlastním jazykem a explicitními rozhraními. Anti-corruption layer pak ohlídá, aby vám do domény nezatekly kompromisy cizích systémů.
Co si z toho odnést: bounded context je vědomé rozhodnutí o tom, kde platí jiné pojmy a jiná pravidla. Složka v src/ je až jeho důsledek. Stejné hranice jsou pak základem pro rozhodnutí, jestli zůstat u monolitu, nebo dělit na služby – o tom jsem psal v článku Monolit nebo mikroslužby v PHP.
Mimochodem, ten kód z úvodu roky fungoval a vydělával peníze. Refaktoring na bounded contexts má přijít na řadu, až když byznysová bolest převáží (zkrátka se to musí vyplatit s ohledem na vývoj a udržitelný kód). Ne dřív.
Zdroje
- Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software (Addison-Wesley, 2003)
- Alberto Brandolini – Introducing EventStorming a eventstorming.com
- Průvodce DDD v Symfony – můj delší materiál o tom, jak DDD stavím v PHP
