Případová studie
Detailní případová studie implementace Domain-Driven Design v Symfony 8 na kompletním projektu – celý proces od analýzy domény, identifikace bounded contexts a strategického i taktického designu až po implementaci s využitím DDD principů a CQRS.
Obsah kapitoly
24.01 Úvod#
Ilustrativní scénář: tým, čísla i rozhodnutí v této kapitole jsou smyšlené. Slouží jako souvislá ukázka, jak DDD a CQRS drží pohromadě napříč jedním projektem.
Tým dostal zadání postavit systém pro správu projektů. Uživatelé zakládají projekty, přidávají úkoly, přiřazují
je členům týmu, mění jejich stav a komentují je. Triviální zadání. První instinkt vývojáře je tabulka projects,
tabulka tasks s cizím klíčem, tabulka comments a TaskService, který vše obslouží. Za tři měsíce má TaskService
osm set řádků a každá změna v přiřazování úkolů rozbije reportování. Tato studie ukazuje druhou cestu –
strategický a taktický DDD s CQRS v Symfony 8 od prvního workshopu po projekce s reconciliation.
24.02 Požadavky#
Systém pro správu projektů má následující požadavky:
- Uživatelé se mohou registrovat a přihlašovat.
- Uživatelé mohou vytvářet projekty.
- Uživatelé mohou přidávat úkoly do projektů.
- Uživatelé mohou přiřazovat úkoly členům týmu.
- Uživatelé mohou měnit stav úkolů (To Do, In Progress, Done).
- Uživatelé mohou přidávat komentáře k úkolům.
- Uživatelé mohou sledovat aktivitu na projektech a úkolech.
- Systém musí být škálovatelný a udržitelný.
24.03 Doménová analýza#
Architektura začíná u rozhovoru s doménovými experty, ne u kódu. Než přijde rozhodnutí o tabulkách a třídách, musí tým vědět, co se v doméně děje a kde leží hranice. Pět bounded contexts z následující sekce Architektura nevypadlo z hlavy architekta – vyplynulo ze tří kroků event stormingu: sběru doménových událostí, jejich seskupení do subdomén a vykreslení kontextových hranic. Formát pochází od Alberta Brandoliniho; notaci, průběh workshopu i jeho anti-vzory rozebírá kapitola Event Storming.
Krok 1: Sběr doménových událostí
První workshop směřoval k otázce „co se v systému děje“. Doménoví experti formulovali v chronologickém pořadí události, které pro ně mají význam. Seznam vznikl bez ohledu na strukturu kódu, frameworku nebo databáze – cílem je zachytit slovník (Ubiquitous Language), ne implementaci.
- Uživatel se zaregistroval.
- Uživatel se přihlásil.
- Uživatel vytvořil projekt.
- Vlastník projektu pozval dalšího uživatele jako člena.
- Pozvaný uživatel přijal pozvánku do projektu.
- Vlastník projektu odebral člena.
- Člen projektu přidal úkol.
- Vlastník přiřadil úkol členovi.
- Přiřazený člen převzal úkol (stav
To Do→In Progress). - Přiřazený člen dokončil úkol (
In Progress→Done). - Člen projektu přidal komentář k úkolu.
- Autor komentáře komentář upravil.
- Systém zaznamenal aktivitu pro audit.
Slovník událostí odhalil několik rozhodnutí ještě před prvním řádkem kódu. Slovo „uživatel“ má v každém kontextu jiný význam: v UserManagement je to identita s e-mailem a heslem, v ProjectManagement je to vlastník nebo člen, v TaskManagement přiřazený řešitel a v CommentManagement autor textu. Stejné slovo, jiná odpovědnost. Právě toto zjištění je zárodkem rozdělení do bounded contexts.
Krok 2: Seskupení událostí do subdomén
Tým druhý den shlukoval události podle významu. Otázka pro každou skupinu zněla: kdo z byznysu za toto odpovídá? Skupina, které rozumí jediný expert, je kandidát na subdoménu. Výsledkem byla mapa událostí na subdomény:
| Subdoména | Událost | Doménový expert |
|---|---|---|
| UserManagement | UserRegistered | Bezpečnostní administrátor |
| UserManagement | UserSignedIn | Bezpečnostní administrátor |
| ProjectManagement | ProjectCreated | Projektový manažer |
| ProjectManagement | MemberAdded | Projektový manažer |
| ProjectManagement | MemberRemoved | Projektový manažer |
| TaskManagement | TaskCreated | Týmový vedoucí |
| TaskManagement | TaskAssigned | Týmový vedoucí |
| TaskManagement | TaskStatusChanged | Týmový vedoucí |
| CommentManagement | CommentAdded | Týmový vedoucí |
| CommentManagement | CommentEdited | Týmový vedoucí |
| ActivityTracking | ActivityRecorded | Compliance / interní audit |
Sloupec Doménový expert není dekorativní. Pomáhá ověřit, že se hranice kontextů skutečně kryjí s organizační realitou. Pokud by jeden kontext potřeboval čtyři různé experty, je to signál, že jde o agregaci nesouvisejících odpovědností. Pokud naopak dva kontexty řídí stejný expert, mohou být kandidáty na sloučení – nebo signálem, že expert pokrývá víc rolí, než je zdravé.
Klasifikace subdomén
Pět subdomén neznamená pět stejně důležitých subdomén. Před převodem na kontexty zařadil tým každou z nich do jedné ze tří kategorií podle kapitoly Subdomény. Zařazení rozhoduje o tom, kolik modelování si která část zaslouží.
| Subdoména | Kategorie | Důsledek pro návrh |
|---|---|---|
| ProjectManagement | Core | vlastní model, bohaté invarianty, nejvíc času na workshopu |
| TaskManagement | Core | stavový automat úkolu je to, čím se produkt liší |
| CommentManagement | Supporting | malý model, žádná investice do taktických vzorů navíc |
| ActivityTracking | Supporting | append-only log bez invariantů |
| UserManagement | Generic | registrace, přihlášení, reset hesla – vyřešený problém |
Zařazení UserManagement mezi Generic jde proti prvnímu instinktu postavit vlastní autentizaci. Kolik taková volba stojí, ukazuje Subdomény. Kontext ve studii zůstává, protože nese hranici a vztah v kontextové mapě, jeho model je ale tenký: identita, e-mail a delegace na Symfony Security, respektive na externího poskytovatele identity. Doménová práce se soustředí do dvou Core kontextů.
Bez tohoto kroku dostane každý kontext stejnou investici do modelu. Přesně tomu se říká anti-vzor „všechno je Core“.
Krok 3: Definice kontextových hranic
Třetí krok převedl subdomény na bounded contexts – jednotky, ve kterých má slovník jeden význam, model jedny invarianty a kód jednu modulovou hranici. Kritéria pro hranici byla tři:
- Sémantická koherence – slova uvnitř kontextu mají jeden význam. Pokud uvnitř téhož kontextu znamená „status“ jednou stav úkolu a podruhé stav projektu, je to signál pro rozdělení.
- Vlastnictví domény – každý kontext má jednoho doménového experta odpovědného za pravidla a slovník. Bez identifikovatelného vlastníka jsou rozhodnutí o modelu náhodná.
- Tempo změn – části systému, které se mění společně, patří do téhož kontextu. Pokud změna v TaskManagement opakovaně vynucuje úpravu v CommentManagement, je hranice mezi nimi špatně vedená.
Převod dopadl 1:1 – z každé subdomény vznikl právě jeden kontext. Pravidlo to není: Core subdoména se běžně rozpadá do několika kontextů a několik Supporting subdomén se naopak vejde do jednoho (Subdomény).
V tomto projektu zafungovala všechna tři kritéria společně. Kompletní mapa vztahů mezi kontexty (Partnership, Customer-Supplier, Open Host Service) je v sekci Architektura. Hlubší teoretický základ pro identifikaci kontextů poskytují kapitoly Co je Domain-Driven Design a Základní koncepty DDD.
24.04 Architektura#
Strategická úroveň drží pět bounded contexts a kontextovou mapu jejich vztahů; typy vztahů, které mapa používá, zavádí kapitola Bounded Context a Context Mapping. Na taktické úrovni žijí agregáty, hodnotové objekty, doménové události a doménové služby. Kód je organizovaný do vertikálních sliců: každá feature obsahuje vše od příkazu po view model. Změna v přiřazování úkolů se neprojeví v reportování, protože obě věci žijí v různých slicích a komunikují přes explicitní kontrakty.
Strategický design: Bounded Contexts a Context Map
Identifikace bounded contexts vychází z doménové analýzy v sekci 24.03. Systém je rozdělen do následujících kontextů:
- UserManagement – identita, registrace, autentizace; vlastník přístupových práv uživatelů.
- ProjectManagement – životní cyklus projektů a členství uživatelů v projektu.
- TaskManagement – úkoly, jejich přiřazování a stavové přechody.
- CommentManagement – komentáře a zpětná vazba k úkolům.
- ActivityTracking – auditní stopa nad událostmi z ostatních kontextů.
Vztahy zachycené v kontextové mapě:
- UserManagement ⟷ ProjectManagement – Partnership. Oba kontexty ovlivňují společný model členství v projektu. Změna kontraktu vyžaduje koordinaci obou týmů.
- ProjectManagement → TaskManagement – Customer / Supplier. ProjectManagement určuje, jaký kontrakt o existenci a členství projektu TaskManagement potřebuje; TaskManagement se přizpůsobuje upstreamu.
- TaskManagement → CommentManagement – Customer / Supplier. Komentář drží
TaskIda bez úkolu ztrácí smysl, takže kontrakt určuje TaskManagement. CommentManagement je downstream a přizpůsobuje se. - Všechny kontexty → ActivityTracking – doménové události na sdílené sběrnici.
Diagram tento vztah popisuje jako Open Host Service / Published Language, což sedí jen zčásti:
publikované události nesou
ProjectId,UserIdaTaskStatus, tedy interní typy vydávajícího kontextu. Published Language je proti tomu samostatný výměnný formát, díky kterému konzument závisí na schématu události, ne na třídách publishera. Dokud tenká integrační událost s primitivy nevznikne, jde o publikaci interního modelu ven (Open Host Service, Published Language). - Sdílené identifikátory –
UserId,ProjectIdaTaskIdtvoří minimální Shared Kernel. V diagramu stojí ve vlastním balíčku, v kódu žijí ve vlastnickém kontextu a ostatní je importují. Evansova podmínka vzoru je závazek koordinovat každou změnu; tady ho drží jediná okolnost – tým je jeden. Cena a alternativa jsou v sekci 24.07.2.
Hranici mezi TaskManagement a ProjectManagement drží port, ne Anti-Corruption Layer. Oba
kontexty pracují s týmiž třídami ProjectId a UserId importovanými z vlastnických kontextů,
takže se nic nepřekládá. Zbývá obrácení závislosti: port ProjectChecker je definovaný v doméně
TaskManagement a jeho infrastrukturní implementace je adaptér do ProjectManagement.
Anti-Corruption Layer v Evansově smyslu z něj bude ve chvíli, kdy do
adaptéru přibude překlad mezi dvěma modely – například až ProjectManagement odejde do vlastní
služby s vlastním tvarem odpovědi. Popisek „ACL“ v diagramu tedy pojmenovává cílový stav, ne
dnešní. Synchronní vs. asynchronní volba je popsaná
v sekci 24.07.3.
Pro asynchronní integraci mezi kontexty slouží doménové události publikované přes Symfony Messenger. Konkrétní ukázka projekce, která naslouchá událostem ze tří kontextů, je v sekci 24.06.
Taktický design a struktura projektu
Implementace na taktické úrovni stojí na těchto vzorech. Základ tvoří entity – objekty s identitou, které se v čase mění (User, Project, Task) – a hodnotové objekty, neměnné nositele konceptů domény bez vlastní identity (UserId, ProjectId, TaskStatus). Nad nimi stojí čtyři další stavební kameny. Agregát drží skupinu objektů, kterou doména mění jako jednu jednotku; zde jím je Project a samostatně Task. Doménová událost zaznamenává, co se stalo a co má význam pro doménové experty (ProjectCreated, TaskAssigned). Repozitář zapouzdřuje persistenci agregátu, takže doménový kód o databázi neví. A doménová služba nese pravidlo, které nepatří žádné entitě ani hodnotovému objektu – v této studii TaskAssignmentService, jehož existenci rozebírá sekce 24.07.4.
Struktura adresářů odráží oba designy zároveň. Každý bounded context má vlastní doménovou vrstvu, infrastrukturu i feature slice; sdílené komponenty žijí v SharedKernel/:
1src/2├── UserManagement/ # Bounded Context: Správa uživatelů3│ ├── Domain/ # Doménová vrstva4│ │ ├── Model/ # Doménové modely5│ │ │ └── User.php # Entita uživatele (Aggregate Root)6│ │ ├── ValueObject/ # Hodnotové objekty7│ │ │ ├── UserId.php8│ │ │ └── Email.php9│ │ ├── Event/ # Doménové události10│ │ │ └── UserRegistered.php11│ │ └── Repository/ # Repozitáře (rozhraní)12│ │ └── UserRepository.php13│ ├── Infrastructure/ # Infrastrukturní vrstva14│ │ └── Repository/ # Implementace repozitářů15│ │ └── DoctrineUserRepository.php16│ ├── Registration/ # Feature: Registrace uživatele17│ │ ├── Command/ # Příkazy18│ │ │ ├── RegisterUser.php19│ │ │ └── RegisterUserHandler.php20│ │ └── Controller/ # Kontrolery21│ │ └── RegistrationController.php22│ ├── Authentication/ # Feature: Autentizace23│ │ └── Controller/ # Kontrolery24│ │ └── SecurityController.php25│ └── GetUser/ # Feature: Získání uživatele26│ ├── Query/ # Dotazy27│ │ ├── GetUser.php28│ │ └── GetUserHandler.php29│ └── ViewModel/ # View modely30│ └── UserViewModel.php31├── ProjectManagement/ # Bounded Context: Správa projektů32│ ├── Domain/33│ │ ├── Model/34│ │ │ ├── Project.php # Entita projektu (Aggregate Root)35│ │ │ └── ProjectMember.php36│ │ ├── ValueObject/37│ │ │ ├── ProjectId.php38│ │ │ └── ProjectStatus.php39│ │ ├── Event/40│ │ │ ├── ProjectCreated.php41│ │ │ └── MemberAdded.php42│ │ ├── Exception/ # Pojmenované doménové výjimky43│ │ │ └── ProjectOwnerCannotBeRemovedException.php44│ │ └── Repository/45│ │ └── ProjectRepository.php46│ ├── Infrastructure/47│ │ └── Repository/48│ │ └── DoctrineProjectRepository.php49│ ├── CreateProject/ # Feature: Vytvoření projektu50│ │ ├── Command/51│ │ │ ├── CreateProject.php52│ │ │ └── CreateProjectHandler.php53│ │ └── Controller/54│ │ └── ProjectController.php55│ └── GetProjects/ # Feature: Seznam projektů56│ ├── Query/57│ │ ├── GetProjects.php58│ │ └── GetProjectsHandler.php59│ ├── Controller/60│ │ └── ProjectsController.php61│ └── ViewModel/62│ └── ProjectViewModel.php63├── TaskManagement/ # Bounded Context: Správa úkolů64│ ├── Domain/65│ │ ├── Model/66│ │ │ └── Task.php # Entita úkolu (Aggregate Root)67│ │ ├── ValueObject/68│ │ │ ├── TaskId.php69│ │ │ └── TaskStatus.php70│ │ ├── Event/71│ │ │ ├── TaskCreated.php72│ │ │ ├── TaskAssigned.php73│ │ │ └── TaskStatusChanged.php74│ │ ├── Service/ # Doménové služby75│ │ │ └── TaskAssignmentService.php76│ │ ├── Port/ # Porty do jiných kontextů77│ │ │ └── ProjectChecker.php78│ │ ├── Exception/79│ │ │ ├── InvalidTaskStateTransitionException.php80│ │ │ └── TaskNotFoundException.php81│ │ └── Repository/82│ │ └── TaskRepository.php83│ ├── Infrastructure/84│ │ └── Repository/85│ │ └── DoctrineTaskRepository.php86│ ├── CreateTask/ # Feature: Vytvoření úkolu87│ │ ├── Command/88│ │ │ ├── CreateTask.php89│ │ │ └── CreateTaskHandler.php90│ │ └── Controller/91│ │ └── TaskController.php92│ ├── AssignTask/ # Feature: Přiřazení úkolu93│ │ ├── Command/94│ │ │ ├── AssignTask.php95│ │ │ └── AssignTaskHandler.php96│ │ └── Controller/97│ │ └── AssignController.php98│ ├── ChangeStatus/ # Feature: Změna stavu úkolu99│ │ ├── Command/100│ │ │ ├── ChangeTaskStatus.php101│ │ │ └── ChangeTaskStatusHandler.php102│ │ └── Controller/103│ │ └── StatusController.php104│ └── GetTask/ # Feature: Získání úkolu105│ ├── Query/106│ │ ├── GetTask.php107│ │ └── GetTaskHandler.php108│ └── ViewModel/109│ └── TaskViewModel.php110├── CommentManagement/ # Bounded Context: Správa komentářů111│ ├── Domain/112│ │ ├── Model/113│ │ │ └── Comment.php114│ │ ├── ValueObject/115│ │ │ └── CommentId.php116│ │ ├── Event/117│ │ │ └── CommentAdded.php118│ │ └── Repository/119│ │ └── CommentRepository.php120│ ├── Infrastructure/121│ │ └── Repository/122│ │ └── DoctrineCommentRepository.php123│ └── AddComment/ # Feature: Přidání komentáře124│ ├── Command/125│ │ ├── AddComment.php126│ │ └── AddCommentHandler.php127│ └── Controller/128│ └── CommentController.php129├── ActivityTracking/ # Bounded Context: Sledování aktivity130│ ├── Domain/131│ │ ├── Model/132│ │ │ └── Activity.php133│ │ ├── ValueObject/134│ │ │ └── ActivityId.php135│ │ └── Repository/136│ │ └── ActivityRepository.php137│ ├── Infrastructure/138│ │ └── Repository/139│ │ └── DoctrineActivityRepository.php140│ └── RecordActivity/ # Feature: Zaznamenání aktivity141│ ├── Command/142│ │ ├── RecordActivity.php143│ │ └── RecordActivityHandler.php144│ └── Controller/145│ └── ActivityController.php146└── SharedKernel/ # Sdílené komponenty147 ├── Domain/ # Sdílená doménová logika148 │ ├── AggregateRoot.php # Bázová třída agregátu (record/releaseEvents)149 │ ├── Exception/ # Výjimky150 │ │ └── DomainException.php # Základní doménová výjimka151 │ └── Bus/ # Rozhraní pro message bus152 │ ├── CommandBus.php # Rozhraní pro command bus153 │ └── QueryBus.php # Rozhraní pro query bus154 └── Infrastructure/ # Sdílená infrastruktura155 ├── Bus/ # Implementace message bus156 │ ├── MessengerCommandBus.php # Implementace command bus157 │ └── MessengerQueryBus.php # Implementace query bus158 └── Persistence/ # Sdílená persistence159 └── DoctrineTypes/ # Vlastní Doctrine typy160 └── UuidType.php # Typ pro UUID
24.05 Implementace#
Sekce prochází jádro systému – od slovníku přes agregáty a doménové události až po command a query stranu CQRS.
Ubiquitous Language
Slovník vznikl s doménovými experty ještě před prvním řádkem kódu. Tytéž pojmy najdete ve třídách, v rozhovoru s produktovým manažerem i v ticketech. Hlavní pojmy:
- Project – Organizační jednotka, která sdružuje související úkoly a členy týmu.
- Task – Jednotka práce, která má být dokončena v projektu.
- Assignee – Člen týmu, kterému je přiřazen úkol.
- Status – Stav úkolu (To Do, In Progress, Done).
- Comment – Textová zpětná vazba k úkolu.
- Activity – Záznam o akci provedené v systému.
Doménový model: Projekt (kořen agregátu)
Agregát používá Doctrine atributy přímo na doménové třídě – jako pragmatickou výchozí volbu,
v souladu s kapitolou 10. Třída dědí
z AggregateRoot (sdílené chování pro record a releaseEvents, viz
lifecycle agregátu) a je final,
protože dědit z agregátu nechceme. Konstruktor je private
a vznik agregátu probíhá přes statickou factory metodu create().
final třída s public readonly vlastnostmi má na Doctrine entitě jednu podmínku: nativní lazy
objekty. Zapíná je $config->enableNativeLazyObjects(true) na PHP 8.4; metoda přibyla v ORM 3.4.0
a od 3.5 je starý režim generovaných proxy vedený jako zastaralý. Bez nich potřebuje Doctrine proxy
odvozenou z entity a final ani readonly neprojdou.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Domain\Model;6 7use App\ProjectManagement\Domain\Event\MemberAdded;8use App\ProjectManagement\Domain\Event\MemberRemoved;9use App\ProjectManagement\Domain\Event\ProjectCreated;10use App\ProjectManagement\Domain\Exception\ProjectOwnerCannotBeRemovedException;11use App\ProjectManagement\Domain\ValueObject\ProjectId;12use App\SharedKernel\Domain\AggregateRoot;13// UserId žije v UserManagement; ostatní kontexty ho importují (viz sekci 24.07.2)14use App\UserManagement\Domain\ValueObject\UserId;15use Doctrine\ORM\Mapping as ORM;16 17#[ORM\Entity]18#[ORM\Table(name: 'projects')]19final class Project extends AggregateRoot20{21 #[ORM\Id]22 #[ORM\Column(type: 'project_id')]23 public readonly ProjectId $id;24 25 #[ORM\Column(type: 'string', length: 255)]26 private string $name;27 28 #[ORM\Column(type: 'text', nullable: true)]29 private ?string $description;30 31 #[ORM\Column(type: 'user_id')]32 public readonly UserId $ownerId;33 34 /** @var list<UserId> */35 #[ORM\Column(type: 'user_id_list')]36 private array $memberIds = [];37 38 #[ORM\Column(type: 'datetime_immutable')]39 public readonly \DateTimeImmutable $createdAt;40 41 #[ORM\Column(type: 'datetime_immutable', nullable: true)]42 private ?\DateTimeImmutable $updatedAt = null;43 44 #[ORM\Version]45 #[ORM\Column(type: 'integer')]46 private int $version = 1;47 48 private function __construct(ProjectId $id, string $name, ?string $description, UserId $ownerId)49 {50 $this->id = $id;51 $this->name = $name;52 $this->description = $description;53 $this->ownerId = $ownerId;54 $this->memberIds = [$ownerId];55 $this->createdAt = new \DateTimeImmutable();56 }57 58 public static function create(ProjectId $id, string $name, ?string $description, UserId $ownerId): self59 {60 $project = new self($id, $name, $description, $ownerId);61 $project->record(new ProjectCreated($id, $name, $ownerId));62 63 return $project;64 }65 66 public function name(): string67 {68 return $this->name;69 }70 71 public function description(): ?string72 {73 return $this->description;74 }75 76 /** @return list<UserId> */77 public function memberIds(): array78 {79 return $this->memberIds;80 }81 82 public function addMember(UserId $userId): void83 {84 foreach ($this->memberIds as $existingId) {85 if ($existingId->equals($userId)) {86 return; // již je členem – idempotentní operace87 }88 }89 $this->memberIds[] = $userId;90 $this->updatedAt = new \DateTimeImmutable();91 92 $this->record(new MemberAdded($this->id, $userId));93 }94 95 public function removeMember(UserId $userId): void96 {97 if ($this->ownerId->equals($userId)) {98 throw ProjectOwnerCannotBeRemovedException::forProject($this->id);99 }100 101 $before = count($this->memberIds);102 $this->memberIds = array_values(array_filter(103 $this->memberIds,104 fn(UserId $id) => !$id->equals($userId),105 ));106 107 if (count($this->memberIds) === $before) {108 return; // nebyl členem – idempotentní operace109 }110 111 $this->updatedAt = new \DateTimeImmutable();112 $this->record(new MemberRemoved($this->id, $userId));113 }114 115 public function rename(string $newName): void116 {117 if ($this->name === $newName) {118 return;119 }120 $this->name = $newName;121 $this->updatedAt = new \DateTimeImmutable();122 }123 124 public function changeDescription(?string $newDescription): void125 {126 if ($this->description === $newDescription) {127 return;128 }129 $this->description = $newDescription;130 $this->updatedAt = new \DateTimeImmutable();131 }132 133 public function updatedAt(): ?\DateTimeImmutable134 {135 return $this->updatedAt;136 }137}
Výjimky nesou jméno pravidla, které se porušilo. ProjectOwnerCannotBeRemovedException dědí
z \DomainException a nabízí statickou factory metodu; tvar ukazuje
kapitola 10.
Sloupec version s #[ORM\Version] zapíná optimistické zamykání. Ukázkové handlery ale verzi
nikam nepředávají, takže konflikt odhalí až Doctrine při flush(). Kontrola očekávané verze
v handleru (find($id, LockMode::OPTIMISTIC, $expectedVersion)) je krok, který ve studii chybí.
rename() a changeDescription() žádnou událost neemitují. Projekce ze
sekce 24.06 se o změně nedozví a read model zůstává zastaralý až do běhu
reconcileru – drift jména patří k rozdílům, které reconciler dorovnává právě proto.
Doménový model: Úkol (kořen agregátu)
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\Domain\Model;6 7use App\TaskManagement\Domain\Event\TaskCreated;8use App\TaskManagement\Domain\Event\TaskAssigned;9use App\TaskManagement\Domain\Event\TaskStatusChanged;10use App\TaskManagement\Domain\Exception\InvalidTaskStateTransitionException;11use App\TaskManagement\Domain\ValueObject\TaskId;12use App\TaskManagement\Domain\ValueObject\TaskStatus;13use App\SharedKernel\Domain\AggregateRoot;14// ProjectId a UserId se importují z vlastnických kontextů (viz sekci 24.07.2)15use App\ProjectManagement\Domain\ValueObject\ProjectId;16use App\UserManagement\Domain\ValueObject\UserId;17 18final class Task extends AggregateRoot19{20 private readonly TaskId $id;21 private string $title;22 private ?string $description;23 private readonly ProjectId $projectId;24 private ?UserId $assigneeId = null;25 private TaskStatus $status;26 private readonly \DateTimeImmutable $createdAt;27 private ?\DateTimeImmutable $updatedAt = null;28 29 private function __construct(TaskId $id, string $title, ?string $description, ProjectId $projectId)30 {31 $this->id = $id;32 $this->title = $title;33 $this->description = $description;34 $this->projectId = $projectId;35 $this->status = TaskStatus::Todo;36 $this->createdAt = new \DateTimeImmutable();37 }38 39 public static function create(TaskId $id, string $title, ?string $description, ProjectId $projectId): self40 {41 $task = new self($id, $title, $description, $projectId);42 $task->record(new TaskCreated($id, $title, $projectId));43 44 return $task;45 }46 47 public function id(): TaskId48 {49 return $this->id;50 }51 52 public function title(): string53 {54 return $this->title;55 }56 57 public function description(): ?string58 {59 return $this->description;60 }61 62 public function projectId(): ProjectId63 {64 return $this->projectId;65 }66 67 public function assigneeId(): ?UserId68 {69 return $this->assigneeId;70 }71 72 public function status(): TaskStatus73 {74 return $this->status;75 }76 77 public function assign(UserId $assigneeId): void78 {79 $this->assigneeId = $assigneeId;80 $this->updatedAt = new \DateTimeImmutable();81 82 $this->record(new TaskAssigned($this->id, $assigneeId));83 }84 85 public function unassign(): void86 {87 $this->assigneeId = null;88 $this->updatedAt = new \DateTimeImmutable();89 }90 91 public function changeStatus(TaskStatus $status): void92 {93 if (!$this->status->canTransitionTo($status)) {94 throw InvalidTaskStateTransitionException::cannotTransition(95 $this->status->value,96 $status->value,97 );98 }99 100 $oldStatus = $this->status;101 $this->status = $status;102 $this->updatedAt = new \DateTimeImmutable();103 104 $this->record(new TaskStatusChanged($this->id, $oldStatus, $status));105 }106 107 public function updateTitle(string $title): void108 {109 $this->title = $title;110 $this->updatedAt = new \DateTimeImmutable();111 }112 113 public function updateDescription(?string $description): void114 {115 $this->description = $description;116 $this->updatedAt = new \DateTimeImmutable();117 }118 119 public function createdAt(): \DateTimeImmutable120 {121 return $this->createdAt;122 }123 124 public function updatedAt(): ?\DateTimeImmutable125 {126 return $this->updatedAt;127 }128}
Doménové události
Agregáty publikují skutečnosti, které pro doménu mají význam. Událost je neměnný záznam minulého
děje – proto jsou všechny třídy final readonly s veřejnými promovanými parametry. Vlastnost
occurredAt nese okamžik vzniku – výchozí new \DateTimeImmutable() ovšem přebírá časovou zónu
serveru, garance UTC vyžaduje explicitní předání hodnoty. Payload obsahuje minimální množinu identifikátorů
a hodnot potřebnou k rekonstrukci kontextu. Teoretický základ doménových událostí je v kapitole
Základní koncepty DDD; návaznost na Event
Sourcing v kapitole Event Sourcing.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Domain\Event;6 7use App\ProjectManagement\Domain\ValueObject\ProjectId;8use App\UserManagement\Domain\ValueObject\UserId;9 10final readonly class ProjectCreated11{12 public function __construct(13 public ProjectId $projectId,14 public string $name,15 public UserId $ownerId,16 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),17 ) {18 }19}
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Domain\Event;6 7use App\ProjectManagement\Domain\ValueObject\ProjectId;8use App\UserManagement\Domain\ValueObject\UserId;9 10final readonly class MemberAdded11{12 public function __construct(13 public ProjectId $projectId,14 public UserId $userId,15 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),16 ) {17 }18}19 20final readonly class MemberRemoved21{22 public function __construct(23 public ProjectId $projectId,24 public UserId $userId,25 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),26 ) {27 }28}
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\Domain\Event;6 7use App\ProjectManagement\Domain\ValueObject\ProjectId;8use App\TaskManagement\Domain\ValueObject\TaskId;9use App\TaskManagement\Domain\ValueObject\TaskStatus;10use App\UserManagement\Domain\ValueObject\UserId;11 12final readonly class TaskCreated13{14 public function __construct(15 public TaskId $taskId,16 public string $title,17 public ProjectId $projectId,18 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),19 ) {20 }21}22 23final readonly class TaskAssigned24{25 public function __construct(26 public TaskId $taskId,27 public UserId $assigneeId,28 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),29 ) {30 }31}32 33final readonly class TaskStatusChanged34{35 public function __construct(36 public TaskId $taskId,37 public TaskStatus $oldStatus,38 public TaskStatus $newStatus,39 public \DateTimeImmutable $occurredAt = new \DateTimeImmutable(),40 ) {41 }42}
Hodnotové objekty: identifikátory a stav úkolu
Identifikátory ProjectId, TaskId a UserId sdílí společné rozhraní: konstruktor předaný
string jen ověří, nové UUID vydává statická metoda generate(). Property $value nese surový
string pro persistenci, equals() srovnává podle hodnoty. TaskStatus je výčtový typ
s explicitním doménovým jazykem.
Plný rozbor Value Objektů je v kapitole
Základní koncepty DDD.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Domain\ValueObject;6 7use Symfony\Component\Uid\Uuid;8 9final readonly class ProjectId10{11 public function __construct(12 public string $value,13 ) {14 if (!Uuid::isValid($value)) {15 throw new \InvalidArgumentException(16 sprintf('Neplatné ProjectId: "%s".', $value),17 );18 }19 }20 21 public static function generate(): self22 {23 return new self((string) Uuid::v7());24 }25 26 public function equals(self $other): bool27 {28 return $this->value === $other->value;29 }30}
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\Domain\ValueObject;6 7enum TaskStatus: string8{9 case Todo = 'todo';10 case InProgress = 'in_progress';11 case Done = 'done';12 13 public function canTransitionTo(self $next): bool14 {15 return match ([$this, $next]) {16 [self::Todo, self::InProgress] => true,17 [self::InProgress, self::Done] => true,18 [self::InProgress, self::Todo] => true,19 default => false,20 };21 }22}
Přechodová tabulka používá match nad polem dvou případů. Porovnání je striktní a u polí probíhá
prvek po prvku; případy výčtového typu jsou singletony, takže dvojice [$this, $next] sedne právě
na jeden řádek tabulky. Zápis je hutný, za cenu toho, že ho čtenář musí přečíst dvakrát.
Command: Vytvoření projektu (Command Pattern)
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\CreateProject\Command;6 7use Symfony\Component\Validator\Constraints as Assert;8 9class CreateProject10{11 public function __construct(12 #[Assert\NotBlank]13 #[Assert\Length(min: 3, max: 255)]14 public readonly string $name,15 16 public readonly ?string $description,17 18 #[Assert\NotBlank]19 #[Assert\Uuid]20 public readonly string $ownerId21 ) {22 }23}
Command Handler: Zpracování vytvoření projektu (Application Service)
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\CreateProject\Command;6 7use App\ProjectManagement\Domain\Model\Project;8use App\ProjectManagement\Domain\Repository\ProjectRepository;9use App\ProjectManagement\Domain\ValueObject\ProjectId;10use App\UserManagement\Domain\ValueObject\UserId;11use Doctrine\ORM\EntityManagerInterface;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13use Symfony\Component\Messenger\MessageBusInterface;14 15#[AsMessageHandler]16final class CreateProjectHandler17{18 public function __construct(19 private readonly ProjectRepository $projectRepository,20 private readonly EntityManagerInterface $em,21 private readonly MessageBusInterface $eventBus,22 ) {23 }24 25 public function __invoke(CreateProject $command): string26 {27 $project = Project::create(28 ProjectId::generate(),29 $command->name,30 $command->description,31 new UserId($command->ownerId),32 );33 34 $this->projectRepository->save($project); // persist agregátu35 $this->em->flush(); // commit zápisu36 37 foreach ($project->releaseEvents() as $event) {38 $this->eventBus->dispatch($event);39 }40 41 return $project->id->value;42 }43}
Pořadí save() → flush() → releaseEvents() → dispatch() je záměrné. Publikovat před commitem
znamená oznámit změnu, kterou databáze ještě může odmítnout; publikovat po commitu zase znamená
o událost přijít, když proces spadne mezi oběma kroky. Obě varianty i cestu přes transakční tabulku
rozebírají Základní koncepty DDD a kapitola
Outbox Pattern.
Handler generuje ProjectId sám a vrací ho volajícímu. Návratová hodnota z command handleru drží
příkaz na synchronní sběrnici – jakmile by šel na asynchronní transport, muselo by ID vzniknout
u volajícího a putovat uvnitř příkazu (CQRS).
Command: Přiřazení úkolu (Command Pattern)
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\AssignTask\Command;6 7use Symfony\Component\Validator\Constraints as Assert;8 9class AssignTask10{11 public function __construct(12 #[Assert\NotBlank]13 #[Assert\Uuid]14 public readonly string $taskId,15 16 #[Assert\NotBlank]17 #[Assert\Uuid]18 public readonly string $assigneeId19 ) {20 }21}
Command Handler: Zpracování přiřazení úkolu (Application Service)
Port ProjectChecker je rozhraní v doméně TaskManagement. Implementace žije v infrastruktuře
a překládá dotaz na volání upstream kontextu:
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\Domain\Port;6 7use App\ProjectManagement\Domain\ValueObject\ProjectId;8use App\UserManagement\Domain\ValueObject\UserId;9 10interface ProjectChecker11{12 public function exists(ProjectId $projectId): bool;13 14 public function isMember(ProjectId $projectId, UserId $userId): bool;15}
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\AssignTask\Command;6 7use App\TaskManagement\Domain\Exception\AssigneeNotProjectMemberException;8use App\TaskManagement\Domain\Exception\ProjectNotFoundException;9use App\TaskManagement\Domain\Exception\TaskNotFoundException;10use App\TaskManagement\Domain\Port\ProjectChecker;11use App\TaskManagement\Domain\Repository\TaskRepository;12use App\TaskManagement\Domain\Service\TaskAssignmentService;13use App\TaskManagement\Domain\ValueObject\TaskId;14use App\UserManagement\Domain\ValueObject\UserId;15use Doctrine\ORM\EntityManagerInterface;16use Symfony\Component\Messenger\Attribute\AsMessageHandler;17use Symfony\Component\Messenger\MessageBusInterface;18 19#[AsMessageHandler]20final class AssignTaskHandler21{22 public function __construct(23 private readonly TaskRepository $taskRepository,24 private readonly ProjectChecker $projectChecker,25 private readonly TaskAssignmentService $taskAssignmentService,26 private readonly EntityManagerInterface $em,27 private readonly MessageBusInterface $eventBus,28 ) {29 }30 31 public function __invoke(AssignTask $command): void32 {33 $taskId = new TaskId($command->taskId);34 $task = $this->taskRepository->findById($taskId);35 36 if ($task === null) {37 throw TaskNotFoundException::withId($taskId->value);38 }39 40 $assigneeId = new UserId($command->assigneeId);41 42 // Ověření přes port - bez přímé závislosti na ProjectManagement43 if (!$this->projectChecker->exists($task->projectId())) {44 throw ProjectNotFoundException::withId($task->projectId()->value);45 }46 47 if (!$this->projectChecker->isMember($task->projectId(), $assigneeId)) {48 throw AssigneeNotProjectMemberException::forTask($taskId->value, $assigneeId->value);49 }50 51 // Doménová služba drží pravidlo přiřazení52 $this->taskAssignmentService->assignTask($task, $assigneeId);53 54 $this->taskRepository->save($task);55 $this->em->flush();56 57 foreach ($task->releaseEvents() as $event) {58 $this->eventBus->dispatch($event);59 }60 }61}
Handler ověřuje doménové pravidlo: řešitel musí být členem projektu. Otázku, kdo smí úkol přiřadit, neřeší – oprávnění patří do autorizační vrstvy nad handlerem, kterou rozebírá kapitola Autorizace v DDD.
Query: Získání projektů uživatele (Query Pattern)
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\GetProjects\Query;6 7use Symfony\Component\Validator\Constraints as Assert;8 9class GetProjects10{11 public function __construct(12 #[Assert\NotBlank]13 #[Assert\Uuid]14 public readonly string $userId15 ) {16 }17}
Query Handler: Zpracování získání projektů uživatele (Read Model)
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\GetProjects\Query;6 7use App\ProjectManagement\Domain\Repository\ProjectRepository;8use App\ProjectManagement\GetProjects\ViewModel\ProjectViewModel;9use App\UserManagement\Domain\ValueObject\UserId;10use Symfony\Component\Messenger\Attribute\AsMessageHandler;11 12#[AsMessageHandler]13class GetProjectsHandler14{15 public function __construct(16 private readonly ProjectRepository $projectRepository17 ) {18 }19 20 public function __invoke(GetProjects $query): array21 {22 $projects = $this->projectRepository->findByMemberId(new UserId($query->userId));23 24 $result = [];25 26 foreach ($projects as $project) {27 $result[] = new ProjectViewModel(28 $project->id->value,29 $project->name(),30 $project->description(),31 $project->ownerId->value,32 count($project->memberIds()),33 0, // počet úkolů naivní verze nezná – Task je samostatný agregát (viz sekci 24.06)34 $project->createdAt35 );36 }37 38 return $result;39 }40}
Doménová služba: Přiřazení úkolu
1<?php2 3declare(strict_types=1);4 5namespace App\TaskManagement\Domain\Service;6 7use App\TaskManagement\Domain\Model\Task;8use App\UserManagement\Domain\ValueObject\UserId;9 10class TaskAssignmentService11{12 // Doménová služba pracuje výhradně s objekty vlastního bounded contextu.13 // Ověření příslušnosti k projektu zajišťuje handler přes ProjectChecker port.14 public function assignTask(Task $task, UserId $assigneeId): void15 {16 $task->assign($assigneeId);17 }18}
24.06 Read modely a projekce#
GetProjectsHandler z předchozí sekce načítá projekty přes doménový repozitář.
Hydratuje agregáty, i když potřebuje jen tabulkový výpis. Pro malý dataset to funguje. Jakmile dataset
naroste na tisíce projektů a desetitisíce úkolů a výpis se obohatí o jména členů a počty úkolů,
každý dotaz znamená opakované JOINy a hydrataci agregátů kvůli zobrazení.
V projektu proto postupně vznikl samostatný read model. Princip: doménové události aktualizují
denormalizovanou tabulku, ze které čte query handler. Žádný JOIN mezi agregáty, žádná
hydratace doménových objektů. Hlubší teoretický základ je v kapitolách
CQRS a Výkonnostní aspekty.
Schéma read modelu
Tabulka project_list_view drží tvar potřebný pro výpis projektů uživatele. Není normalizovaná –
obsahuje vypočítané hodnoty (member_count, task_count) a denormalizované pole
member_ids jako JSON. Tato tabulka není zdrojem pravdy; lze ji kdykoli znovu sestavit z primárních tabulek.
Dotaz operátorem @> i GIN index předpokládají PostgreSQL sloupec typu jsonb. Types::JSON
vytvoří sloupec json, nad kterým @> ani GIN index nefungují; od DBAL 4.3 na to existuje typ
Types::JSONB. Na DBAL 3.x zbývá option ['jsonb' => true], kterou tatáž verze 4.3 označila
za zastaralou.
Entita read modelu nenese readOnly: true. Příznak vypíná sledování změn, takže by z projekce
prošel jen persist() a každý UPDATE by tiše zmizel – přesně to, co projekce dělá nejčastěji.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Infrastructure\ReadModel;6 7use Doctrine\DBAL\Types\Types;8use Doctrine\ORM\Mapping as ORM;9 10#[ORM\Entity]11#[ORM\Table(name: 'project_list_view')]12#[ORM\Index(columns: ['owner_id'], name: 'idx_owner')]13class ProjectListView14{15 #[ORM\Id]16 #[ORM\Column(type: Types::GUID)]17 public string $projectId;18 19 #[ORM\Column(type: Types::STRING, length: 255)]20 public string $name;21 22 #[ORM\Column(type: Types::TEXT, nullable: true)]23 public ?string $description = null;24 25 #[ORM\Column(type: Types::GUID)]26 public string $ownerId;27 28 // PostgreSQL: jsonb, ne json - nad json operátor @> ani GIN index nefungují.29 // Types::JSONB vyžaduje DBAL 4.3+; na DBAL 3.x: Types::JSON s options ['jsonb' => true].30 #[ORM\Column(type: Types::JSONB)]31 public array $memberIds = [];32 33 #[ORM\Column(type: Types::INTEGER)]34 public int $memberCount = 0;35 36 #[ORM\Column(type: Types::INTEGER)]37 public int $taskCount = 0;38 39 #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]40 public \DateTimeImmutable $createdAt;41 42 #[ORM\Column(type: Types::DATETIME_IMMUTABLE)]43 public \DateTimeImmutable $updatedAt;44}
GIN index nad member_ids v mapování nenajdete. Atribut #[ORM\Index] sice zná parametr flags,
ale PostgreSQLPlatform ho nepřepisuje a do DDL se nedostane; vznikl by běžný B-tree index, který
dotaz s @> stejně nepoužije. Index proto zakládá ruční migrace:
1CREATE INDEX idx_members ON project_list_view USING gin (member_ids);
Projection: aktualizace read modelu z událostí
Projekce naslouchá doménovým událostem ze všech kontextů, které mají vliv na podobu výpisu projektů.
Běží jako asynchronní message handler – mimo originální transakci, takže ji nemůže shodit.
Každou událost obsluhuje samostatná metoda s atributem #[AsMessageHandler]; Messenger
routuje podle type-hintu parametru, obecný type-hint object proto použít nelze.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Infrastructure\ReadModel;6 7use App\ProjectManagement\Domain\Event\MemberAdded;8use App\ProjectManagement\Domain\Event\MemberRemoved;9use App\ProjectManagement\Domain\Event\ProjectCreated;10use App\TaskManagement\Domain\Event\TaskCreated;11use Doctrine\ORM\EntityManagerInterface;12use Symfony\Component\Messenger\Attribute\AsMessageHandler;13 14class ProjectListProjection15{16 public function __construct(17 private readonly EntityManagerInterface $em18 ) {19 }20 21 #[AsMessageHandler]22 public function onProjectCreated(ProjectCreated $event): void23 {24 if ($this->em->find(ProjectListView::class, $event->projectId->value) !== null) {25 return; // událost už byla zpracovaná26 }27 28 $now = new \DateTimeImmutable();29 $view = new ProjectListView();30 $view->projectId = $event->projectId->value;31 $view->name = $event->name;32 $view->ownerId = $event->ownerId->value;33 $view->memberIds = [$event->ownerId->value];34 $view->memberCount = 1;35 $view->taskCount = 0;36 $view->createdAt = $now;37 $view->updatedAt = $now;38 $this->em->persist($view);39 $this->em->flush();40 }41 42 #[AsMessageHandler]43 public function onMemberAdded(MemberAdded $event): void44 {45 $view = $this->em->find(ProjectListView::class, $event->projectId->value);46 if ($view === null) {47 // Out-of-order delivery: MemberAdded přišlo dřív než ProjectCreated.48 // Reconciler (sekce 24.06.4) dohledá zaostalou view a obnoví ji49 // ze zdrojových agregátů.50 return;51 }52 $userId = $event->userId->value;53 if (!in_array($userId, $view->memberIds, strict: true)) {54 $view->memberIds[] = $userId;55 $view->memberCount++;56 $view->updatedAt = new \DateTimeImmutable();57 $this->em->flush();58 }59 }60 61 #[AsMessageHandler]62 public function onMemberRemoved(MemberRemoved $event): void63 {64 $view = $this->em->find(ProjectListView::class, $event->projectId->value);65 if ($view === null) {66 return;67 }68 $userId = $event->userId->value;69 $view->memberIds = array_values(array_filter(70 $view->memberIds,71 static fn(string $id): bool => $id !== $userId72 ));73 $view->memberCount = count($view->memberIds);74 $view->updatedAt = new \DateTimeImmutable();75 $this->em->flush();76 }77 78 #[AsMessageHandler]79 public function onTaskCreated(TaskCreated $event): void80 {81 $view = $this->em->find(ProjectListView::class, $event->projectId->value);82 if ($view === null) {83 return;84 }85 $view->taskCount++;86 $view->updatedAt = new \DateTimeImmutable();87 $this->em->flush();88 }89}
Query handler nad read modelem (revize GetProjectsHandler)
Naivní verze ze sekce 24.05 hydratovala doménové agregáty
jen kvůli zobrazení. Po zavedení projekce se třída GetProjectsHandler přepsala na čistý
DBAL dotaz nad read tabulkou. Žádné agregáty, žádná doménová logika – jen výběr sloupců a mapování
na ProjectViewModel. Stejný název třídy, stejný command, jiná implementace; volající
ani Symfony Messenger o změně nevědí.
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\GetProjects\Query;6 7use App\ProjectManagement\GetProjects\ViewModel\ProjectViewModel;8use Doctrine\DBAL\Connection;9use Symfony\Component\Messenger\Attribute\AsMessageHandler;10 11#[AsMessageHandler]12class GetProjectsHandler13{14 public function __construct(15 private readonly Connection $db16 ) {17 }18 19 /** @return ProjectViewModel[] */20 public function __invoke(GetProjects $query): array21 {22 $rows = $this->db->fetchAllAssociative(23 'SELECT project_id, name, description, owner_id, member_count, task_count, created_at24 FROM project_list_view25 WHERE member_ids @> :userId26 ORDER BY updated_at DESC',27 ['userId' => json_encode([$query->userId])]28 );29 30 return array_map(31 static fn(array $row): ProjectViewModel => new ProjectViewModel(32 projectId: $row['project_id'],33 name: $row['name'],34 description: $row['description'],35 ownerId: $row['owner_id'],36 memberCount: (int) $row['member_count'],37 taskCount: (int) $row['task_count'],38 createdAt: new \DateTimeImmutable($row['created_at']),39 ),40 $rows41 );42 }43}
Idempotence projekce a reconciliation
Asynchronní doručování přes Messenger nezaručuje pořadí zpráv: pokud transport přerozdělí
zprávy mezi více workerů, může MemberAdded dorazit dřív než ProjectCreated
téhož projektu. Projekce na to musí být připravená dvěma vlastnostmi.
Idempotence. Opakované zpracování téže události nesmí změnit výsledek. V ukázce výše to
zajišťují tři detaily: onProjectCreated nejdřív hledá existující view a při druhém doručení
skončí bez zápisu; onMemberAdded nepřidá uživatele dvakrát díky kontrole
in_array(..., strict: true); onMemberRemoved přepočítává memberCount z aktuální délky
pole, ne inkrementem.
Slabé místo zbývá u onTaskCreated. Inkrement taskCount znamená při opakovaném doručení
o jedničku navíc. Odsunout problém na retry strategii Messengeru (výchozí tři pokusy) a odtud
na failure transport není idempotence, jen odklizené selhání. Řešení má dvě podoby. Buď každá událost ponese vlastní eventId a projekce si zpracovaná ID zapamatuje –
dnešní třídy nesou jen occurredAt, takže by šlo o změnu payloadu. Nebo deduplikaci převezme
DeduplicateMiddleware se stampem DeduplicateStamp, které Messenger nabízí od verze 7.3. Plný
vzor idempotentního příjmu popisuje kapitola Outbox Pattern.
Reconciler. Pokud událost přijde mimo pořadí (handler vrátí return bez zápisu, protože $view === null) nebo se ztratí, projekce zůstává zastaralá. Reconciler je samostatný proces, který
v pravidelném intervalu detekuje rozdíl mezi write modelem a read modelem a doplní chybějící data.
V této studii je řešen jako Symfony console command spouštěný z cronu jednou za hodinu (frekvence je
kompromis mezi čerstvostí a zatížením DB):
1<?php2 3declare(strict_types=1);4 5namespace App\ProjectManagement\Infrastructure\ReadModel;6 7use App\ProjectManagement\Domain\Repository\ProjectRepository;8use Doctrine\ORM\EntityManagerInterface;9use Symfony\Component\Console\Attribute\AsCommand;10use Symfony\Component\Console\Command\Command;11use Symfony\Component\Console\Style\SymfonyStyle;12 13#[AsCommand(14 name: 'project-list:reconcile',15 description: 'Dorovná zaostalý read model project_list_view ze zdrojových agregátů.',16)]17final class ReconcileProjectListView18{19 public function __construct(20 private readonly ProjectRepository $projects,21 private readonly EntityManagerInterface $em,22 ) {23 }24 25 public function __invoke(SymfonyStyle $io): int26 {27 $now = new \DateTimeImmutable();28 $repaired = 0;29 30 foreach ($this->projects->all() as $project) {31 $view = $this->em->find(ProjectListView::class, $project->id->value);32 $expectedMembers = array_map(33 static fn($id) => $id->value,34 $project->memberIds(),35 );36 37 if ($view === null) {38 // Chybějící view: založit a rovnou naplnit všechna pole.39 $view = new ProjectListView();40 $view->projectId = $project->id->value;41 $view->ownerId = $project->ownerId->value;42 $view->createdAt = $project->createdAt;43 $view->name = $project->name();44 $view->description = $project->description();45 $view->memberIds = $expectedMembers;46 $view->memberCount = count($expectedMembers);47 $view->updatedAt = $now;48 $this->em->persist($view);49 $repaired++;50 continue;51 }52 53 $needsRepair = $view->name !== $project->name()54 || $view->description !== $project->description()55 || $view->memberIds !== $expectedMembers56 || $view->memberCount !== count($expectedMembers);57 58 if (!$needsRepair) {59 continue;60 }61 62 $view->name = $project->name();63 $view->description = $project->description();64 $view->memberIds = $expectedMembers;65 $view->memberCount = count($expectedMembers);66 $view->updatedAt = $now;67 $repaired++;68 }69 70 $this->em->flush();71 $io->writeln(sprintf('Dorovnáno %d projektů.', $repaired));72 73 return Command::SUCCESS;74 }75}
Command používá invokable syntaxi, kterou Symfony doporučuje od verze 7.3. Dědění z třídy
Command zůstává podporované, jen je vedené jako starší tvar zápisu.
Reconciler nepřebírá roli projekce; jen dorovnává to, co projekce z technických důvodů nedoručila. V provozu se vyplatí alert nad počtem dorovnaných záznamů: vysoké číslo signalizuje systémový problém s transportem, ne drobné přeházení pořadí zpráv.
Co tato podoba nedorovnává: task_count (dopočítal by se z TaskRepository), ownerId
a createdAt u již existující view a sirotčí řádky po smazaných projektech. Neřeší ani objem –
$this->projects->all() hydratuje všechny agregáty najednou. Na tisících projektů patří do smyčky
dávkování po několika stovkách kusů, EntityManager::clear() po každé dávce a přepínače --limit
a --dry-run.
Důsledky pro konzistenci
Read model je eventually consistent. Mezi commitem zápisu a aktualizací projekce zůstává okno (typicky milisekundy, při zatížení Messengeru sekundy), ve kterém vrácený seznam neobsahuje nově vytvořený projekt. Toto okno se v projektu pokrylo dvěma cestami:
- Optimistická aktualizace UI – po úspěšné odpovědi na command klient přidá záznam do lokálního stavu a teprve po další navigaci načítá aktualizovaný read model. Uživatel okamžitě vidí výsledek své akce.
- Read-your-writes přes write model – pro kritické dotazy okamžitě po commandu (např. stránka Detail nově vytvořeného projektu) handler čte přímo z write modelu nebo z cache namapované na ID právě dokončené operace. Cena: ztráta výhod read modelu pro tento jeden tok.
24.07 Výzvy a rozhodnutí#
Žádný projekt v DDD nezačíná hotový. Pět níže uvedených rozhodnutí ukazuje místa, kde tým váhal mezi dvěma legitimními možnostmi. Místo „správné“ odpovědi existuje kontext, který volbu určil, a cena, kterou za ni tým platí. Stejná otázka v jiném projektu by mohla dopadnout jinak. Závěrečná podsekce pak shrnuje, co by dnes proběhlo jinak.
1. Eventual consistency napříč kontexty
Otázka: má být zápis aktivity v ActivityTracking součástí téže transakce jako vydávající operace (např. zápis projektu), nebo asynchronní reakce na publikovanou událost?
Volba: asynchronní zpracování přes Messenger transport. Audit se nesmí stát kritickým bodem selhání pro hlavní use case. Pokud je transport pro audit nedostupný, zápis projektu se přesto úspěšně dokončí a aktivita se zaznamená, jakmile je transport zase dostupný. Záruka, že se událost neztratí ani při výpadku mezi commitem a publikací, ovšem vyžaduje outbox tabulku – tu ukázky v této kapitole nemají (Outbox Pattern).
Cena: uživatel s rolí auditor vidí novou aktivitu se zpožděním. Pro audit log, kde čtenář není stejný uživatel jako autor akce, je toto zpoždění přijatelné. Pro notifikace v reálném čase by tento kompromis nestačil – tam pomůže synchronní integrace nebo websocket push z projekce.
2. Sdílené identifikátory jako Shared Kernel
Otázka: UserId se objevuje ve všech kontextech (vlastník projektu, přiřazený
řešitel, autor komentáře). Bude jedna sdílená třída, kterou ostatní kontexty importují, nebo si každý kontext drží
vlastní reprezentaci jako primitivní string?
Volba: jedna třída ve vlastnickém kontextu, importovaná ostatními. UserId žije
v UserManagement, ProjectId v ProjectManagement, TaskId v TaskManagement; downstream
kontexty tyto value objecty používají přímo. Vzor má jméno:
Shared Kernel – malá společně vlastněná část modelu, kterou žádný
z kontextů nemůže změnit sám. Tým je jeden, deploy je jeden, riziko, že se UUID formát mezi kontexty
rozejde, je zanedbatelné. Sdílená třída navíc drží validaci na jednom místě.
Cena: závislost na doménové vrstvě cizího kontextu. Když vlastnický kontext rozšíří UserId o novou
validaci, dotkne se to všech ostatních. Refaktor takto sdílené třídy je v praxi koordinovaný release.
Alternativa: Pokud by se tým štěpil nebo se kontexty oddělovaly do samostatných služeb, primitivní string by byl bezpečnější (každý kontext si validuje sám) za cenu duplikace. Pro monolit s jedním deploy pipeline je sdílená třída pragmatičtější.
3. Synchronní ACL přes port vs. asynchronní reakce na event
Otázka: při přiřazení úkolu (AssignTask) musí TaskManagement
ověřit, že přiřazovaný uživatel je členem projektu. Synchronní volání portu ProjectChecker, nebo
čistě asynchronní reakce na TaskAssignmentRequested a kompenzace, pokud členství neplatí?
Volba: synchronní port. Operace musí selhat okamžitě, pokud uživatel není členem projektu. Uživatel čeká na odpověď příkazu a chce hned vědět, zda přiřazení prošlo, nebo proč ne.
Cena: TaskManagement má časovou závislost na ProjectManagement. Pokud druhý kontext není dostupný, přiřazení selže. V monolitu je tato závislost neviditelná, ve světě služeb přidá síťový skok a riziko kaskádových selhání.
Alternativa pro distribuovaný systém: TaskManagement by si držel lokální projekci „project members“ aktualizovanou přes eventy z ProjectManagement. Validace by běžela nad lokální tabulkou, bez síťového volání. Pro monolit jde o předčasnou optimalizaci, ale jakmile by se kontexty oddělily, je to první refaktor, který by měl proběhnout. Kdy takové oddělení dává smysl a co stojí, rozebírá kapitola DDD a microservices. Pokud by validace selhala až po dokončení přiřazení, stav vrací kompenzační scénář – vzor, který popisuje kapitola Sagas a Process Manager.
4. Doménová služba vs. logika v handleru
Otázka: TaskAssignmentService::assignTask() aktuálně volá pouze
Task::assign(). Má smysl mít doménovou službu, která jen deleguje?
Volba: zachovat ji jako místo pro rozšíření. Přiřazení úkolu je doménový koncept, který v budoucnu zřejmě poroste – notifikace přiřazenému, kontrola pracovní zátěže, validace deadline, integrace s kalendářem. Vystavená abstrakce dovolí přidat tato pravidla, aniž by se musel měnit handler, controller nebo samotný agregát.
Cena: aktuálně prázdná abstrakce, která může čtenáři kódu připadat nadbytečná. Kapitola 10 označuje doménovou službu, která jen obalí volání jediného agregátu, za anti-vzor: oslabuje agregát a vede k anemickému modelu. Zdejší výjimka stojí a padá s tím, jestli pravidla kolem přiřazení opravdu přibudou. Pokud nepřibudou, platí anti-vzor a služba má zmizet.
Alternativa: inline volání v handleru a refaktor ve chvíli, kdy vznikne první důvod pro doménovou službu. YAGNI v praxi. Volba mezi těmito dvěma cestami je věcí týmové dohody – obě jsou v DDD legitimní.
5. Velikost agregátu Project
Otázka: má Project obsahovat seznam úkolů (Task[]) a být velkým
agregátem, nebo jsou Project a Task dva samostatné agregáty propojené přes
ProjectId?
Volba: dva samostatné agregáty. Task drží ProjectId jako referenci,
ale není uvnitř Project.
Důvody:
- Přidání úkolu nemusí způsobovat update verze projektu (žádné optimistické locking konflikty).
- Načítání projektu nemusí načítat všechny úkoly – výpis projektu zůstává levný.
- Souběžné přidávání úkolů různými uživateli nezpůsobuje konflikt na agregátu projektu.
- Transakční hranice úkolu je omezená; menší agregát = menší zámek = vyšší propustnost.
Cena: invariant „úkol patří do existujícího projektu“ se vynucuje na úrovni handleru
(přes ProjectChecker), ne v doménovém modelu. Při přímém zápisu do databáze (např. data import)
může vzniknout úkol bez projektu. Foreign key constraint na project_id tomu zabrání na úrovni
infrastruktury.
Alternativa: Pokud by aplikace vyžadovala invariant „projekt nesmí mít víc než 50 úkolů“,
nabízejí se dvě cesty: přesunout pravidlo do doménové služby s explicitním kontraktem, nebo z Task
udělat komponentu uvnitř Project agregátu (hůř škálovatelné, ale konzistentní s ohledem
na invariant). Pravidla pro velikost agregátu a jeho transakční hranici rozebírá
Návrh agregátů, anti-vzory typu God Aggregate pak
Anti-vzory a typické chyby.
Co by dnes proběhlo jinak
Předchozích pět voleb vyšlo. Tři další stojí za pojmenování právě proto, že nevyšly.
rename() a changeDescription() neemitují událost. Read model se o změně jména nedozví
a zůstane zastaralý až do běhu reconcileru. Oprava je událost ProjectRenamed, ne hodinový cron.
Události publikované do ActivityTrackingu nesou interní hodnotové objekty vydávajícího kontextu. Dokud běží monolit, nikdo to nepocítí. První konzument mimo repozitář ale zmrazí doménový model v podobě, ve které se zrovna nachází.
TaskAssignmentService je pořád prázdná. Rozšíření, kvůli kterému vznikla, za celou dobu nepřišlo.
Katalog podobných třecích ploch – od Doctrine přes ordering zpráv po jazykový drift – vede kapitola DDD v praxi: kde to bolí.
24.08 Ponaučení#
Z návrhu popsaného výše plyne deset bodů, které drží i mimo tuto studii. Většina vychází ze strategického a taktického designu, zbytek z práce s read modely a z vědomého řízení kompromisů.
- Strategický design rozhoduje o výsledku – Identifikace pěti bounded contexts a jejich vztahů na začátku projektu odhalila, že slovo „uživatel“ znamená v každém kontextu něco jiného. Bez kontextové mapy by se tato sémantická rozdílnost objevila až ve sporech nad pull requesty.
- Ubiquitous Language zpřesní model – Společný jazyk s doménovými experty odstranil nejednoznačnosti v požadavcích a zrcadlil se přímo v názvech tříd a metod. Tester, vývojář i produktový manažer mluví o
TaskAssigned, ne každý o něčem jiném. - Agregáty a hranice transakcí – Vymezené agregáty udržely data konzistentní. Každý agregát si hlídal vnitřní konzistenci a měnil se v jedné transakci.
- Doménové události pro integraci – Doménové události odvázaly bounded contexts od vzájemných synchronních volání. Po vytvoření úkolu publikoval agregát událost
TaskCreated; ActivityTracking i ProjectListProjection na ni reagovaly samostatně, aniž by o sobě věděly. - CQRS pro oddělení zodpovědností – Příkazy mění stav, dotazy čtou bez vedlejších efektů. Každá strana má vlastní handler, vlastní model a vlastní testy. Roli message busu obstaral Symfony Messenger.
- Vertikální slice architektura pro modularitu – Organizace kódu podle feature místo technických vrstev znamenala, že změna v jedné feature se zpravidla nedotýká ostatních. Každá feature nese vlastní command, handler, kontroler i view model. Nová feature obvykle vznikne přidáním adresáře, ne úpravou existujících tříd.
- Testování doménového modelu – Doménové objekty bez závislostí na frameworku lze testovat čistým PHPUnit bez bootstrappingu kernelu. Unit testy ověřovaly chování agregátů a doménových služeb, integrační testy spolupráci mezi částmi systému. Podrobná strategie pro DDD projekty je v kapitole Testování DDD aplikací.
- Read modely jako samostatný artefakt – Oddělení write a read strany přes projekce ukázalo svou hodnotu, jakmile dataset překročil několik tisíc projektů. Hydratace agregátů pro účely výpisu je drahá; denormalizovaný read model ji z výpisu odstranil úplně a místo několika
JOINů a stovek objektů zbyl jeden dotaz nad jednou tabulkou. Cenou byla eventual consistency, kterou tým ošetřil optimistickou aktualizací UI v kombinaci s read-your-writes pro kritické scénáře. - Doménová analýza předchází kódu – Tři kroky event stormingu (sběr událostí, seskupení do subdomén, definice hranic) zafungovaly jako filtr proti předčasné technické dekompozici. Bez tohoto kroku by hranice kontextů kopírovaly databázové tabulky nebo obrazovkový tok, ne sémantické bloky domény. Dva dny u tabule stojí zlomek toho, co později stojí posun špatně vedené hranice.
- Trade-offy dokumentovat, ne řešit – Ne každé rozhodnutí má jednu správnou odpověď. Sdílené třídy identifikátorů napříč kontexty, eventual consistency u auditu, synchronní ACL přes port – každá z těchto voleb má cenu, kterou tým přijal s vědomím alternativy. Záznam těchto rozhodnutí v dokumentaci (ADR) zachoval kontext pro pozdější refaktor; bez něj by se za půl roku diskuse opakovala znovu.
24.09 Další četba#
- Eric Evans, Domain-Driven Design (Addison-Wesley, 2003) – jediná průběžná doména lodní přepravy napříč celou knihou; open-source implementace citerus/dddsample-core.
- Vaughn Vernon, Implementing Domain-Driven Design (Addison-Wesley, 2013) a ukázky VaughnVernon/IDDD_Samples. Kontext
iddd_agilepmřeší prakticky totožnou doménu jako tato studie, identitu ale drží jako samostatný kontext, se kterým ostatní pracují přes překlad. - Vlad Khononov, Learning Domain-Driven Design (O'Reilly, 2021) – help-desk SaaS jako průběžný příklad, včetně klasifikace subdomén.
- DDD Crew, DDD Starter Modelling Process – osm kroků od pochopení byznysu ke kódu. Tato kapitola prochází kroky Discover, Decompose, Strategize, Connect, Define a Code; Understand a Organise nechává stranou.
- CodelyTV/php-ddd-example – spustitelná PHP reference se strukturou
src/<BoundedContext>/<Modul>/{Application,Domain,Infrastructure}a vlastní bázovou třídou agregátu. - Mathias Verraes, Patterns for Decoupling in Distributed Systems: Explicit Public Events – proč je veřejná jen malá, vědomě označená podmnožina událostí.
Časté otázky
Jakou doménu případová studie popisuje?
Systém pro správu projektů a úkolů – uživatelé vytvářejí projekty, přidávají úkoly, přiřazují je členům týmu, mění jejich stav a komentují je. Scénář je ilustrativní: tým, čísla i rozhodnutí jsou smyšlené a slouží jako souvislá ukázka návrhu. Doména je dostatečně bohatá, aby obsáhla strategické (context map) i taktické (agregát, doménová služba) vzory DDD, a přitom uchopitelná v rozsahu jedné kapitoly. Konkrétní požadavky v sekci Požadavky.
Proč je systém rozdělen do pěti bounded contexts místo jednoho modelu?
Každý kontext má jinou sémantiku: UserManagement řeší identitu, ProjectManagement životní cyklus projektu, TaskManagement stavové přechody úkolů, CommentManagement komunikaci a ActivityTracking audit. Rozdělení odráží reálné doménové hranice a umožňuje vyvíjet každý kontext samostatně, s vlastním jazykem a vlastními invarianty. Sdílení jediného modelu by vedlo ke god aggregate a ke kompromisům napříč sémanticky odlišnými oblastmi. Rozbor v sekci Architektura.
Jak spolu bounded contexty komunikují?
Primárním prostředkem integrace jsou doménové události: po dokončení operace agregát publikuje událost (např. TaskCreated), na kterou reagují jiné kontexty asynchronně přes Messenger. Synchronní dotazy mezi kontexty se řeší přes porty (rozhraní) s implementací v infrastruktuře cílového kontextu – volající kontext nezávisí na detailech implementace. Konkrétní ukázka v sekci Implementace.
Jaký přínos měla vertikální slice architektura?
Každá feature (CreateProject, AssignTask, AddComment) vznikla jako samostatný balíček s vlastním commandem, handlerem, kontrolerem a view modelem. Změna ve feature nezasahuje do ostatních slicí, což zkracuje cyklus vývoj–test–nasazení a usnadňuje onboarding. Šíření změn napříč vrstvami, typické pro horizontální členění, se v takovém uspořádání téměř nevyskytuje. Detailní srovnání v kapitole Architektonické styly.
Proč má smysl oddělit read model od doménového modelu?
Doménový model existuje pro vynucování invariantů a reprezentaci doménových pravidel; výpis projektů žádné invarianty nepotřebuje. Hydratace agregátu jen kvůli zobrazení názvu a počtu členů je drahá – při růstu datasetu rozhoduje, jestli výpis znamená jeden dotaz nad jednou tabulkou, nebo několik JOINů a stovky sestavených objektů. Denormalizovaný read model aktualizovaný přes projekce umožní oddělit tempo zápisu a čtení a optimalizovat každou stranu zvlášť. Cenou je eventual consistency. Konkrétní implementace v sekci Read modely a projekce.
Jaká jsou tři nejdůležitější ponaučení z projektu?
Zaprvé, kontextová mapa nakreslená před kódem oddělí významy, které jedno slovo nese v různých částech systému; bez ní se rozdíl objeví až ve sporech nad pull requesty. Zadruhé, ubiquitous language budovaný s doménovými experty drží stejné pojmy v kódu, v ticketu i v rozhovoru. Zatřetí, malé agregáty s jasnou transakční hranicí udrží model konzistentní bez distribuovaných transakcí. Úplný seznam včetně ponaučení o read modelech a vědomých trade-offech v sekci Ponaučení.
Co bylo nejtěžším rozhodnutím projektu?
Volba mezi synchronním ověřením členství v projektu (přes port ProjectChecker) a asynchronní reakcí přes lokální projekci. Synchronní cesta v monolitu znamená méně pohyblivých částí, ale vytváří časovou závislost mezi kontexty. Studie volí synchronní variantu jako pragmatický kompromis pro fázi monolitu, s vědomím, že při štěpení do služeb přijde refaktor na lokální projekci. Plný kontext rozhodnutí včetně dalších čtyř kompromisů v sekci Výzvy a rozhodnutí.