Migrace z CRUD architektury na DDD
Kdy a proč migrovat z CRUD na DDD
CRUD architektura (Create, Read, Update, Delete) je přirozený výchozí bod pro mnoho aplikací. Pro jednoduché správy dat – záznamy kontaktů, katalogy produktů, administrační rozhraní bez komplexní logiky – je CRUD zcela dostačující a přidávání vrstev DDD by bylo přebytečným inženýrstvím. Problém nastává tehdy, kdy se aplikace postupem času stává komplexnější a doménová logika začíná pronikat na nevhodná místa.
Příznaky, že CRUD architektura nestačí
-
God Services (Boží služby) – Třídy jako
UserServiceneboOrderServiceobsahují stovky řádků kódu a stávají se centrálním místem pro veškerou doménovou logiku. Přidání jakékoli nové funkce vyžaduje zásah do stejné třídy a riziko regresí roste. - Fat Controllers (Tlusté kontrolery) – Symfony kontrolery přestaly být tenkou vrstvou pro HTTP adaptaci a místo toho přímo implementují doménová pravidla: validaci, výpočty, přechody stavů. Kontroler by měl delegovat na doménový model, nikoli ho suplovat.
- Business logika v repozitářích – Doctrine repozitáře obsahují komplexní podmínky, které vyjadřují doménová pravidla (např. "objednávky, které je možné zrušit"). Tato logika patří do doménového modelu, nikoli do databázové vrstvy.
- Překrývání zodpovědností – Není jasné, zda konkrétní pravidlo patří do kontroleru, service nebo repozitáře. Tým nemá sdílené chápání, kde co hledat.
- Nízká testovatelnost – Business logika je neoddělitelně svázána s HTTP vrstvou nebo databází. Napsání unit testu pro doménové pravidlo vyžaduje rozsáhlý mocking.
- Komunikační propast – Vývojáři a doménoví experti používají jiný slovník. Kód neodráží jazyk businessu; pojmy jako "aktivace účtu" nebo "storno objednávky" nejsou viditelné v názvech tříd a metod.
Kdy DDD přináší hodnotu a kdy je CRUD dostačující
Rozhodnutí o migraci by mělo být podloženo analýzou komplexity domény, nikoli módními trendy. Martin Fowler ve své práci o architektonických vzorech upozorňuje, že aplikace Transaction Script (a jeho specializace CRUD) jsou zcela legitimní pro aplikace s jednoduchými doménovými pravidly [1].
Kdy DDD přináší hodnotu
- Doména obsahuje komplexní doménová pravidla, která se často mění.
- Existují přechody stavů entit (objednávka: vytvořena → potvrzena → odeslána → doručena).
- Tým komunikuje s doménovými experty a potřebuje sdílený jazyk.
- Aplikace je dlouhodobě rozvíjena a musí být udržovatelná v horizontu let.
- Existuje více Bounded Contexts s odlišnými pohledy na stejné entity.
Kdy zůstat u CRUD
- Aplikace je v podstatě CRUD nad databázovými tabulkami bez doménové logiky.
- Doménová pravidla jsou triviální a stabilní.
- Tým je malý a čas na migraci není dostupný.
- Aplikace je krátkodobá nebo se bude v blízké budoucnosti kompletně přepisovat.
Realistické zhodnocení nákladů migrace
Migrace z CRUD na DDD není jednorázová akce. Je to kontinuální proces, který může trvat měsíce až roky v závislosti na velikosti kódové základny. Migrace sama o sobě nepřináší okamžitou hodnotu pro zákazníka – hodnotu přinese až zlepšená schopnost rychleji a bezpečněji přidávat nové funkcionality. Tým by měl management přesvědčit tím, že migraci provádí inkrementálně souběžně s vývojem nových funkcionalit, nikoli jako izolovaný refaktoringový projekt.
Strangler Fig Pattern – vzor postupné náhrady
Strangler Fig Pattern (vzor fíkovníku škrtiče) je architektonická strategie popsaná Martinem Fowlerem [2], která umožňuje postupnou náhradu starého systému novým bez nutnosti "big bang" přepisu. Název pochází od tropického fíkovníku, který roste kolem hostitelského stromu a postupně ho nahrazuje.
Princip fungování
- Nová funkcionalita je vždy implementována v DDD stylu – nové Bounded Contexts, doménové objekty, repozitáře.
- Stará funkcionalita zůstává v CRUD podobě a je postupně nahrazována při refaktoringu nebo při úpravách stávajících funkcí.
- Koexistence – obě části systému fungují paralelně a jsou propojeny přes Anti-Corruption Layer nebo sdílenou databázi.
- Postupná eliminace – s každou iterací se CRUD část zmenšuje a DDD část roste, dokud starý kód zcela nevymizí.
Příklad: Koexistence CRUD a DDD ve struktuře projektu
src/
├── Controller/ # Stará CRUD vrstva (postupně se zmenšuje)
│ ├── UserController.php # Původní CRUD kontroler
│ └── OrderController.php # Původní CRUD kontroler
│
├── Service/ # Stará service vrstva (God Services)
│ ├── UserService.php # Bude nahrazena DDD vrstvou
│ └── OrderService.php # Bude nahrazena DDD vrstvou
│
├── Entity/ # Doctrine entity (sdílené nebo duplikované)
│ ├── User.php
│ └── Order.php
│
└── UserManagement/ # Nová DDD vrstva (postupně roste)
├── Domain/
│ ├── Model/
│ │ ├── User.php # Doménová entita (ne Doctrine entita)
│ │ └── Email.php # Value Object
│ ├── Repository/
│ │ └── UserRepository.php # Doménové rozhraní
│ └── Event/
│ └── UserRegistered.php # Domain Event
├── Application/
│ ├── Command/
│ │ ├── RegisterUser.php
│ │ └── RegisterUserHandler.php
│ └── Query/
│ ├── GetUserProfile.php
│ └── GetUserProfileHandler.php
└── Infrastructure/
└── Repository/
└── DoctrineUserRepository.php # Implementace repozitáře
Výhody oproti přímé refaktorizaci (Big Bang Rewrite)
Přímý přepis celého systému najednou (tzv. "big bang rewrite") je považován za jedno z největších rizik v softwarovém vývoji. Joel Spolsky ve svém slavném článku "Things You Should Never Do" [3] popisuje, proč firmy ztratily konkurenční výhodu tím, že kompletně přepsaly fungující systémy. Strangler Fig Pattern oproti tomu:
- Umožňuje kontinuální dodávku nové hodnoty zákazníkovi i během migrace.
- Snižuje riziko – systém nikdy není kompletně "rozbitý".
- Poskytuje možnost rollbacku: pokud nová implementace selhává, stará stále funguje.
- Umožňuje týmu učit se DDD postupně, na reálném produkčním kódu.
- Refaktoring lze zastavit kdykoli – systém zůstává v konzistentním, funkčním stavu.
Krok 1: Analýza existující domény
Než začneme přesouvat kód, musíme pochopit doménu. Nejčastější chybou je přímý skok do refaktoringu bez předchozí analýzy – výsledkem je pak DDD architektura, která přesně kopíruje strukturu starých databázových tabulek, aniž by odrážela skutečný doménový model.
Identifikace Bounded Contexts z existujícího CRUD kódu
Bounded Contexts lze v existující CRUD aplikaci identifikovat sledováním přirozených hranic:
- Skupiny entit a tabulek, které jsou silně provázané navzájem, ale slabě propojené s ostatními skupinami – to jsou kandidáti na jeden Bounded Context.
-
God Services – velké service třídy jsou paradoxně dobrým vodítkem. Pokud
OrderServiceobsahuje logiku objednávky, platby i doručení, jsou to tři různé Bounded Contexts skryté v jedné třídě. - Opakující se slovo s různým významem – pokud "zákazník" v kontextu prodeje znamená něco jiného než "zákazník" v kontextu zákaznické podpory, jde o přirozené rozhraní dvou Bounded Contexts.
Event Storming jako nástroj pro analýzu
Event Storming je workshopová technika navržená Albertem Brandolinim [4], která umožňuje kolaborativně modelovat doménu prostřednictvím doménových událostí. V kontextu migrace z CRUD je velmi užitečná pro:
- Odkrytí implicitní doménové logiky skryté v kontrolerech a service třídách.
- Identifikaci přechodů stavů entit (z pohledu businessu, nikoli databáze).
- Nalezení přirozených hranic Bounded Contexts.
- Zapojení doménových expertů do návrhu nové architektury.
Příklad: Identifikace doménové logiky v CRUD kontroleru
Následující příklad ilustruje typický CRUD kontroler, ve kterém je skryta netriviální doménová logika. Tato logika bude v dalších krocích extrahována do doménového modelu.
<?php
// PŘED migrací: Typický CRUD kontroler s ukrytou doménovou logikou
namespace App\Controller;
use App\Entity\User;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Doctrine\ORM\EntityManagerInterface;
class UserController extends AbstractController
{
public function __construct(
private EntityManagerInterface $em
) {}
#[Route('/users/register', methods: ['POST'])]
public function register(Request $request): Response
{
$email = $request->request->get('email');
$password = $request->request->get('password');
// Doménová logika č. 1: validace emailu (patří do Value Object)
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->json(['error' => 'Neplatný e-mail'], 422);
}
// Doménová logika č. 2: kontrola unikátnosti (patří do doménové služby)
$existing = $this->em->getRepository(User::class)
->findOneBy(['email' => $email]);
if ($existing) {
return $this->json(['error' => 'E-mail již existuje'], 409);
}
// Doménová logika č. 3: hashování hesla a bezpečnostní pravidla
if (strlen($password) < 8) {
return $this->json(['error' => 'Heslo musí mít alespoň 8 znaků'], 422);
}
$hashedPassword = password_hash($password, PASSWORD_BCRYPT);
$user = new User();
$user->setEmail($email);
$user->setPassword($hashedPassword);
$user->setCreatedAt(new \DateTimeImmutable());
// Doménová logika č. 4: výchozí stav uživatele
$user->setStatus('pending_verification');
$this->em->persist($user);
$this->em->flush();
// Doménová logika č. 5: odeslání uvítacího e-mailu
// ... (inline kód pro odeslání e-mailu)
return $this->json(['id' => $user->getId()], 201);
}
}
V tomto kontroleru lze identifikovat nejméně pět oblastí doménové logiky, které patří do doménového modelu: validace formátu e-mailu, unikátnost e-mailu, bezpečnostní pravidla hesla, výchozí stav uživatele a side-effect registrace (uvítací e-mail jako Domain Event).
Krok 2: Extrakce doménové vrstvy
Extrakce doménové vrstvy znamená přesunutí doménových pravidel z kontrolerů a service tříd do doménových objektů. Cílem je, aby doménové objekty samy vynucovaly svá invarianty – pravidla, která musí být vždy splněna, bez ohledu na to, kdo s objektem pracuje.
Přesunutí business pravidel do doménových objektů
Refaktoring probíhá ve dvou hlavních krocích: nejprve vytvoříme doménové Value Objects pro primitivní typy nesoucí doménová pravidla, poté extrahujeme logiku do doménových entit a služeb.
Příklad: Refaktorizace UserService – before/after
<?php
// PŘED: God Service s přímou závislostí na Doctrine
namespace App\Service;
class UserService
{
public function __construct(
private EntityManagerInterface $em,
private MailerInterface $mailer
) {}
public function register(string $email, string $password): User
{
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new \InvalidArgumentException('Neplatný e-mail');
}
if (strlen($password) < 8) {
throw new \InvalidArgumentException('Heslo příliš krátké');
}
$existing = $this->em->getRepository(User::class)
->findOneBy(['email' => $email]);
if ($existing) {
throw new \RuntimeException('E-mail již existuje');
}
$user = new User();
$user->setEmail($email);
$user->setPassword(password_hash($password, PASSWORD_BCRYPT));
$user->setStatus('pending_verification');
$this->em->persist($user);
$this->em->flush();
$this->mailer->send(/* uvítací e-mail */);
return $user;
}
}
<?php
declare(strict_types=1);
// PO: Doménová entita s vlastními invarianty
namespace App\UserManagement\Domain\Model;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\HashedPassword;
use App\UserManagement\Domain\Event\UserRegistered;
class User
{
private readonly UserId $id;
private Email $email;
private HashedPassword $password;
private UserStatus $status;
private readonly \DateTimeImmutable $registeredAt;
private array $domainEvents = [];
private function __construct(
UserId $id,
Email $email,
HashedPassword $password
) {
$this->id = $id;
$this->email = $email;
$this->password = $password;
$this->status = UserStatus::PENDING_VERIFICATION;
$this->registeredAt = new \DateTimeImmutable();
// Doménová událost – side-effect registrace je nyní explicitní
$this->domainEvents[] = new UserRegistered($id, $email);
}
// Named constructor vyjadřuje záměr lépe než new User()
public static function register(
UserId $id,
Email $email,
HashedPassword $password
): self {
return new self($id, $email, $password);
}
public function activate(VerificationToken $token): void
{
if ($this->status !== UserStatus::PENDING_VERIFICATION) {
throw new \DomainException('Uživatel již byl aktivován nebo je zablokován.');
}
$token->validate(); // Token si validuje sám sebe
$this->status = UserStatus::ACTIVE;
}
public function releaseDomainEvents(): array
{
$events = $this->domainEvents;
$this->domainEvents = [];
return $events;
}
public function id(): UserId { return $this->id; }
public function email(): Email { return $this->email; }
public function status(): UserStatus { return $this->status; }
}
Doménová entita User nyní sama vynucuje svá pravidla: výchozí stav, přechod stavu
při aktivaci, emituje Domain Event při registraci. Kontroler ani service nemůže tyto invarianty
obejít.
Zavedení Value Objects místo primitive types
Primitive Obsession je code smell, při kterém jsou koncepty s business sémantikou reprezentovány
primitivními typy jako string nebo int. Value Object nahrazuje primitiv
objektem, který zapouzdřuje validaci a chování.
Příklad: Refaktorizace string emailu na Email Value Object
<?php
// PŘED: Email jako string – validace je rozptýlena v celé aplikaci
class UserController {
public function register(Request $request): Response {
$email = $request->request->get('email'); // string, nic negarantuje
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { /* ... */ }
// ... validace se opakuje na každém místě použití
}
}
// --- Soubor: Email.php ---
declare(strict_types=1);
// PO: Email jako Value Object – validace je na jednom místě
namespace App\UserManagement\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)
);
}
// Zakázané domény (business pravidlo)
$domain = substr($normalized, strpos($normalized, '@') + 1);
if (in_array($domain, ['example.com', 'test.com'], true)) {
throw new \DomainException(
'Registrace z testovacích domén není povolena.'
);
}
$this->value = $normalized;
}
public function value(): string
{
return $this->value;
}
public function domain(): string
{
return substr($this->value, strpos($this->value, '@') + 1);
}
public function equals(self $other): bool
{
return $this->value === $other->value;
}
public function __toString(): string
{
return $this->value;
}
}
Díky Value Objectu Email je validace zapouzdřena na jednom místě. Kdykoli je
vytvořena instance Email, máme garantovanou platnost hodnoty – bez ohledu na to,
kde v aplikaci k vytvoření dochází. Toto je základní princip "Make Illegal States Unrepresentable".
Krok 3: Zavedení repozitářů
V CRUD architektuře se pro přístup k datům typicky používá EntityManagerInterface nebo
Doctrine repozitáře přímo v kontrolerech a service třídách. DDD přináší doménové rozhraní repozitáře,
které abstrahuje persistenci od domény a umožňuje výměnu implementace (např. přechod z SQL na jiné
úložiště) bez změny doménového kódu.
Vytvoření doménového rozhraní repozitáře
Doménové rozhraní repozitáře je součástí doménové vrstvy – definuje, jaké operace jsou potřeba z pohledu domény. Neobsahuje žádné zmínky o Doctrine, SQL nebo jiné infrastrukturní technologii.
Příklad: Doménové rozhraní vs. Doctrine implementace
<?php
declare(strict_types=1);
// Doménové rozhraní – součást domény, žádná infrastrukturní závislost
namespace App\UserManagement\Domain\Repository;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\UserId;
interface UserRepository
{
public function save(User $user): void;
public function findById(UserId $id): ?User;
public function findByEmail(Email $email): ?User;
/** @return User[] */
public function findActiveUsers(): array;
public function nextIdentity(): UserId;
}
<?php
declare(strict_types=1);
// Infrastrukturní implementace – obaluje Doctrine EntityManager
namespace App\UserManagement\Infrastructure\Repository;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\Repository\UserRepository;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\UserId;
use Doctrine\ORM\EntityManagerInterface;
final class DoctrineUserRepository implements UserRepository
{
public function __construct(
private EntityManagerInterface $em
) {}
public function save(User $user): void
{
$this->em->persist($user);
// Flush je záměrně ponechán na aplikační vrstvě (Command Handler)
// aby byla možná transakční konzistence přes více agregátů
}
public function findById(UserId $id): ?User
{
return $this->em->find(User::class, $id->value());
}
public function findByEmail(Email $email): ?User
{
return $this->em->getRepository(User::class)
->findOneBy(['email.value' => $email->value()]);
}
public function findActiveUsers(): array
{
return $this->em->createQueryBuilder()
->select('u')
->from(User::class, 'u')
->where('u.status = :status')
->setParameter('status', 'active')
->getQuery()
->getResult();
}
public function nextIdentity(): UserId
{
return UserId::generate();
}
}
Doménová vrstva závisí pouze na rozhraní UserRepository. Symfony DI container
zajistí, že do doménových služeb bude injektována DoctrineUserRepository. Díky
tomu lze implementaci repozitáře vyměnit v konfiguračním souboru bez změny doménového kódu.
Konfigurace Dependency Injection v Symfony
# config/services.yaml
services:
App\UserManagement\Domain\Repository\UserRepository:
alias: App\UserManagement\Infrastructure\Repository\DoctrineUserRepository
Tato konfigurace zajistí, že Symfony automaticky injektuje Doctrine implementaci všude tam,
kde je typovaná závislost na doménovém rozhraní UserRepository.
Krok 4: Postupné zavedení CQRS
Command Query Responsibility Segregation (CQRS) je přirozené rozšíření DDD, avšak jeho zavedení by mělo přijít až poté, co je doménový model dobře etablován. Předčasné zavedení CQRS bez zralého doménového modelu vede k přesunu komplexity z doménové vrstvy do handleru, kde je neviditelná a hůře testovatelná.
Začít s Command stranou (write side)
Nejpřirozenějším místem pro zavedení CQRS je write side – operace, které mění stav systému. Query side (čtení) lze zpočátku ponechat s přímými Doctrine dotazy a refaktorovat ji samostatně, nebo ji ponechat jako optimalizované SQL dotazy i v DDD systému (read modely).
Příklad: Extrakce RegisterUserCommand z UserController
<?php
// PŘED: Logika přímo v kontroleru nebo service
namespace App\Controller;
class UserController extends AbstractController
{
public function __construct(
private UserService $userService
) {}
#[Route('/users/register', methods: ['POST'])]
public function register(Request $request): Response
{
// Kontroler musí vědět, jaké parametry service očekává
$this->userService->register(
$request->request->get('email'),
$request->request->get('password'),
$request->request->get('name')
);
return $this->json(['status' => 'ok'], 201);
}
}
<?php
// --- Soubor: RegisterUser.php ---
// PO: Command objekt jako explicitní kontrakt
namespace App\UserManagement\Application\Command;
final readonly class RegisterUser
{
public function __construct(
public string $email,
public string $password,
public string $name,
) {}
}
// --- Soubor: RegisterUserHandler.php ---
// Handler zapouzdřuje aplikační logiku jednoho use case
namespace App\UserManagement\Application\Command;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\Repository\UserRepository;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\HashedPassword;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
#[AsMessageHandler]
final class RegisterUserHandler
{
public function __construct(
private UserRepository $users,
private UserRegistrationPolicy $policy,
) {}
public function __invoke(RegisterUser $command): void
{
$email = new Email($command->email);
$password = HashedPassword::fromPlaintext($command->password);
// Doménová politika ověřuje business pravidla přes repozitář
$this->policy->assertEmailIsUnique($email);
$user = User::register(
$this->users->nextIdentity(),
$email,
$password
);
$this->users->save($user);
// Domain Events jsou zpracovány Symfony Messengerem
foreach ($user->releaseDomainEvents() as $event) {
// event dispatch je řešen infrastrukturní vrstvou
}
}
}
// --- Soubor: UserController.php ---
// Kontroler je nyní tenký – pouze HTTP adaptér
namespace App\Controller;
use App\UserManagement\Application\Command\RegisterUser;
use Symfony\Component\Messenger\MessageBusInterface;
class UserController extends AbstractController
{
public function __construct(
private MessageBusInterface $commandBus
) {}
#[Route('/users/register', methods: ['POST'])]
public function register(Request $request): Response
{
$this->commandBus->dispatch(new RegisterUser(
email: $request->request->getString('email'),
password: $request->request->getString('password'),
name: $request->request->getString('name'),
));
return $this->json(['status' => 'ok'], 201);
}
}
Command RegisterUser je prosté DTO (Data Transfer Object) bez závislostí. Handler
RegisterUserHandler orchestruje doménový model. Kontroler je redukován na HTTP
vrstvu, která pouze přeloží HTTP požadavek na Command. Tato separace odpovědností výrazně
zlepšuje testovatelnost každé vrstvy zvlášť.
Testování při migraci
Testování je kritickým aspektem migrace. Bez dostatečného pokrytí testy hrozí, že refaktoring zavede regrese, které jsou odhaleny až v produkci. Strategie testování při migraci z CRUD na DDD kombinuje dvě techniky: charakterizační testy pro zachycení stávajícího chování a postupné doplňování unit testů pro novou doménovou vrstvu.
Charakterizační testy (Characterization Tests)
Pojem "charakterizační testy" pochází z knihy Michaela Featherse "Working Effectively with Legacy Code" [5]. Charakterizační test nepopisuje, jaké by mělo být správné chování systému, ale zachycuje jaké chování systém aktuálně má. Slouží jako síť, která zachytí nechtěné změny chování při refaktoringu.
Příklad: Charakterizační test pro CRUD kontroler
<?php
declare(strict_types=1);
namespace Tests\Characterization;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* Charakterizační testy zachycují AKTUÁLNÍ chování systému.
* Jsou záměrně popsány jako "chová se tak, jak se chová",
* ne "mělo by se chovat tak a tak".
* Pokud refaktoring změní toto chování, test selže a upozorní tým.
*/
class UserRegistrationCharacterizationTest extends WebTestCase
{
public function test_registration_returns_201_with_valid_data(): void
{
$client = static::createClient();
$client->request('POST', '/users/register', [
'email' => 'test@example-valid.com',
'password' => 'SecurePassword123',
'name' => 'Jan Novák',
]);
// Zachycujeme aktuální HTTP status kód
self::assertResponseStatusCodeSame(201);
}
public function test_registration_returns_422_for_invalid_email(): void
{
$client = static::createClient();
$client->request('POST', '/users/register', [
'email' => 'not-an-email',
'password' => 'SecurePassword123',
'name' => 'Jan Novák',
]);
// Zachycujeme aktuální chybový kód a strukturu odpovědi
self::assertResponseStatusCodeSame(422);
$data = json_decode($client->getResponse()->getContent(), true);
self::assertArrayHasKey('error', $data);
}
public function test_duplicate_email_returns_409(): void
{
$client = static::createClient();
// První registrace
$client->request('POST', '/users/register', [
'email' => 'duplicate@example-valid.com',
'password' => 'SecurePassword123',
'name' => 'Jan Novák',
]);
// Druhá registrace se stejným e-mailem
$client->request('POST', '/users/register', [
'email' => 'duplicate@example-valid.com',
'password' => 'AnotherPassword456',
'name' => 'Jiný Uživatel',
]);
self::assertResponseStatusCodeSame(409);
}
}
Charakterizační testy jsou psány před refaktoringem. Cílem je, aby všechny procházely po celou dobu migrace – selhání testu signalizuje, že refaktoring změnil pozorovatelné chování systému, ať záměrně nebo omylem.
Unit testy doménové vrstvy
Jednou z největších výhod DDD je, že doménové objekty jsou testovatelné v izolaci bez nutnosti databáze, HTTP klienta nebo jiné infrastruktury. Unit testy doménové vrstvy jsou rychlé, deterministické a přesně dokumentují doménová pravidla.
Příklad: Unit test doménové entity
<?php
declare(strict_types=1);
namespace Tests\UserManagement\Domain\Model;
use App\UserManagement\Domain\Model\User;
use App\UserManagement\Domain\ValueObject\UserId;
use App\UserManagement\Domain\ValueObject\Email;
use App\UserManagement\Domain\ValueObject\HashedPassword;
use App\UserManagement\Domain\Event\UserRegistered;
use PHPUnit\Framework\TestCase;
final class UserTest extends TestCase
{
public function test_newly_registered_user_is_pending_verification(): void
{
$user = User::register(
UserId::generate(),
new Email('jan@firma.cz'),
HashedPassword::fromPlaintext('SecurePass123')
);
self::assertTrue($user->status()->isPendingVerification());
}
public function test_registration_emits_user_registered_event(): void
{
$user = User::register(
UserId::generate(),
new Email('jan@firma.cz'),
HashedPassword::fromPlaintext('SecurePass123')
);
$events = $user->releaseDomainEvents();
self::assertCount(1, $events);
self::assertInstanceOf(UserRegistered::class, $events[0]);
}
public function test_cannot_activate_already_active_user(): void
{
$user = User::register(/* ... */);
$token = VerificationToken::valid('abc123');
$user->activate($token);
$this->expectException(\DomainException::class);
$user->activate($token); // druhá aktivace musí selhat
}
}
Rizika a doporučení
Nejčastější chyby při migraci
-
Anémický doménový model – Nejčastější past. Vývojáři vytvoří třídy s názvem
jako v DDD (
User,Order), ale tyto třídy obsahují pouze gettery a settery bez doménové logiky. Logika zůstane v service třídách. Výsledek je DDD terminologie s CRUD implementací. - Přílišná granularita Bounded Contexts – Rozdělení domény na příliš mnoho malých kontextů vede k distribuované komplexitě. Každá integrace mezi kontexty přidává overhead. Začněte s většími kontexty a rozdělujte je až tehdy, když je důvod k tomu jasný.
- Doctrine entity jako doménové entity – Přímé přidávání DDD logiky do Doctrine entit je antipattern. Doctrine mapování (anotace, atributy) svazuje doménový objekt s infrastrukturní technologií. Oddělte doménové entity od persistence mapování.
- CQRS bez doménového modelu – Zavedení CommandBusu a QueryBusu bez refaktorovaného doménového modelu přidá vrstvy komplexity bez přínosu. CQRS je amplifikátor – zesílí jak výhody, tak problémy stávající architektury.
- Ignorování Anti-Corruption Layer – Při integraci nové DDD vrstvy se starým CRUD kódem je nutné vytvořit překladovou vrstvu. Bez ní pronikají koncepty starého modelu do nového a kontaminují ho.
Tipy pro týmovou komunikaci
- Vytvořte glosář pojmů (Ubiquitous Language) a udržujte ho aktuální. Vyvěste ho na wiki nebo přímo v repozitáři jako součást dokumentace.
- Pravidelně pořádejte krátké Event Storming sessiony (30–60 minut) pro nové funkcionality před jejich implementací.
- Nastavte code review pravidla: doménová logika nesmí být v kontrolerech, doménové objekty nesmějí záviset na infrastruktuře.
- Komunikujte s managementem v pojmech business hodnoty, nikoli technické architektury. Migrace na DDD = schopnost rychleji a bezpečněji přidávat nové funkce.
Realistické odhady náročnosti
Zkušenosti z praxe ukazují, že migrace středně velké CRUD aplikace (50–100 tabulek, 3–5 let vývoje) na DDD architekturu trvá při inkrementálním přístupu 12 až 24 měsíců. Tato čísla předpokládají, že migrace probíhá souběžně s vývojem nových funkcionalit a nevěnuje se jí dedikovaný tým na plný úvazek. Faktory, které dobu prodlužují: špatná testovatelnost stávajícího kódu (nutnost psát charakterizační testy), nedostatečná znalost domény v týmu, absence doménových expertů.
Varování před Big Bang Rewrites
Nikdy nezačínejte migraci na DDD kompletním přepisem systému. Big Bang Rewrite je architektonicky nejrizikovější rozhodnutí, které může tým učinit. Typický scénář: tým začne "přepis na zelenou louku", po 6 měsících zjistí, že nový systém nesplňuje všechny edge cases původního systému (které nikdo nezdokumentoval), původní systém dostává mezitím nové funkcionality, nový systém za ním nestíhá. Výsledkem je buď zrušení projektu přepisu, nebo spuštění nedokončeného systému s fatálními chybami.
Vždy preferujte inkrementální migraci pomocí Strangler Fig Patternu: zachovejte funkční systém v produkci, přidávejte DDD vrstvami a nahrazujte CRUD kód postupně při každém sprintu.
Pro hlubší pochopení DDD konceptů a jejich implementace v Symfony doporučujeme prostudovat kapitolu Implementace DDD v Symfony a CQRS v Symfony.