Anti-vzory a typické chyby v DDD
Úvodem: Proč je důležité znát anti-vzory
Domain-Driven Design je mocný přístup k návrhu softwaru, ale jeho komplexnost s sebou přináší mnohá úskalí. Zkušenosti z praxe ukazují, že týmy, které s DDD začínají, opakovaně narážejí na stejné chyby - a to i přesto, že dobře rozumí teorii. Znát anti-vzory je proto stejně důležité jako znát vzory samotné.
Anti-vzor je přístup, který vypadá správně – nebo k němu vývojáři přirozeně sklouznou – ale narušuje principy DDD a způsobuje dlouhodobé problémy s udržovatelností, testovatelností a výkonem.
Klasifikace typických chyb v DDD
Chyby při implementaci DDD lze rozdělit do tří kategorií:
- Strategické chyby - špatně definované Bounded Contexts, ignorování Ubiquitous Language, sdílená databáze napříč kontexty. Tyto chyby mají nejzávažnější dopad, protože ovlivňují celkovou architekturu systému.
- Taktické chyby - anémický doménový model, příliš velké agregáty, Primitive Obsession. Tyto chyby se projevují na úrovni doménového modelu a narušují objektově orientované principy.
- Implementační chyby - doménová logika v infrastrukturní vrstvě, mutovatelné události, over-engineering. Tyto chyby vznikají při konkrétní implementaci a jsou obvykle nejsnáze opravitelné.
Anti-vzor: Anémický doménový model (Anemic Domain Model)
Anémický doménový model je pravděpodobně nejrozšířenějším anti-vzorem v objektově orientovaném vývoji obecně, a v DDD zvláště. Termín popularizoval Martin Fowler ve svém článku z roku 2003 [1]. Jedná se o situaci, kdy doménové třídy (entity, agregáty) slouží pouze jako datové kontejnery - obsahují výhradně gettery a settery - a veškerá doménová logika je přesunuta do servisní vrstvy.
Proč je anémický model problém?
- Porušení zapouzdření (encapsulation) - základní princip OOP říká, že data a chování, které na nich operuje, by měly být společně. Anémický model toto porušuje tím, že data jsou v entitě, ale logika je jinde.
- Ztráta modelu jako abstrakce domény - pokud entity obsahují pouze data, model přestává vyjadřovat chování domény a stává se pouhým datovým schématem přeloženým do tříd. Doménový expert by v takovém modelu nerozeznal žádné doménové procesy ani pravidla, pouze strukturu dat - model tak ztrácí svůj komunikační a dokumentační přínos.
- Duplicita logiky - doménová pravidla rozptýlená do service tříd vedou k jejich kopírování na více místech, protože není jasné kanonické místo pro logiku.
- Obtížná testovatelnost - testování logiky v servisní vrstvě vyžaduje mockování závislostí, kdežto doménová logika v entitě je testovatelná izolovaně bez jakýchkoliv závislostí.
Špatně: Anémická entita User a servisní vrstva s logikou
V tomto příkladu entita User neobsahuje žádnou doménovou logiku - pouze gettery
a settery. Veškerá logika je přesunuta do UserService, což vede k anémickému modelu.
Příklad: Anémická entita User (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: Entita je pouze datový kontejner
namespace App\UserManagement\Domain\Model;
class User
{
private string $id;
private string $email;
private string $status;
private ?string $verificationToken;
private \DateTimeImmutable $createdAt;
public function getId(): string { return $this->id; }
public function setId(string $id): void { $this->id = $id; }
public function getEmail(): string { return $this->email; }
public function setEmail(string $email): void { $this->email = $email; }
public function getStatus(): string { return $this->status; }
public function setStatus(string $status): void { $this->status = $status; }
public function getVerificationToken(): ?string { return $this->verificationToken; }
public function setVerificationToken(?string $token): void { $this->verificationToken = $token; }
public function getCreatedAt(): \DateTimeImmutable { return $this->createdAt; }
public function setCreatedAt(\DateTimeImmutable $dt): void { $this->createdAt = $dt; }
}
// ŠPATNĚ: Business logika v servisní třídě
class UserService
{
public function activateUser(User $user, string $token): void
{
if ($user->getStatus() !== 'pending') {
throw new \DomainException('User is not pending activation.');
}
if ($user->getVerificationToken() !== $token) {
throw new \DomainException('Invalid verification token.');
}
$user->setStatus('active');
$user->setVerificationToken(null);
}
public function deactivateUser(User $user): void
{
if ($user->getStatus() !== 'active') {
throw new \DomainException('User is not active.');
}
$user->setStatus('inactive');
}
}
Správně: Entita User s bohatou doménovou logikou
Správný přístup přesouvá doménovou logiku přímo do entity. Entita sama zajišťuje své invarianty a vystavuje doménově orientované metody místo holých setterů.
Příklad: Bohatá entita User (správně)
<?php
declare(strict_types=1);
namespace App\UserManagement\Domain\ValueObject;
enum UserStatus: string
{
case PENDING = 'pending';
case ACTIVE = 'active';
case INACTIVE = 'inactive';
public function isPending(): bool
{
return $this === self::PENDING;
}
public function isActive(): bool
{
return $this === self::ACTIVE;
}
}
final class VerificationToken
{
private function __construct(
private readonly string $value,
) {}
public static function generate(): self
{
return new self(bin2hex(random_bytes(32)));
}
public static function fromString(string $value): self
{
return new self($value);
}
public function equals(self $other): bool
{
return hash_equals($this->value, $other->value);
}
public function value(): string
{
return $this->value;
}
}
<?php
declare(strict_types=1);
// SPRÁVNĚ: Entita obsahuje doménovou logiku
namespace App\UserManagement\Domain\Model;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\UserStatus;
use App\UserManagement\Domain\ValueObject\VerificationToken;
use App\UserManagement\Domain\Event\UserRegisteredEvent;
use App\UserManagement\Domain\Event\UserActivatedEvent;
use App\UserManagement\Domain\Event\UserDeactivatedEvent;
class User
{
private readonly UserId $id;
private readonly Email $email;
private UserStatus $status;
private ?VerificationToken $verificationToken;
private readonly \DateTimeImmutable $createdAt;
private array $domainEvents = [];
private function __construct(
UserId $id,
Email $email,
VerificationToken $verificationToken
) {
$this->id = $id;
$this->email = $email;
$this->status = UserStatus::PENDING;
$this->verificationToken = $verificationToken;
$this->createdAt = new \DateTimeImmutable();
}
public static function register(UserId $id, Email $email): self
{
$token = VerificationToken::generate();
$user = new self($id, $email, $token);
$user->domainEvents[] = new UserRegisteredEvent($id, $email);
return $user;
}
public function activate(VerificationToken $token): void
{
if (!$this->status->isPending()) {
throw new \DomainException('Uživatel není ve stavu čekající na aktivaci.');
}
if (!$this->verificationToken->equals($token)) {
throw new \DomainException('Neplatný ověřovací token.');
}
$this->status = UserStatus::ACTIVE;
$this->verificationToken = null;
$this->domainEvents[] = new UserActivatedEvent($this->id);
}
public function deactivate(): void
{
if (!$this->status->isActive()) {
throw new \DomainException('Lze deaktivovat pouze aktivního uživatele.');
}
$this->status = UserStatus::INACTIVE;
$this->domainEvents[] = new UserDeactivatedEvent($this->id);
}
public function id(): UserId { return $this->id; }
public function email(): Email { return $this->email; }
public function status(): UserStatus { return $this->status; }
public function releaseDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
}
Klíčovým rozdílem je, že správná entita vystavuje doménově orientované metody (activate(),
deactivate(), register()) namísto generických setterů. Entita sama garantuje
své invarianty - nikdo zvenčí nemůže entitu dostat do nekonzistentního stavu.
Anti-vzor: Primitive Obsession (posedlost primitivy)
Primitive Obsession je anti-vzor, při němž vývojáři používají primitivní datové typy (string,
int, float) tam, kde by měly být použity hodnotové objekty (Value Objects).
Tento anti-vzor je zákeřný, protože primitiva jsou na první pohled jednodušší a rychlejší na použití,
ale vedou k závažným problémům.
Problémy způsobené Primitive Obsession
-
Ztráta validace - primitivní
stringmůže obsahovat jakoukoliv hodnotu, zatímco hodnotový objektEmailgarantuje, že vždy obsahuje platnou e-mailovou adresu. -
Chybějící sémantika - typ
stringneříká nic o tom, co hodnota reprezentuje.Email,PhoneNumberneboPostalCodejsou sémanticky bohaté. -
Záměna identifikátorů - používání
intpro všechna ID vede k tomu, že typový systém PHP ani IDE nemohou odhalit záměnu$orderIda$userId- obě jsou jenint. - Rozptýlená validační logika - bez hodnotových objektů se validace opakuje na každém místě, kde se s hodnotou pracuje.
Špatně: Primitiva místo Value Objects
Níže uvedený kód používá primitivní typy pro e-mail, peněžní částku a identifikátory.
Typový systém PHP neodhalí záměnu $orderId za $userId, protože obojí je int.
Příklad: Primitive Obsession (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: Primitiva místo hodnotových objektů
class Order
{
private int $id;
private int $userId; // int, stejný typ jako $id - záměna je možná!
private string $email; // libovolný string, bez validace
private float $amount; // float pro peníze - nebezpečné kvůli zaokrouhlování
private string $currency; // string "CZK", "EUR"... bez omezení
public function __construct(
int $id,
int $userId,
string $email,
float $amount,
string $currency
) {
// Validace (pokud vůbec existuje) je rozptýlena do konstruktoru
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Invalid email');
}
if ($amount < 0) {
throw new \InvalidArgumentException('Amount cannot be negative');
}
// ... a opakuje se na každém dalším místě, kde se s hodnotami pracuje
$this->id = $id;
$this->userId = $userId;
$this->email = $email;
$this->amount = $amount;
$this->currency = $currency;
}
}
// Typový systém PHP neodhalí tuto chybu:
$orderId = 42;
$userId = 17;
processOrder($userId, $orderId); // Záměna parametrů - a PHP si nestěžuje!
Správně: Value Objects nesoucí sémantiku a validaci
Hodnotové objekty zapouzdřují validaci, zabraňují záměně ID různých entit a nesou doménovou sémantiku.
Příklad: Value Objects (správně)
<?php
declare(strict_types=1);
// SPRÁVNĚ: Hodnotové objekty s validací a sémantikou
namespace App\OrderManagement\Domain\ValueObject;
final class Email
{
private readonly string $value;
public function __construct(string $value)
{
$normalized = mb_strtolower(trim($value));
if (!filter_var($normalized, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException(
sprintf('"%s" není platná e-mailová adresa.', $value)
);
}
$this->value = $normalized;
}
public function value(): string { return $this->value; }
public function equals(self $other): bool { return $this->value === $other->value; }
public function __toString(): string { return $this->value; }
}
enum Currency: string
{
case CZK = 'CZK';
case EUR = 'EUR';
case USD = 'USD';
public function equals(self $other): bool
{
return $this === $other;
}
}
final class Money
{
private readonly int $amountInCents; // Celé číslo - žádné problémy s plovoucí desetinnou čárkou
private readonly Currency $currency;
public function __construct(int $amountInCents, Currency $currency)
{
if ($amountInCents < 0) {
throw new \InvalidArgumentException('Částka nemůže být záporná.');
}
$this->amountInCents = $amountInCents;
$this->currency = $currency;
}
public static function zero(Currency $currency): self
{
return new self(0, $currency);
}
public function add(self $other): self
{
if (!$this->currency->equals($other->currency)) {
throw new \DomainException('Nelze sčítat částky v různých měnách.');
}
return new self($this->amountInCents + $other->amountInCents, $this->currency);
}
public function amountInCents(): int { return $this->amountInCents; }
public function currency(): Currency { return $this->currency; }
}
// Silně typované identifikátory - záměna je odhalena typovým systémem
final class OrderId
{
public function __construct(private readonly string $value)
{
if (!preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i', $value)) {
throw new \InvalidArgumentException('Neplatný formát UUID pro OrderId.');
}
}
public function value(): string { return $this->value; }
public function equals(self $other): bool { return $this->value === $other->value; }
}
final class UserId
{
public function __construct(private readonly string $value) { /* stejná validace */ }
public function value(): string { return $this->value; }
}
// Nyní typový systém PHP odhalí záměnu:
function processOrder(OrderId $orderId, UserId $userId): void { /* ... */ }
$orderId = new OrderId('a1b2c3d4-...');
$userId = new UserId('e5f6g7h8-...');
processOrder($userId, $orderId); // PHP TypeError: Argument #1 must be of type OrderId
Anti-vzor: Příliš velký agregát (God Aggregate)
Agregát by měl být navrhován kolem transakční konzistence - to jest kolem nejmenší skupiny objektů, která musí být vždy v konzistentním stavu. Příliš velký agregát (někdy označovaný jako "God Aggregate") sdružuje příliš mnoho entit a logiky do jednoho celku, čímž porušuje princip jedné odpovědnosti a způsobuje řadu závažných problémů.
Problémy způsobené příliš velkým agregátem
- Výkonnostní problémy - načtení celého agregátu z databáze je pomalé, pokud obsahuje stovky nebo tisíce podřízených entit (např. všechny položky objednávky zákazníka za celý rok).
- Problémy s konkurencí (concurrency) - agregát je zamčen jako celek při každé změně. Velký agregát znamená větší pravděpodobnost konfliktů při souběžném přístupu.
- Těsné provázání (tight coupling) - příliš mnoho entit uvnitř jednoho agregátu ztěžuje nezávislý vývoj a testování.
- Narušení Bounded Context hranic - god agregát bývá příznakem špatně definovaných hranic kontextů.
Špatně: God Aggregate obsahující příliš mnoho entit
Následující příklad ukazuje agregát Customer, který neúměrně sdružuje objednávky,
adresy, platební karty i recenze - to vše jako přímé součásti jednoho agregátu.
Příklad: Příliš velký agregát (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: God Aggregate - příliš mnoho odpovědností
class Customer
{
private CustomerId $id;
private string $name;
private Email $email;
/** @var Order[] */
private array $orders = []; // Celá historie objednávek
/** @var Address[] */
private array $addresses = []; // Všechny adresy zákazníka
/** @var CreditCard[] */
private array $creditCards = []; // Platební karty
/** @var ProductReview[] */
private array $reviews = []; // Recenze produktů zákazníkem
/** @var WishlistItem[] */
private array $wishlistItems = []; // Přání zákazníka
// Při načtení zákazníka z DB musíme načíst vše - tisíce záznamů!
// Při update zákazníka zamkneme celou tuto strukturu.
// Přidání nové objednávky vyžaduje celý agregát v paměti.
}
Správně: Malé agregáty s jasnou transakční hranicí
Agregáty by měly být navrhovány kolem skutečné transakční potřeby. Zákazník a jeho objednávky jsou samostatné agregáty - objednávku lze vytvořit, aniž by bylo nutné načíst celou historii zákazníka.
Příklad: Správně rozdělené agregáty
<?php
declare(strict_types=1);
// SPRÁVNĚ: Malé agregáty s jednoznačnou odpovědností
namespace App\OrderManagement\Domain\Model;
use App\OrderManagement\Domain\ValueObject\OrderId;
use App\OrderManagement\Domain\ValueObject\CustomerId;
use App\OrderManagement\Domain\ValueObject\ProductId;
use App\OrderManagement\Domain\ValueObject\Address;
use App\OrderManagement\Domain\ValueObject\OrderStatus;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\Currency;
use App\OrderManagement\Domain\ValueObject\Email;
use App\OrderManagement\Domain\ValueObject\WishlistId;
use App\OrderManagement\Domain\Event\OrderPlacedEvent;
// Agregát 1: Customer - pouze identita a kontaktní údaje
class Customer
{
private readonly CustomerId $id;
private string $name;
private Email $email;
// Zákazník obsahuje jen to, co je součástí jeho identity.
// Adresa pro doručení je součástí objednávky, ne zákazníka.
}
// Agregát 2: Order - transakční hranice pro jednu objednávku
final class Order
{
private readonly OrderId $id;
private readonly CustomerId $customerId; // Pouze reference - ne celý Customer objekt!
private Address $shippingAddress;
private OrderStatus $status;
/** @var OrderItem[] */
private array $items = [];
private readonly \DateTimeImmutable $placedAt;
private array $domainEvents = [];
public function __construct(
OrderId $id,
CustomerId $customerId,
Address $shippingAddress
) {
$this->id = $id;
$this->customerId = $customerId;
$this->shippingAddress = $shippingAddress;
$this->status = OrderStatus::DRAFT;
$this->placedAt = new \DateTimeImmutable();
}
public function addItem(ProductId $productId, int $quantity, Money $unitPrice): void
{
if ($this->status !== OrderStatus::DRAFT) {
throw new \DomainException('Položky lze přidat pouze k objednávce ve stavu Draft.');
}
$this->items[] = new OrderItem($productId, $quantity, $unitPrice);
}
public function place(): void
{
if (empty($this->items)) {
throw new \DomainException('Nelze potvrdit prázdnou objednávku.');
}
$this->status = OrderStatus::PLACED;
$this->domainEvents[] = new OrderPlacedEvent($this->id, $this->customerId);
}
public function totalAmount(): Money
{
return array_reduce(
$this->items,
fn(Money $carry, OrderItem $item) => $carry->add($item->subtotal()),
Money::zero(Currency::CZK)
);
}
}
// Agregát 3: Wishlist - zcela oddělená doménová odpovědnost
class Wishlist
{
private readonly WishlistId $id;
private readonly CustomerId $customerId;
/** @var WishlistItem[] */
private array $items = [];
}
Pravidlo pro navrhování agregátů zní: agregát by měl být co nejmenší, aby zachoval invarianty (doménová pravidla) platné v rámci jedné transakce. Pokud změna jednoho objektu nevyžaduje konzistentní změnu druhého ve stejné transakci, patří do různých agregátů.
Anti-vzor: Sdílená databáze napříč Bounded Contexts
Jeden z nejzávažnějších strategických anti-vzorů nastává, když různé Bounded Contexts sdílejí stejné databázové tabulky nebo přistupují přímo k datům jiného kontextu. I když se to na počátku jeví jako pragmatické řešení, vede to k těsnému provázání, které znemožňuje nezávislý vývoj a nasazení jednotlivých kontextů.
Špatně: Přímý přístup ke sdíleným tabulkám
Kontexty OrderManagement a Billing přímo přistupují ke stejné tabulce
users. Změna schématu tabulky v jednom kontextu okamžitě ovlivní druhý.
Příklad: Sdílená databáze (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: OrderManagement context přímo dotazuje tabulku users z UserManagement kontextu
namespace App\OrderManagement\Infrastructure\Repository;
use App\OrderManagement\Domain\ValueObject\CustomerId;
use App\OrderManagement\Domain\ValueObject\OrderId;
use Doctrine\DBAL\Connection;
class DoctrineOrderRepository
{
public function __construct(private Connection $connection) {}
public function findOrdersWithUserDetails(CustomerId $customerId): array
{
// Přímý JOIN na tabulku z jiného Bounded Context!
return $this->connection->executeQuery(
'SELECT o.*, u.email, u.billing_address, u.vat_number
FROM orders o
JOIN users u ON o.user_id = u.id -- tabulka patří do UserManagement kontextu!
WHERE o.customer_id = :id',
['id' => $customerId->value()]
)->fetchAllAssociative();
}
}
// Billing context dělá totéž:
namespace App\Billing\Infrastructure;
class InvoiceGenerator
{
public function generate(OrderId $orderId): Invoice
{
// Opět přímý přístup k tabulce orders z OrderManagement kontextu!
$data = $this->db->query(
'SELECT o.total, u.billing_address, u.vat_number
FROM orders o JOIN users u ON o.user_id = u.id
WHERE o.id = :id',
['id' => $orderId->value()]
);
// ...
}
}
Správně: Izolovaná data s Anti-Corruption Layer
Každý Bounded Context vlastní svá data. Komunikace mezi kontexty probíhá přes definované rozhraní (Anti-Corruption Layer, doménové události nebo explicitní API), nikoliv přes přímý přístup do databáze.
Příklad: Izolované kontexty s ACL (správně)
<?php
declare(strict_types=1);
// SPRÁVNĚ: Každý kontext vlastní svá data a komunikuje přes definované rozhraní
// OrderManagement kontext si ukládá pouze to, co potřebuje pro svou logiku.
// Billing údaje zákazníka získává přes Anti-Corruption Layer.
namespace App\Billing\Domain\Port;
use App\Billing\Domain\ValueObject\Address;
use App\Billing\Domain\ValueObject\CustomerId;
use App\Billing\Domain\ValueObject\VatNumber;
// Port (rozhraní) - Billing kontext definuje, co potřebuje vědět o zákazníkovi
interface CustomerDataProvider
{
public function getBillingDataForCustomer(CustomerId $customerId): CustomerBillingData;
}
// CustomerBillingData je DTO specifické pro Billing kontext - ne User entita!
final class CustomerBillingData
{
public function __construct(
public readonly string $fullName,
public readonly Address $billingAddress,
public readonly ?VatNumber $vatNumber,
) {}
}
// Infrastrukturní adapter - implementace v Billing kontextu, volá UserManagement přes API
namespace App\Billing\Infrastructure\Adapter;
use App\Billing\Domain\Port\CustomerBillingData;
use App\Billing\Domain\Port\CustomerDataProvider;
use App\Billing\Domain\ValueObject\Address;
use App\Billing\Domain\ValueObject\CustomerId;
use App\Billing\Domain\ValueObject\VatNumber;
use Symfony\Contracts\HttpClient\HttpClientInterface;
class HttpUserManagementAdapter implements CustomerDataProvider
{
public function __construct(private readonly HttpClientInterface $httpClient) {}
public function getBillingDataForCustomer(CustomerId $customerId): CustomerBillingData
{
$response = $this->httpClient->request(
'GET',
"/internal/users/{$customerId->value()}/billing"
);
$data = $response->toArray();
return new CustomerBillingData(
fullName: $data['full_name'],
billingAddress: Address::fromArray($data['billing_address']),
vatNumber: isset($data['vat_number']) ? new VatNumber($data['vat_number']) : null,
);
}
}
Alternativou k synchronnímu HTTP volání je asynchronní komunikace přes doménové události.
Billing kontext může naslouchat události CustomerBillingDataUpdated a lokálně
si ukládat kopii potřebných dat (tzv. Read Model projection). Tím se eliminuje
synchronní závislost za cenu eventuální konzistence.
Anti-vzor: Mutovatelné doménové události
Doménová událost reprezentuje fakt, který se stal v minulosti - a minulost nelze změnit. Události musí být striktně immutable (neměnné). Mutovatelná událost je konceptuální rozpor: pokud lze událost po vytvoření změnit, ztrácí svou sémantickou hodnotu jako historický záznam.
Mutovatelné události navíc způsobují praktické problémy při event sourcingu, auditních logách a při komunikaci mezi Bounded Contexts, kde přijímající kontext předpokládá, že obdrží konzistentní a neměnná data.
Špatně: Mutovatelná událost s veřejnými settery
Veřejné settery a chybějící readonly semantika umožňují modifikaci události
po jejím vzniku, čímž narušují integritu historického záznamu.
Příklad: Mutovatelná událost (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: Mutovatelná doménová událost
class OrderPlacedEvent
{
private string $orderId;
private string $customerId;
private float $totalAmount;
private \DateTime $occurredAt; // Mutovatelný DateTime!
// Veřejné settery - událost lze po vytvoření libovolně měnit
public function setOrderId(string $orderId): void
{
$this->orderId = $orderId;
}
public function setTotalAmount(float $amount): void
{
$this->totalAmount = $amount; // Měnit celkovou částku události? Nonsens!
}
public function setOccurredAt(\DateTime $dt): void
{
$this->occurredAt = $dt; // Čas vzniku události by měl být fixní
}
public function getOrderId(): string { return $this->orderId; }
public function getTotalAmount(): float { return $this->totalAmount; }
public function getOccurredAt(): \DateTime { return $this->occurredAt; }
}
Správně: Immutable událost s readonly properties
Správná doménová událost je vytvořena jednou, nastavena v konstruktoru a poté nelze žádnou
její vlastnost změnit. PHP 8.1+ readonly properties jsou ideálním nástrojem.
Příklad: Immutable doménová událost (správně)
<?php
declare(strict_types=1);
// SPRÁVNĚ: Immutable doménová událost s readonly properties (PHP 8.1+)
namespace App\OrderManagement\Domain\Event;
use App\OrderManagement\Domain\ValueObject\CustomerId;
use App\OrderManagement\Domain\ValueObject\Money;
use App\OrderManagement\Domain\ValueObject\OrderId;
final class OrderPlacedEvent
{
public readonly \DateTimeImmutable $occurredAt;
public function __construct(
public readonly OrderId $orderId,
public readonly CustomerId $customerId,
public readonly Money $totalAmount,
public readonly int $itemCount,
) {
$this->occurredAt = new \DateTimeImmutable();
// Všechny hodnoty jsou nastaveny jednou v konstruktoru.
// Neexistují žádné settery - událost je neměnná.
}
// Jediné metody jsou readonly accessory (nebo přímý přístup k readonly properties)
public function orderId(): OrderId { return $this->orderId; }
public function customerId(): CustomerId { return $this->customerId; }
public function totalAmount(): Money { return $this->totalAmount; }
public function occurredAt(): \DateTimeImmutable { return $this->occurredAt; }
}
// Alternativa pro starší PHP: final class s private properties a bez setterů
final class OrderCancelledEvent
{
private readonly OrderId $orderId;
private readonly string $reason;
private readonly \DateTimeImmutable $occurredAt;
public function __construct(OrderId $orderId, string $reason)
{
$this->orderId = $orderId;
$this->reason = $reason;
$this->occurredAt = new \DateTimeImmutable();
// Žádné settery - zapouzdření zajišťuje immutabilitu
}
public function orderId(): OrderId { return $this->orderId; }
public function reason(): string { return $this->reason; }
public function occurredAt(): \DateTimeImmutable { return $this->occurredAt; }
}
Anti-vzor: Doménová logika v infrastrukturní vrstvě
DDD striktně odděluje doménovou vrstvu od infrastrukturní. Infrastrukturní vrstva (Doctrine repozitáře, Symfony Forms, kontrolery, event listenery) by měla být tenká a delegovat veškerou doménovou logiku do doménové vrstvy. Pokud se doménová pravidla začnou objevovat v infrastrukturních třídách, dochází k narušení architekturních hranic a ke vzniku skryté, těžko testovatelné logiky.
Špatně: Doménová logika v Doctrine repozitáři
Repozitář by měl pouze ukládat a načítat agregáty. Jakákoliv doménová logika (výpočty, aplikace doménových pravidel, stavové přechody) v repozitáři je anti-vzor.
Příklad: Doménová logika v repozitáři a kontroleru (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: Business logika v Doctrine repozitáři
namespace App\UserManagement\Infrastructure\Repository;
use Doctrine\ORM\EntityRepository;
class DoctrineUserRepository extends EntityRepository
{
public function activateUser(string $userId, string $token): void
{
$user = $this->find($userId);
// Business logika přímo v repozitáři - ŠPATNĚ!
if ($user->getStatus() !== 'pending') {
throw new \RuntimeException('User is not pending.');
}
if ($user->getToken() !== $token) {
throw new \RuntimeException('Invalid token.');
}
$user->setStatus('active');
$user->setToken(null);
$user->setActivatedAt(new \DateTime());
// Repozitář volá flush - to by měla řídit aplikační vrstva
$this->getEntityManager()->flush();
}
}
// ŠPATNĚ: Business logika v Symfony kontroleru
class UserController extends AbstractController
{
public function activate(Request $request, string $userId): Response
{
$user = $this->userRepository->find($userId);
$token = $request->query->get('token');
// Business logika v kontroleru!
if (empty($token) || strlen($token) !== 32) {
return $this->json(['error' => 'Invalid token format'], 400);
}
if ($user->getCreatedAt() < new \DateTime('-24 hours')) {
// Expirace tokenu - business pravidlo patří do domény, ne do kontroleru!
$user->setStatus('expired');
$this->entityManager->flush();
return $this->json(['error' => 'Token expired'], 400);
}
// ...
}
}
Správně: Tenká infrastruktura, bohatá doménová vrstva
Kontroler a repozitář jsou tenké orchestrátory. Business logika žije v doménové entitě nebo doménové službě.
Příklad: Správné vrstvení - logika v doméně (správně)
<?php
declare(strict_types=1);
// SPRÁVNĚ: Business logika v doménové entitě (viz sekce o anémickém modelu)
// Repozitář je pouze tenký adaptér pro persistenci
namespace App\UserManagement\Infrastructure\Repository;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\Repository\UserRepositoryInterface;
use Doctrine\ORM\EntityManagerInterface;
class DoctrineUserRepository implements UserRepositoryInterface
{
public function __construct(private readonly EntityManagerInterface $em) {}
public function save(User $user): void
{
$this->em->persist($user);
// Flush je řízen aplikační vrstvou (Unit of Work), ne repozitářem
}
public function findById(UserId $id): ?User
{
return $this->em->find(User::class, $id->value());
}
}
// SPRÁVNĚ: Aplikační vrstva (Command Handler) orkestruje, doména rozhoduje
namespace App\UserManagement\Application\Command;
use App\UserManagement\Domain\Repository\UserRepositoryInterface;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\Exception\UserNotFoundException;
use App\UserManagement\Domain\ValueObject\VerificationToken;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\Messenger\MessageBusInterface;
class ActivateUserHandler
{
public function __construct(
private readonly UserRepositoryInterface $users,
private readonly EntityManagerInterface $em,
private readonly MessageBusInterface $eventBus
) {}
public function __invoke(ActivateUserCommand $command): void
{
$user = $this->users->findById(new UserId($command->userId));
if ($user === null) {
throw new UserNotFoundException($command->userId);
}
// Business logika je v entitě - handler pouze orkestruje
$user->activate(VerificationToken::fromString($command->token));
$this->em->flush(); // Flush patří do aplikační vrstvy
foreach ($user->releaseDomainEvents() as $event) {
$this->eventBus->dispatch($event);
}
}
}
// SPRÁVNĚ: Tenký Symfony kontroler
class UserController extends AbstractController
{
public function activate(Request $request, string $userId): Response
{
$this->commandBus->dispatch(new ActivateUserCommand(
userId: $userId,
token: $request->query->getString('token'),
));
return $this->json(['status' => 'activated']);
}
}
Anti-vzor: Over-engineering u jednoduchých aplikací
DDD není vhodné pro každý projekt. Eric Evans sám upozorňuje, že DDD přináší největší přidanou hodnotu u komplexních domén se složitou doménovou logikou. Pro jednoduché CRUD aplikace, administrativní nástroje nebo prototypy je plnohodnotné DDD překombinované - přináší obrovskou počáteční složitost bez odpovídajícího benefitu.
Příznaky over-engineeringu v DDD kontextu
- Agregáty, Value Objects a Events pro doménu, kde skutečně stačí jednoduchý formulář a databázová tabulka (CRUD).
- Více než 5 architekturních vrstev pro aplikaci, jejíž doménová logika se vejde na jednu stránku A4.
- CQRS s event sourcingem pro systém, který nemá požadavky na auditní logy ani na komplexní reporting.
- Tým tráví více času navrhováním architektury než implementací business hodnoty.
- Přidání nové funkce vyžaduje úpravu desítek souborů v různých vrstvách, i když jde o triviální změnu.
Start simple, add complexity when needed
Začněte s jednoduchým přístupem - aktivní záznamy, jednoduché service třídy nebo MVC bez DDD. DDD prvky přidávejte inkrementálně, jakmile se doménová složitost začne projevovat. Refaktoring od jednoduchého k DDD je mnohem snazší než odstraňování zbytečné složitosti z přenavržené architektury.
Vhodné indikátory pro zavedení DDD: složitá doménová pravidla, která se neustále mění; více doménových expertů s odlišnými pohledy na problém; systém, u nějž se předpokládá dlouhodobý vývoj a vysoká míra změn v business logice.
Příklad: Kdy použít DDD a kdy ne
# DDD je vhodné pro:
✔ E-commerce platforma s komplexními pravidly pro slevy, zásoby, dopravu
✔ Bankovní systém s regulatorními požadavky a složitou finanční logikou
✔ ERP systém se vzájemně propojenými doménovými procesy
✔ Pojišťovací systém s komplexními výpočty pojistného
# DDD je překombinované pro:
✗ Blog nebo CMS (kategorie, příspěvky, komentáře - čistý CRUD)
✗ Jednoduchý e-shop s desítkami produktů a základními objednávkami
✗ Interní admin panel pro správu číselníků
✗ Prototyp nebo MVP s nejistou business logikou
✗ Microservice s jednou jasnou a stabilní odpovědností
Anti-vzor: Ignorování Ubiquitous Language
Jedním ze základních pilířů DDD je Ubiquitous Language - společný jazyk sdílený vývojáři, doménovými experty a všemi zainteresovanými stranami. Tento jazyk musí být konzistentně používán v kódu, dokumentaci, testech i v komunikaci. Ignorování tohoto principu vede k situaci, kdy se tatáž doménová entita nazývá různě na různých místech, což způsobuje nedorozumění, chyby a ztrátu doménového vhledu v kódu.
Špatně: Různé názvy pro stejný koncept
Doménový expert mluví o Pojistníkovi, databáze má tabulku clients,
backendový kód používá User, frontend říká Account
a API endpoint je /customers. Každá vrstva mluví jiným jazykem.
Příklad: Nekonzistentní pojmenování (špatně)
<?php
declare(strict_types=1);
// ŠPATNĚ: Tatáž doménová entita má různé názvy na různých místech
// Databázová tabulka: "clients"
// Doménový expert: "Pojistník" (PolicyHolder)
// Backendový kód:
class User { /* ... */ } // Proč User? Systém je pro pojišťovnu!
class Customer { /* ... */ } // Jiný název ve stejném projektu
class Account { /* ... */ } // Třetí název v jiném modulu
// API endpoint: GET /api/clients/{id}
// Doctrine entita:
#[ORM\Entity]
#[ORM\Table(name: 'clients')]
class User { /* ... */ } // Třída "User", tabulka "clients" - zmatek
// Metody v kódu:
function getCustomerById(int $id): User { /* ... */ } // Vrací User, bere customer
function findUser(int $clientId): Customer { /* ... */ } // Bere client, vrací Customer
// Výsledek: vývojář musí neustále překládat mezi vrstvami místo práce na business logikou
Správně: Konzistentní jazyk napříč všemi vrstvami
Ubiquitous Language vyžaduje investici: vývojáři musí naslouchat doménovým expertům, porozumět jejich terminologii a tu pak konzistentně přenést do kódu. Výsledkem je kód, který doménový expert může číst a rozumět mu.
Příklad: Konzistentní Ubiquitous Language (správně)
<?php
declare(strict_types=1);
// SPRÁVNĚ: Jednotný jazyk pojišťovací domény napříč všemi vrstvami
// Doménový expert: "Pojistník" → kód: PolicyHolder
// Doménový expert: "Pojistná smlouva" → kód: InsurancePolicy
// Doménový expert: "Pojistné plnění" → kód: Claim
// Doménový expert: "Pojistná událost" → kód: InsuredEvent
namespace App\Insurance\Domain\Model;
use App\Insurance\Domain\ValueObject\BirthNumber;
use App\Insurance\Domain\ValueObject\ContactDetails;
use App\Insurance\Domain\ValueObject\Money;
use App\Insurance\Domain\ValueObject\PersonName;
use App\Insurance\Domain\ValueObject\PolicyHolderId;
use App\Insurance\Domain\ValueObject\RiskProfile;
// Třídy pojmenovány přesně podle doménového slovníku:
class PolicyHolder
{
private readonly PolicyHolderId $id;
private PersonName $fullName;
private BirthNumber $birthNumber; // Specifický pojišťovací identifikátor
private ContactDetails $contactDetails;
public function fileClaimFor(InsuredEvent $event): Claim
{
// Metoda pojmenována jazykem domény - doménový expert rozumí!
return Claim::open($this->id, $event);
}
}
class InsurancePolicy
{
public function calculatePremium(RiskProfile $riskProfile): Money
{
// Název metody je přímo z doménového slovníku pojišťovny
return $this->basePremium->adjustFor($riskProfile);
}
public function isValidForEvent(InsuredEvent $event): bool
{
// Doménový expert okamžitě rozumí, co tato metoda dělá
return $this->validFrom <= $event->occurredAt()
&& $this->validTo >= $event->occurredAt();
}
}
// Databázová tabulka: policy_holders (ne "users" ani "clients")
// API endpoint: POST /api/policy-holders/{id}/claims
// Testy: "When a policy holder files a claim for an insured event..."
Doménový slovník jako živý artefakt
Doporučenou praxí je udržovat živý glosář (tzv. doménový slovník), který mapuje pojmy z doménového jazyka na odpovídající třídy, metody a databázové struktury v kódu. Tento slovník by měl být dostupný všem členům týmu a pravidelně aktualizován.
- Pojistník → třída
PolicyHolder, tabulkapolicy_holders - Pojistná smlouva → třída
InsurancePolicy, tabulkainsurance_policies - Pojistná událost → třída
InsuredEvent, eventInsuredEventOccurred - Pojistné plnění → třída
Claim, tabulkaclaims - Pojistné (částka) → Value Object
Premium
Ubiquitous Language není jen o pojmenování tříd - zahrnuje také pojmenování metod, proměnných, databázových sloupců, API endpointů, chybových zpráv a testovacích scénářů. Čím konzistentnější jazyk, tím přímočařejší mapování mezi požadavky doménového experta a implementací.
Tato kapitola pokryla nejčastější anti-vzory a typické chyby při implementaci DDD. Dobrá znalost těchto úskalí pomáhá udržet kvalitu doménového modelu v průběhu celého životního cyklu projektu. Pro hlubší porozumění doporučujeme studovat doporučené zdroje, zejména Vaughn Vernonovu knihu Implementing Domain-Driven Design, která se anti-vzorům věnuje podrobně na praktických příkladech.