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í string může obsahovat jakoukoliv hodnotu, zatímco hodnotový objekt Email garantuje, že vždy obsahuje platnou e-mailovou adresu.
  • Chybějící sémantika - typ string neříká nic o tom, co hodnota reprezentuje. Email, PhoneNumber nebo PostalCode jsou sémanticky bohaté.
  • Záměna identifikátorů - používání int pro všechna ID vede k tomu, že typový systém PHP ani IDE nemohou odhalit záměnu $orderId a $userId - obě jsou jen int.
  • 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, tabulka policy_holders
  • Pojistná smlouva → třída InsurancePolicy, tabulka insurance_policies
  • Pojistná událost → třída InsuredEvent, event InsuredEventOccurred
  • Pojistné plnění → třída Claim, tabulka claims
  • 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.