DDD v praxi - kde to bolí
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 - míst, kde se architektonické principy střetávají s realitou frameworku, databáze, asynchronní infrastruktury nebo týmové dynamiky.
Tato kapitola je katalog 20 reálných problémů, se kterými se setkávají týmy implementující DDD v PHP a Symfony. 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.
A - Doctrine vs. doménový model
Doctrine ORM je mocný nástroj, ale jeho interní model (Unit of Work, Identity Map, lazy loading) byl navržen pro jednoduchý CRUD. Bohaté doménové modely s ním přicházejí do konfliktu na šesti místech.
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. To je výkonné pro CRUD, ale 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 skutečně vyžaduje změnu dvou agregátů atomicky (a nemůžete použít
Outbox + Saga), zavolejte explicitně
beginTransaction() / commit() v Application Service
a oba repozitáře volejte v rámci téže transakce. 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.
PHP: Application Service jako transakční hranice
<?php
declare(strict_types=1);
namespace App\Warehouse\Application\Service;
use App\Ordering\Domain\Repository\OrderRepository;
use App\Warehouse\Domain\Repository\ReservationRepository;
use Doctrine\ORM\EntityManagerInterface;
final class ConfirmTransferService
{
public function __construct(
private readonly OrderRepository $orders,
private readonly ReservationRepository $reservations,
private readonly EntityManagerInterface $em,
) {}
public function execute(ConfirmTransferCommand $command): void
{
$this->em->beginTransaction();
try {
$order = $this->orders->get($command->orderId);
$reservation = $this->reservations->get($command->reservationId);
$order->markAsTransferred();
$reservation->confirmFor($order->id());
$this->orders->save($order);
$this->reservations->save($reservation);
$this->em->commit();
} catch (\Throwable $e) {
$this->em->rollback();
throw $e;
}
}
}
Pokud oba agregáty nesdílejí databázi (nebo jsou v různých Bounded Contexts), použijte místo transakce Outbox pattern nebo Sagu. Atomická cross-context transakce je architektonický zápach.
A3. Mapping složitých Value Objects
Problém: Doctrine #[Embedded] funguje dobře pro jednoduché
VO (jméno + příjmení → dva sloupce). Narazíte ale na jeho limity, jakmile potřebujete:
polymorfní VO (různé typy cen), nullable VO v kolekcích, VO s vlastní serializační logikou
(Money = integer + string), nebo VO, které se mapují na jiný datový typ než default
(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.
PHP: Custom Type pro Money Value Object
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Doctrine\Type;
use App\SharedKernel\Domain\ValueObject\Money;
use App\SharedKernel\Domain\ValueObject\Currency;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;
final class MoneyType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
return 'VARCHAR(50)'; // formát: "12345_CZK"
}
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Money
{
if ($value === null) {
return null;
}
[$amount, $currencyCode] = explode('_', (string) $value, 2);
return new Money((int) $amount, new Currency($currencyCode));
}
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string
{
if ($value === null) {
return null;
}
/** @var Money $value */
return $value->amountInCents() . '_' . $value->currency()->code();
}
}
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;
Pro polymorfní VO (různé typy platby: karta, hotovost, voucher) zvažte místo dědičnosti Value Object s diskriminátorem: uložte typ jako enum do jednoho sloupce a detaily jako JSON do druhého. Tím se vyhnete discriminator map, která je těžkopádná pro VO.
A4. Lazy loading vs. bohaté agregáty
Problém: Doctrine defaultně načítá asociace lazy - místo skutečného
objektu vloží do property proxy třídu, která se inicializuje až při prvním přístupu.
Bohaté agregáty (metody jako totalPrice(), items())
mohou neúmyslně triggerovat lazy load mimo otevřenou transakci nebo
po detach(), což vyústí ve výjimku
UninitializedLazyObjectException (PHP 8.4 lazy objects) nebo
ORMInvalidArgumentException v starších verzích Doctrine ORM.
Příčina: Lazy proxy je infrastrukturní koncept - doménový model
o ní neví a nesmí vědět. Bohužel, pokud Doctrine vloží proxy na místo
OrderItems, doménová metoda $order->items()
v sobě implicitně spoléhá na aktivní databázové připojení.
Ř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). To znamená, že nově vytvořený
agregát nemá ID, dokud není persistován a flushed - porušuje to doménový invariant,
že každý agregát musí mít identitu od okamžiku vzniku.
Příčina: Databázové generování ID je výkonné, 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ží.
PHP: UUID v konstruktoru agregátu (PHP 8.4 + Symfony Uid)
<?php
declare(strict_types=1);
namespace App\Ordering\Domain\ValueObject;
use Symfony\Component\Uid\Uuid;
final class OrderId
{
private function __construct(private readonly string $value) {}
public static function generate(): self
{
return new self((string) Uuid::v7()); // UUIDv7 - time-sortable
}
public static function fromString(string $value): self
{
if (!Uuid::isValid($value)) {
throw new \InvalidArgumentException("Invalid OrderId: {$value}");
}
return new self($value);
}
public function toString(): string
{
return $this->value;
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
}
// V agregátu:
final class Order
{
private OrderId $id;
public function __construct(CustomerId $customerId)
{
$this->id = OrderId::generate(); // identita vzniká v doméně
// ...
}
}
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ářů doporučujeme 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.
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.
PHP: OutboxEvent entita a OutboxPublisher service
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Outbox;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
#[ORM\Table(name: 'outbox_events')]
final class OutboxEvent
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 255)]
private string $eventType;
#[ORM\Column(type: 'json')]
private array $payload;
#[ORM\Column]
private \DateTimeImmutable $createdAt;
#[ORM\Column(nullable: true)]
private ?\DateTimeImmutable $publishedAt = null;
public function __construct(string $eventType, array $payload)
{
$this->eventType = $eventType;
$this->payload = $payload;
$this->createdAt = new \DateTimeImmutable();
}
public function markAsPublished(): void
{
$this->publishedAt = new \DateTimeImmutable();
}
public function isPublished(): bool { return $this->publishedAt !== null; }
public function eventType(): string { return $this->eventType; }
public function payload(): array { return $this->payload; }
}
Klíčový 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
}
}
Symfony Messenger v Symfony 8+ nabízí vlastní Doctrine Transport, který ukládá zprávy do databáze a garantuje at-least-once doručení bez nutnosti vlastního Outbox kódu. Zvažte jeho použití jako alternativu před implementací vlastního Outbox patternu.
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:
-
Zkontrolujte failed transport:
Pokud je zpráva zde, zobrazí se s chybou. Znovu ji zpracujte:php bin/console messenger:failed:showphp bin/console messenger:failed:retry -
Zapněte verbose logging: V
config/packages/monolog.yamlpřidejte handler promessengerchannel na úroveňdebug. Každý dispatch, receive a zpracování se zaloguje. - 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.
PHP: Middleware pro Correlation ID logging
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Messenger;
use Psr\Log\LoggerInterface;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Uid\Uuid;
// Vlastní Stamp - musí implementovat StampInterface
final class CorrelationIdStamp implements \Symfony\Component\Messenger\Stamp\StampInterface
{
public function __construct(public readonly string $correlationId) {}
}
final class CorrelationIdMiddleware implements MiddlewareInterface
{
public function __construct(private readonly LoggerInterface $logger) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(CorrelationIdStamp::class)
?? new CorrelationIdStamp((string) Uuid::v7());
$this->logger->info('Messenger: processing message', [
'correlation_id' => $stamp->correlationId,
'message_class' => $envelope->getMessage()::class,
]);
return $stack->next()->handle(
$envelope->with($stamp),
$stack,
);
}
}
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čí.
PHP: IdempotencyMiddleware
<?php
declare(strict_types=1);
namespace App\SharedKernel\Infrastructure\Messenger;
use Doctrine\DBAL\Connection;
use Symfony\Component\Messenger\Envelope;
use Symfony\Component\Messenger\Middleware\MiddlewareInterface;
use Symfony\Component\Messenger\Middleware\StackInterface;
use Symfony\Component\Messenger\Stamp\StampInterface;
// Vlastní Stamp nesoucí idempotency klíč
final class IdempotencyStamp implements StampInterface
{
public function __construct(public readonly string $key) {}
}
final class IdempotencyMiddleware implements MiddlewareInterface
{
public function __construct(private readonly Connection $connection) {}
public function handle(Envelope $envelope, StackInterface $stack): Envelope
{
$stamp = $envelope->last(IdempotencyStamp::class);
if ($stamp === null) {
return $stack->next()->handle($envelope, $stack); // zpráva bez klíče: vždy zpracuj
}
$alreadyProcessed = (bool) $this->connection->fetchOne(
'SELECT 1 FROM processed_messages WHERE idempotency_key = ?',
[$stamp->key],
);
if ($alreadyProcessed) {
return $envelope; // duplikát - přeskočit bez zpracování
}
$result = $stack->next()->handle($envelope, $stack);
$this->connection->insert('processed_messages', [
'idempotency_key' => $stamp->key,
'processed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
return $result;
}
}
Tabulka processed_messages poroste bez omezení. Přidejte
pravidelný cleanup (cron) nebo TTL index pro automatické mazání
starých záznamů. Obvyklá retence je 7–30 dní - doba, po které broker
přestane doručovat retries.
TOCTOU race condition: Kód výše obsahuje závodní podmínku - dvě paralelní instance workeru mohou obě vidět, že záznam neexistuje a obě zprávu zpracovat. Navíc pořadí SELECT + zpracování + INSERT znamená, že pokud handler vyhodí výjimku, klíč se nezapíše a zpráva se zkusí znovu - což je správné chování, ale odhaluje jiný problém: pokud INSERT provedeme před zpracováním, selhání handleru zanechá klíč zapsaný a zpráva nebude nikdy zopakována (ztracená zpráva).
Bezpečné řešení: proveďte zpracování a INSERT do deduplikační tabulky v téže databázové transakci. Při selhání handleru transakce selže celá (klíč se nevloží) a Messenger zprávu zopakuje:
$this->connection->beginTransaction();
try {
// Unique constraint na idempotency_key zabrání duplicitě na DB úrovni
$this->connection->insert('processed_messages', [
'idempotency_key' => $stamp->key,
'processed_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'),
]);
$result = $stack->next()->handle($envelope, $stack);
$this->connection->commit();
return $result;
} catch (\Doctrine\DBAL\Exception\UniqueConstraintViolationException) {
$this->connection->rollBack();
return $envelope; // duplicitní zpráva - přeskočit
} catch (\Throwable $e) {
$this->connection->rollBack(); // handler selhal - klíč se nezapíše, Messenger zopakuje
throw $e;
}
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, ale objednávka ještě
neexistuje (nebo je ve špatném stavu).
Řešení - tři přístupy podle kontextu:
| Přístup | Kdy použít | Trade-off |
|---|---|---|
| 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 |
Pozor: Pro ordering problémy nepoužívejte
UnrecoverableMessageHandlingException - ta zprávu
přeskočí retry strategii a zprávu okamžitě přesune do failed transport.
Správný přístup je hodit standardní výjimku; Messenger zprávu
odloží do retry fronty s exponential backoff. Pokud po vyčerpání všech retries
stále selhává, teprve pak skončí v failed transport - kde ji lze prozkoumat
a znovu odeslat.
C - Modelování
Správné doménové modelování je obtížnější než implementace - vyžaduje disciplínu v rozhodnutích, která se zdají triviální, dokud nezpůsobí problém.
C1. Kde žije validace
Problém: Validace je rozeseta na třech místech: Symfony Validator (anotace na DTO), Application Service (business 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á |
| Business pravidlo | 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 |
Klíčové pravidlo: Doménový invariant vždy vynucujte v doméně - nikdy nespoléhejte na validaci ve vyšší vrstvě, protože 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
Problém: Objednávka prochází stavy: Draft → Placed → Paid →
Shipped → Delivered → Cancelled. Anémický přístup: $order->setStatus('shipped')
- stav se změní bez guard conditions, bez side effectů, bez kontroly, zda přechod
dává smysl.
Řešení: Explicitní metody pro každý přechod. Metoda ověřuje, zda je přechod validní (guard condition), provede změnu stavu a zaregistruje doménovou událost.
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));
}
}
Symfony Workflow může spravovat přechody stavů - ale jako
infrastrukturní helper, nikoli jako součást doménového modelu.
Doménový objekt nesmí záviset na WorkflowInterface. Voter / Controller
může použít Workflow pro UI logiku; doménová metoda ověřuje invariant sama.
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í.
PHP: Port v doméně + Adapter v infrastruktuře
<?php
// Port - v doméně (App\Payment\Domain\Port)
interface PaymentGateway
{
/** @throws PaymentFailedException */
public function charge(Money $amount, PaymentToken $token): PaymentId;
/** @throws RefundFailedException */
public function refund(PaymentId $id, Money $amount): void;
}
// Adapter - v infrastruktuře (App\Payment\Infrastructure\Stripe)
final class StripePaymentGateway implements PaymentGateway
{
public function __construct(private readonly \Stripe\StripeClient $stripe) {}
public function charge(Money $amount, PaymentToken $token): PaymentId
{
try {
$charge = $this->stripe->charges->create([
'amount' => $amount->amountInCents(),
'currency' => strtolower($amount->currency()->code()),
'source' => $token->value(),
]);
return PaymentId::fromString($charge->id);
} catch (\Stripe\Exception\CardException $e) {
throw new PaymentFailedException($e->getMessage(), previous: $e);
}
}
public function refund(PaymentId $id, Money $amount): void
{
$this->stripe->refunds->create([
'charge' => $id->toString(),
'amount' => $amount->amountInCents(),
]);
}
}
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é
business 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 není statický artefakt - vyvíjí se s pochopením domény. 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.
-
Living documentation 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í.
D - Symfony-specifické třenice
Symfony je mocný framework, ale některé jeho konvence jsou navrženy pro CRUD aplikace. Tato sekce popisuje tři místa, kde framework-first přístup koliduje s DDD.
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, framework-friendly
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.
PHP: StateProcessor jako adapter k Application Service
<?php
declare(strict_types=1);
namespace App\Ordering\Infrastructure\ApiPlatform;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\Metadata\Post;
use ApiPlatform\State\ProcessorInterface;
use App\Ordering\Application\Command\PlaceOrderCommand;
use Symfony\Component\Messenger\MessageBusInterface;
// API resource DTO - nikdy agregát
#[ApiResource(operations: [new Post(processor: PlaceOrderProcessor::class)])]
final class OrderResource
{
public string $customerId;
public array $items;
// Pouze to, co API má vidět
}
// StateProcessor - tenká vrstva
final class PlaceOrderProcessor implements ProcessorInterface
{
public function __construct(private readonly MessageBusInterface $commandBus) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): OrderResponse
{
/** @var OrderResource $data */
// ID generujeme před dispatchem - dispatch() vrací Envelope, ne doménový objekt
$orderId = OrderId::generate();
$command = new PlaceOrderCommand(
orderId: $orderId,
customerId: CustomerId::fromString($data->customerId),
items: $data->items,
);
$this->commandBus->dispatch($command);
return new OrderResponse($orderId->toString());
}
}
D3. Security Voter vs. doménová oprávnění
Problém: Business pravidla přístupu jsou součástí domény - napří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.
PHP: Voter jako tenký adaptér + doménová metoda
<?php
declare(strict_types=1);
// Doménová metoda v agregátu - testovatelná bez frameworku
// Aktuální čas je parametr (ne wall-clock) - metoda je deterministická a snadno testovatelná
final class Order
{
public function canBeCancelledBy(UserId $userId, \DateTimeImmutable $now): bool
{
if ($this->status === OrderStatus::Shipped || $this->status === OrderStatus::Delivered) {
return false;
}
$withinWindow = $this->placedAt > $now->modify('-24 hours');
return $withinWindow && $this->customerId->equals($userId);
}
}
// Voter - pouze adaptér, žádná business logika
final class OrderVoter extends Voter
{
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === 'ORDER_CANCEL' && $subject instanceof Order;
}
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
/** @var Order $subject */
return $subject->canBeCancelledBy(UserId::fromString($user->getId()), new \DateTimeImmutable());
}
}
E - Organizace a tým
DDD selže ne proto, že by byl technicky špatný - ale proto, že tým ho nepochopil, management ho nepodpořil nebo znalosti zůstaly u jednoho člověka.
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 business 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 side effectů |
Taktika: Nezačínejte argumentem „naše kód je špatný." Začněte konkrétní business 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 - téměř vždy selže: trvá déle než odhadnuto, tým ztrácí motivaci, business 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ší business 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.
Strangler fig neznamená, že legacy kód a DDD kód sdílejí databázové tabulky. Nový modul má vlastní tabulky; data z legacy se migrují postupně, případně se synchronizují přes events nebo cron job.
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 business 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.