Architektonické styly – Hexagonal, Onion, Clean
DDD říká, co modelovat. Architektonický styl říká, kam to v kódu patří. Kapitola srovnává vrstvenou architekturu, Hexagonal (Cockburn), Onion (Palermo), Clean (Martin) a Vertical Slice a ukazuje, co z nich zvolit v Symfony 8.
Obsah kapitoly
Kapitola ve videokurzu
Celý videokurzPřehrávač YouTube se načte až po kliknutí.
Když tým řekne „přejdeme na DDD“, obvykle tím myslí dvě věci: budeme líp modelovat doménu a přerovnáme adresářovou strukturu. Obě rozhodnutí jsou ve skutečnosti ortogonální, tedy na sobě nezávislá. Domain-Driven Design je modelovací technika; architektonický styl určuje uspořádání kódu a směr závislostí. DDD funguje ve vrstvené architektuře, v Hexagonální, Onion, Clean i ve Vertical Slice. Platí to i obráceně: hexagonální architektura postavená nad anémickým CRUD modelem nemá s DDD nic společného.
Následuje srovnání čtyř vrstvových stylů (Layered, Hexagonal, Onion, Clean) a pátého, feature-orientovaného Vertical Slice, včetně jejich podoby v Symfony 8. Vítěz z něj nevychází, protože každý styl má kontext, ve kterém se vyplatí. Užitečnější jsou rozhodovací kritéria a anti-vzory, které z dobré teorie dělají špatný kód.
09.01 Proč architektonický styl není totéž co DDD#
Zmatek v DDD literatuře nejčastěji plyne ze směšování dvou nezávislých rozhodnutí. První je modelovací technika: budeme používat agregáty, hodnotové objekty, doménové události, Ubiquitous Language a Bounded Contexts? Nebo zůstaneme u procedurálního CRUDu, kde controller čte z databáze, zvaliduje data a zapíše je zpět? Druhé je uspořádání kódu: členit projekt podle technických vrstev, přes porty a adaptéry, do koncentrických prstenců, nebo podle feature?
Obě rozhodnutí se dají kombinovat libovolně. Existují projekty s čistým CRUD modelem v Hexagonální architektuře (porty oddělují HTTP od databáze, ale uvnitř je anémický řádek tabulky). Jinde žijí bohaté DDD agregáty v klasické vrstvené struktuře (Doctrine entity v adresáři src/Entity, ale s metodami jako $order->confirm(), $order->cancel() a invarianty kontrolovanými v konstruktoru). Architektonický styl ovlivňuje testovatelnost a kompozici; na modelovací metodu nesahá.
Eric Evans v původní knize Domain-Driven Design (2003) [1] věnuje vrstvené architektuře („layered architecture“) jen jednu krátkou kapitolu. Těžiště knihy leží v modelování. Vrstvy v ní model chrání před technickými detaily, cílem samy o sobě nejsou. Pozdější autoři (Vernon, Khononov, Millett & Tune) ukazují DDD ve stylech vrstvových, hexagonálních i feature-first. Všechny fungují, pokud má doménový model uvnitř skutečný obsah.
Triviální doméně (CRUD nad několika tabulkami, žádné invarianty, žádné stavové přechody) žádný architektonický styl nepomůže, protože není co chránit. Bohatá doména, kterou neoddělíte od frameworku (přímá volání EntityManageru, Symfony Request/Response objekty, externí HTTP klienti), dá kód, který na první pohled vypadá „čistě“. Bez celé infrastruktury se ale testovat nedá.
Styly jsou seřazené od nejjednoduššího po nejsložitější; u každého následuje jeho podoba v Symfony, vhodné a nevhodné nasazení a nejčastější anti-vzor.
09.02 Layered (klasická vrstvená)#
Martin Fowler v Patterns of Enterprise Application Architecture (2002) [2] pracuje se třemi hlavními vrstvami: Presentation, Domain (doménová logika) a Data Source. Eric Evans v Domain-Driven Design (2003) pracuje se čtyřmi vrstvami: User Interface (Presentation), Application, Domain a Infrastructure. Trvá přitom na pravidle, že vrstva smí záviset jen na vrstvách pod sebou, nikdy nahoru. Pozdější DDD literatura toto rozdělení převzala.
Čtyři standardní vrstvy
- Presentation Layer – interakce se světem (HTTP controllery, CLI commandy, GraphQL resolvery). V Symfony to jsou třídy v
src/Controller/. - Application Layer – orchestrace use casů, transakce, mapování DTO. Tenké třídy, žádná doménová logika; ta žije v doméně. V Symfony bývají v
src/Service/nebosrc/Application/. - Domain Layer – agregáty, entity, hodnotové objekty, doménové služby, repository rozhraní. Žádné závislosti na frameworku. V Symfony obvykle
src/Entity/+src/Domain/. - Infrastructure Layer – Doctrine repository implementace, e-mail brány, HTTP klienti, Messenger transporty. V Symfony
src/Repository/+src/Infrastructure/.
Struktury dál v kapitole pracují jen se třemi adresáři (Domain/, Application/, Infrastructure/) a controllery řadí do infrastruktury. Tak vrstvy pro PHP popsal Matthias Noback a konvence se ujala. Z Evansovy čtveřice tím splývá UI s infrastrukturou; pravidlo o směru závislostí zůstává beze změny.
Typická Symfony struktura
1src/2├── Controller/ # Presentation3│ ├── OrderController.php4│ └── CustomerController.php5├── Service/ # Application6│ ├── OrderService.php7│ └── CustomerService.php8├── Entity/ # Domain (s Doctrine atributy → leak)9│ ├── Order.php10│ ├── OrderLine.php11│ └── Customer.php12├── Repository/ # Infrastructure13│ ├── OrderRepository.php14│ └── CustomerRepository.php15└── Form/ # Presentation (vstupy)16 └── OrderType.php
Adresáře Controller/, Entity/ a Repository/ odpovídají výchozímu Symfony skeletonu. Plní je MakerBundle: make:controller vytvoří controller, make:entity entitu i její repository, make:form formulář. Junior tým se ve struktuře vyzná bez vysvětlování. Každý soubor má své místo a nový use case znamená controller, service, entitu a repository.
Příklad doménové entity ve vrstveném DDD
1<?php2 3declare(strict_types=1);4 5namespace App\Entity;6 7use App\Repository\OrderRepository;8use Doctrine\Common\Collections\ArrayCollection;9use Doctrine\Common\Collections\Collection;10use Doctrine\ORM\Mapping as ORM;11 12#[ORM\Entity(repositoryClass: OrderRepository::class)]13#[ORM\Table(name: 'orders')]14class Order15{16 #[ORM\Id]17 #[ORM\Column(type: 'string', length: 36)]18 private string $id;19 20 #[ORM\Column(type: 'string', length: 32)]21 private string $status = 'draft';22 23 #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]24 private Collection $lines;25 26 public function __construct(string $id)27 {28 $this->id = $id;29 // Bez inicializace skončí první dotaz na kolekci hláškou30 // "Typed property must not be accessed before initialization".31 $this->lines = new ArrayCollection();32 }33 34 public function id(): string35 {36 return $this->id;37 }38 39 public function status(): string40 {41 return $this->status;42 }43 44 public function confirm(): void45 {46 if ($this->status !== 'draft') {47 throw new \DomainException('Only draft orders can be confirmed.');48 }49 if ($this->lines->isEmpty()) {50 throw new \DomainException('Cannot confirm an empty order.');51 }52 $this->status = 'confirmed';53 }54 55 public function cancel(): void56 {57 if ($this->status === 'shipped') {58 throw new \DomainException('Cannot cancel a shipped order.');59 }60 $this->status = 'cancelled';61 }62}
Třída Order má bohaté chování (confirm(), cancel()) a kontroluje invarianty, což je kvalitní DDD modelování. Přes atributy #[ORM\Entity] a #[ORM\Column] ale závisí na Doctrine ORM. Pravidlo „nelze potvrdit prázdnou objednávku“ tak žije v doménovém kódu, který ví, že se ukládá přes Doctrine. Z pohledu Hexagonal/Onion architektury jde o domain leak: doménová třída importuje knihovnu z Infrastructure a nese její metadata. Layered tento kompromis pragmaticky přijímá a kniha ho má jako výchozí volbu (rozbor v kapitole Implementace v Symfony 8). Hexagonal trvá na separaci přes Persisted Object Pattern. Ve stejném duchu tu stojí i holá \DomainException. Zbytek knihy používá pojmenované výjimky jako InvalidOrderStateTransitionException; Layered ukázka zůstává u zkratky, aby bylo vidět, co styl skutečně vyžaduje a co ne.
Kdy se Layered hodí
Layered se vyplatí tam, kde je předvídatelnost cennější než izolace. Juniornímu týmu dá Symfony skeleton a make:* commandy strukturu, kterou nemusí vymýšlet. U aplikace s deseti až padesáti endpointy se investice do portů a adaptérů nevrátí. Má-li produkt krátký horizont (MVP, prototyp, interní nástroj), je vendor lock-in na Doctrine teoretické riziko, protože migrace nikdy nepřijde. Týmu, který Symfony ovládá plynně, by dodatečná vrstva jen zdržovala práci a žádný jeho skutečný problém by neřešila.
Kdy Layered přestává stačit
- Doménový model vyžaduje testy bez databáze. Testy přes Doctrine fixtures jsou pomalé a křehké.
- Plánujete vyměnit perzistentní vrstvu (např. PostgreSQL → DynamoDB, nebo Doctrine → manuální SQL). Vyjmout Doctrine atributy z entit pak znamená rozsáhlou migraci.
- Doménová pravidla potřebují žít na jednom místě. Ve vrstveném modelu mají sklon rozptýlit se mezi controllery, service vrstvu a entity.
- Aplikace má více vstupních kanálů (HTTP API, CLI, message queue, GraphQL). Application Service psaný kolem HTTP Request objektu se na CLI vstup hodí špatně.
Typický Layered controller v Symfony
Orchestrační kód v Layered architektuře: controller načte Doctrine entitu z repository a předá ji Application Service, která zavolá doménovou metodu a flushne změny. Žádné porty, DTO mappery ani explicitní rozhraní mezi vrstvami.
1<?php2 3declare(strict_types=1);4 5namespace App\Controller;6 7use App\Repository\OrderRepository;8use App\Service\OrderService;9use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;10use Symfony\Component\HttpFoundation\JsonResponse;11use Symfony\Component\HttpFoundation\Request;12use Symfony\Component\Routing\Attribute\Route;13 14final class OrderController extends AbstractController15{16 public function __construct(17 private readonly OrderRepository $repository,18 private readonly OrderService $service,19 ) {20 }21 22 #[Route('/orders/{id}/confirm', methods: ['POST'])]23 public function confirm(string $id): JsonResponse24 {25 $order = $this->repository->find($id);26 if ($order === null) {27 throw $this->createNotFoundException("Order {$id} not found.");28 }29 30 $this->service->confirm($order);31 32 return new JsonResponse(['status' => $order->status()]);33 }34}
Kód je čitelný, krátký a pro Symfony typický. Cenu zaplatíte v testech. OrderController::confirm() otestujete buď přes WebTestCase s celým bootem aplikace, nebo s mocky OrderRepository i OrderService. V Hexagonal struktuře stačí zavolat use case bez controlleru.
09.03 Hexagonal Architecture (Ports & Adapters, Cockburn 2005)#
V klasické třívrstvé struktuře (UI / Logic / Database) procházely testy aplikační logiky nutně buď přes UI, nebo přes databázi. To Alistairu Cockburnovi vadilo a v roce 2005 navrhl v článku Hexagonal Architecture (Ports and Adapters) [3] jiné uspořádání. Aplikační jádro (doména) v něm komunikuje s vnějším světem výhradně přes dobře definované porty (rozhraní). Konkrétní technologie (HTTP, SQL, e-mail, fronta zpráv) tyto porty implementují jako adaptéry.
Číslo šest v hexagonu nic neznamená. Cockburn ho zvolil proto, aby měl kreslíř kolem jádra dost místa na porty a adaptéry a nebyl svázaný jednorozměrným vrstvovým schématem [3]. Stejně dobře by posloužil osmiúhelník nebo trojúhelník.
Od roku 2024 má vzor i knižní zpracování: Hexagonal Architecture Explained, které Cockburn napsal s Juanem Manuelem Garridem de Paz. Kniha vznikla mimo jiné jako reakce na výklady, které se od originálu odchýlily.
Dva typy portů
- Driving (Inbound, Primary) port – to, co aplikace umí. Definuje, jak vnější svět volá doménu. V DDD termínech to odpovídá Application Service nebo Use Case rozhraní. Příklad:
ForPlacingOrders,ForCancellingOrders,ForGettingOrderHistory. Předpona For sleduje Cockburnovu konvenci: port se jmenuje podle účelu konverzace. Jména portů se díky ní nekříží s příkazemPlaceOrderz kapitoly CQRS. - Driven (Outbound, Secondary) port – to, co aplikace potřebuje. Definuje rozhraní pro externí závislosti. V DDD jsou to repository rozhraní, brány na externí systémy, publishery doménových událostí. Příklad:
OrderRepository,EmailSender,EventPublisher.
Adaptéry implementují porty: Driving adaptér (Symfony Controller, CLI Command, Messenger Handler) volá inbound port; Driven adaptér (Doctrine Repository, SMTP Mailer, RabbitMQ publisher) implementuje outbound port. Doména samotná nezná žádný adaptér ani konkrétní technologii.
Kolik portů dává smysl
Port není v originále synonymum pro rozhraní jedné závislosti. Cockburn ho definuje jako „účelovou konverzaci“, tedy tematický kanál, do kterého se typicky zapojuje víc adaptérů pro různé technologie [3]. K počtu dodává, že krajní varianta „port pro každý use case“ vede u větší aplikace ke stovkám portů. Sám se přiklání ke dvěma až čtyřem. V ukázkovém systému jmenuje čtyři: příjem dat o počasí, správce, odběratele notifikací a databázi odběratelů.
PHP praxe jde jinudy. Repozitáře, mailery a publishery událostí dostávají vlastní rozhraní jedna ku jedné, protože to odpovídá tomu, jak se v Symfony píše autowiring i testovací double. Tuto jemnější granularitu používá i zbytek průvodce. Cockburnova definice to ale není: u něj pokrývá jeden port celou konverzaci, kterou PHP praxe rozdělí do několika rozhraní.
Jedna hranice platí v obou výkladech. V rozhovoru z roku 2020 označuje Cockburn za hlavní chybu praxe „jednu technologii na port, nebo port na technologii“ [4]. Tím se ztrácí smysl portu, tedy záměna technologie beze změny jádra. Rozhraní RedisOrderCache je porušením vzoru; OrderCache s Redis adaptérem a in-memory adaptérem pro testy není.
Mechanismus pod porty pojmenoval Gerard Meszaros při rozhovoru s Cockburnem v roce 2011 jako Configurable Dependency. Konkrétní implementaci takové závislosti určuje až sestavení aplikace zvenčí [4]. V Symfony tu roli plní Service Container.
Symfony struktura podle Hexagonal
1src/2├── Ordering/ # Bounded Context3│ ├── Domain/ # Doménové jádro (žádné framework deps)4│ │ ├── Model/5│ │ │ ├── Order.php # Aggregate Root – ČISTÉ PHP6│ │ │ └── OrderLine.php7│ │ ├── ValueObject/8│ │ │ └── OrderId.php9│ │ ├── Event/10│ │ │ └── OrderConfirmed.php11│ │ └── Port/ # Outbound porty (interfaces)12│ │ ├── OrderRepository.php13│ │ └── EventPublisher.php14│ ├── Application/ # Inbound porty + use casy15│ │ ├── UseCase/16│ │ │ ├── ForPlacingOrders.php # Inbound port (interface)17│ │ │ └── PlaceOrderHandler.php # Implementace use casu18│ │ └── Dto/19│ │ └── PlaceOrderInput.php20│ └── Infrastructure/ # Adaptéry (driving + driven)21│ ├── Http/ # Driving adapter22│ │ └── PlaceOrderController.php23│ ├── Cli/ # Driving adapter24│ │ └── PlaceOrderCommand.php25│ └── Persistence/ # Driven adapter26│ ├── DoctrineOrderRepository.php27│ ├── OrderMapper.php # Překlad doména ↔ ORM28│ └── OrderOrmEntity.php # Doctrine entita pro persistenci29└── SharedKernel/30 └── Domain/31 └── DomainException.php
Ze struktury plyne:
- Adresář
Domain/neobsahuje import z Doctrine, Twig ani jiné knihovny, jen čisté PHP a vlastní typy. Ze Symfony smí jen komponentaSymfony\Component\Uid, přes kterouOrderId::generate()vytváří identitu. - Repository rozhraní (
OrderRepository) žije vDomain/Port/; jeho implementace (DoctrineOrderRepository) žije vInfrastructure/Persistence/. Doména závisí na rozhraní, infrastruktura ho implementuje. - Doménová entita (
Order) není Doctrine entita. K mapování slouží samostatnáOrderOrmEntity+ mapper (vzor Persisted Object Pattern), takže doména zůstává čistá. Pozn.: Hexagonal Architecture trvá na této separaci. Pragmatičtější přístup, který zbytek průvodce používá jako výchozí, atributy přímo na agregátu připouští (viz rozhodnutí o mappingu). - Vstup do aplikace prochází přes inbound port (
ForPlacingOrders). HTTP Controller a CLI Command nezávisí na doméně přímo, ale na tomto portu.
Dělení jádra na Domain/ a Application/ v originále nenajdete. Cockburn popisuje jen vnitřek a vnějšek hexagonu; rozdělení na aplikační a doménovou vrstvu je podle Garrida de Paz téma DDD, ne hexagonální architektury [4]. Struktura výše je tedy skladba dvou vzorů, ne jednoho.
Příklad: Outbound port a jeho adaptér
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Port;6 7use App\Ordering\Domain\Exception\OrderNotFoundException;8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\ValueObject\OrderId;10 11// Port/ je hexagonální jméno pro adresář, kterému zbytek knihy říká Repository/.12interface OrderRepository13{14 /** @throws OrderNotFoundException */15 public function get(OrderId $id): Order;16 17 public function save(Order $order): void;18 19 // Seznam objednávek zákazníka sem nepatří: obrazovky obsluhuje20 // read model (kapitola CQRS), repozitář načítá agregát pro změnu.21}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Persistence;6 7use App\Ordering\Domain\Exception\OrderNotFoundException;8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\Port\OrderRepository;10use App\Ordering\Domain\ValueObject\OrderId;11use Doctrine\ORM\EntityManagerInterface;12 13final class DoctrineOrderRepository implements OrderRepository14{15 public function __construct(16 private readonly EntityManagerInterface $em,17 private readonly OrderMapper $mapper,18 ) {19 }20 21 public function get(OrderId $id): Order22 {23 $orm = $this->em->find(OrderOrmEntity::class, $id->value)24 ?? throw OrderNotFoundException::withId($id);25 26 return $this->mapper->toDomain($orm);27 }28 29 public function save(Order $order): void30 {31 // Ukázka pokrývá insert. Reálná implementace při update nejprve32 // najde existující OrderOrmEntity přes find() a přepíše její pole –33 // persist() nové instance by skončil kolizí primárního klíče.34 //35 // Flush je tu proto, že kontroler volá port přímo. Jakmile příkazy36 // půjdou přes command bus s doctrine_transaction middleware,37 // transakci vlastní ten a flush odsud zmizí. Bez jednoho nebo38 // druhého API vrátí 201 a v databázi nezůstane nic.39 $orm = $this->mapper->toOrm($order);40 $this->em->persist($orm);41 $this->em->flush();42 }43}
Doménová třída Order je čisté PHP bez jediného Doctrine atributu. OrderOrmEntity je samostatná persistenční třída s Doctrine mapováním a OrderMapper překlápí mezi nimi. Platí se za to dvojí třídou a ručně psaným mapováním. Doménový model se pak dá testovat v paměti bez databáze a změna persistenční vrstvy ho nezasáhne.
Příklad: Inbound port a jeho HTTP adapter
Driving (inbound) port definuje, co aplikace umí. V DDD termínech je to kontrakt Application Service. V Symfony 8 se zpravidla mapuje na CQRS Command/Query handler (podrobně v kapitole CQRS) dispatchovaný přes Messenger Bus. Port jde také zapsat explicitně jako rozhraní s jediným handlerem jako implementací.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\UseCase;6 7use App\Ordering\Application\Dto\PlaceOrderInput;8use App\Ordering\Application\Dto\PlaceOrderOutput;9 10/**11 * Inbound port (driving) – kontrakt aplikační schopnosti12 * „umístit objednávku“. HTTP adaptér, CLI command i testy13 * volají přes tento port; konkrétní implementace je v handleru.14 */15interface ForPlacingOrders16{17 public function handle(PlaceOrderInput $input): PlaceOrderOutput;18}
Oba DTO jsou obyčejné neměnné struktury bez chování. Port jimi vymezuje, co vstupuje dovnitř a co vystupuje ven:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\Dto;6 7final readonly class PlaceOrderInput8{9 /** @param list<array{productId: string, quantity: int, unitPriceInCents: int}> $items */10 public function __construct(11 public string $customerId,12 public array $items,13 ) {}14}15 16final readonly class PlaceOrderOutput17{18 public function __construct(19 public string $orderId,20 public string $status,21 ) {}22}
HTTP adaptér nezná konkrétní třídu handleru, jen rozhraní portu. Port na implementaci naváže kontejner (viz sekci o Service Containeru níže). V testech tak jde handler vyměnit za fake bez celé aplikační vrstvy.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Http;6 7use App\Ordering\Application\Dto\PlaceOrderInput;8use App\Ordering\Application\UseCase\ForPlacingOrders;9use Symfony\Component\HttpFoundation\JsonResponse;10use Symfony\Component\HttpFoundation\Request;11use Symfony\Component\Routing\Attribute\Route;12 13final class PlaceOrderController14{15 public function __construct(16 private readonly ForPlacingOrders $useCase,17 ) {18 }19 20 #[Route('/api/orders', methods: ['POST'])]21 public function __invoke(Request $request): JsonResponse22 {23 $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);24 25 $input = new PlaceOrderInput(26 customerId: $payload['customerId'],27 items: $payload['items'],28 );29 30 $output = $this->useCase->handle($input);31 32 return new JsonResponse([33 'orderId' => $output->orderId,34 'status' => $output->status,35 ], 201);36 }37}
Symfony Service Container a auto-wiring
Symfony autowiring doplňuje závislosti podle typu. U rozhraní si kontejner poradí sám, pokud při načítání services.yaml objeví rozhraní i právě jednu jeho implementaci. Alias na ni pak vytvoří automaticky. V hexagonální struktuře výše leží OrderRepository i DoctrineOrderRepository pod src/, takže type-hint na port funguje bez jediného řádku konfigurace.
Ruční alias je potřeba ve dvou situacích. Buď je implementací víc než jedna, nebo adresář s rozhraním či s implementací nespadá do resource, typicky kvůli vyloučení (viz Konfigurace per-context). Alias zapsaný i tam, kde by vznikl sám, chybou není: dokumentuje volbu výchozího adaptéru. První možnost je zápis v config/services.yaml:
1services:2 _defaults:3 autowire: true4 autoconfigure: true5 6 App\:7 resource: '../src/'8 9 # Explicitní binding portu na výchozí adaptér10 App\Ordering\Domain\Port\OrderRepository:11 alias: App\Ordering\Infrastructure\Persistence\DoctrineOrderRepository12 13 # Pro testy lze přepsat v config/services_test.yaml
Druhá možnost je atribut #[AsAlias] přímo na implementaci. Alias pak žije ve vrstvě Infrastructure, kam patří:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Persistence;6 7use App\Ordering\Domain\Port\OrderRepository;8use Symfony\Component\DependencyInjection\Attribute\AsAlias;9 10#[AsAlias(id: OrderRepository::class)]11final class DoctrineOrderRepository implements OrderRepository12{13 // ... viz implementace výše14}
Use case v Application vrstvě pak deklaruje závislost prostým type-hintem na doménové rozhraní a o existenci Doctrine adaptéru neví:
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Application\UseCase;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\Port\OrderRepository;9use App\Ordering\Domain\ValueObject\CustomerId;10use App\Ordering\Domain\ValueObject\OrderId;11use App\Ordering\Domain\ValueObject\ProductId;12use App\Ordering\Application\Dto\PlaceOrderInput;13use App\Ordering\Application\Dto\PlaceOrderOutput;14use App\SharedKernel\Domain\Currency;15use App\SharedKernel\Domain\Money;16 17final class PlaceOrderHandler implements ForPlacingOrders18{19 public function __construct(20 private readonly OrderRepository $orders,21 ) {22 }23 24 public function handle(PlaceOrderInput $input): PlaceOrderOutput25 {26 // DTO nese primitivy z HTTP vrstvy; převod na hodnotové objekty27 // patří sem, do aplikační vrstvy. Doména primitivy nepřijímá.28 $order = Order::place(29 OrderId::generate(),30 CustomerId::fromString($input->customerId),31 );32 33 foreach ($input->items as $item) {34 $order->addItem(35 ProductId::fromString($item['productId']),36 $item['quantity'],37 new Money($item['unitPriceInCents'], Currency::CZK),38 );39 }40 41 $this->orders->save($order);42 43 return new PlaceOrderOutput($order->id->value, $order->status->value);44 }45}
Atribut #[Autowire(service: DoctrineOrderRepository::class)] přímo v konstruktoru handleru je v Application vrstvě anti-vzor. Vyžaduje import Infrastructure třídy, čímž porušuje Dependency Rule, kterou celá struktura chrání. Use case by znal konkrétní adaptér a záměna implementace (testovací InMemoryOrderRepository) by znamenala zásah do aplikačního kódu místo do konfigurace. Alias patří do services.yaml nebo na implementaci, nikdy do vnitřních vrstev.
Jakmile portu odpovídá víc implementací, automatický alias zaniká a kontejner ohlásí nejednoznačnost. Výchozí adaptér pak určuje alias a druhá implementace se zpřístupní pojmenovaným autowiring aliasem:
1services:2 # Výchozí adaptér portu3 App\Ordering\Domain\Port\OrderRepository:4 alias: App\Ordering\Infrastructure\Persistence\DoctrineOrderRepository5 6 # Druhá implementace pod pojmenovaným aliasem7 App\Ordering\Domain\Port\OrderRepository $readOnlyOrders:8 alias: App\Ordering\Infrastructure\Persistence\ReadOnlyOrderRepository
Na pojmenovaný alias se v konstruktoru odkazuje atribut #[Target('readOnlyOrders')]. Vazba podle jména parametru bez atributu funguje také, ale rozbije ji přejmenování parametru. Testovací prostředí přepisuje výchozí alias v config/services_test.yaml, takže InMemoryOrderRepository nahradí Doctrine adaptér bez zásahu do aplikačního kódu.
Druhý port: publisher doménových událostí
Repository je nejviditelnější outbound port, ne však jediný. Druhým častým kandidátem je publikace doménových událostí. Aplikační vrstva volá EventPublisher::publish($event) a neřeší, kdo události konzumuje. Implementací může být Symfony Messenger, RabbitMQ, in-memory dispatcher pro testy, v jednoduchých scénářích i no-op.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Domain\Port;6 7interface EventPublisher8{9 // Události se typují jako `object`. Kanonický AggregateRoot::record()10 // v této knize žádnou bázovou třídu událostí nevyžaduje, takže vázat11 // port na společného předka by sem nepustil ani OrderPlaced.12 public function publish(object $event): void;13 14 /**15 * @param iterable<object> $events16 */17 public function publishAll(iterable $events): void;18}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Messaging;6 7use App\Ordering\Domain\Port\EventPublisher;8use Symfony\Component\Messenger\MessageBusInterface;9 10final class MessengerEventPublisher implements EventPublisher11{12 public function __construct(13 private readonly MessageBusInterface $eventBus,14 ) {15 }16 17 public function publish(object $event): void18 {19 $this->eventBus->dispatch($event);20 }21 22 public function publishAll(iterable $events): void23 {24 foreach ($events as $event) {25 $this->eventBus->dispatch($event);26 }27 }28}
Pro testy stačí InMemoryEventPublisher, který události jen sbírá do pole. Test pak ověří, co use case publikoval, bez Messengeru, RabbitMQ a jiné infrastruktury. Běží v jednotkách milisekund, ne ve stovkách.
1<?php2 3declare(strict_types=1);4 5namespace App\Tests\Ordering\Doubles;6 7use App\Ordering\Domain\Port\EventPublisher;8 9final class InMemoryEventPublisher implements EventPublisher10{11 /** @var list<object> */12 private array $published = [];13 14 public function publish(object $event): void15 {16 $this->published[] = $event;17 }18 19 public function publishAll(iterable $events): void20 {21 foreach ($events as $event) {22 $this->publish($event);23 }24 }25 26 /**27 * @return list<object>28 */29 public function published(): array30 {31 return $this->published;32 }33}
Kdy se Hexagonal hodí
- Doména s bohatým chováním – kde se vyplatí investovat do testů domény bez databáze.
- Více vstupních kanálů. HTTP API, CLI, Messenger consumer i GraphQL jsou jen jiné driving adaptéry nad stejným inbound portem.
- Plánovaná výměna technologie – migrace z Doctrine ORM na DBAL nebo na cloudovou databázi se omezí na nový adaptér.
- Aplikace s 50–500 endpointy – kde se overhead portů amortizuje počtem use casů.
Kdy Hexagonal nedává smysl
U CRUDu nad několika tabulkami je port, adaptér a mapper pro každou entitu režie bez návratnosti. Nepomůže ani týmu, který neovládá Dependency Injection; bez inverze závislostí je struktura jen kosmetická. A nevyplatí se ani u produktu s krátkým horizontem.
09.04 Onion Architecture (Palermo 2008)#
Onion Architecture představil Jeffrey Palermo v blogové sérii [5]. První tři díly vyšly v roce 2008, čtvrtý (Part 4 – After Four Years) v roce 2013. Vzor upravuje vrstvenou architekturu: doménový model staví do středu a závislosti smějí směřovat jen dovnitř, nikdy ven. Jméno Dependency Rule dal tomuto pravidlu až Robert C. Martin (viz Clean Architecture). Metaforou je cibule (onion) s koncentrickými prstenci.
Čtyři koncentrické vrstvy Onion
- Domain Model (jádro) – entity, hodnotové objekty, agregáty, doménové události. Nezávisí na ničem, tedy ani na frameworku a persistenci.
- Domain Services – bezstavové třídy s doménovou logikou, která nepatří do žádné konkrétní entity. Závisí jen na Domain Model. Palermo do prvního prstenu kolem modelu v textu řadí hlavně rozhraní repozitářů; název vrstvy nese jeho diagram [5].
- Application Services – orchestrace use casů, transakce, mapování DTO. Závisí na Domain Services a Domain Model.
- UI / Infrastructure – controllery, repository implementace, externí brány. Vnější vrstva závisí na Application Services.
Podstatné je slovo koncentrické. Vrstvy nejsou naskládané nad sebou (nahoře UI, dole DB), ale soustředné: jádro uprostřed, vnější svět kolem. Tím mizí jeden problém klasické vrstvené architektury, kde Domain smí záviset na Infrastructure (třeba číst z databáze). Onion to nedovoluje. Repozitáře jádro deklaruje jako rozhraní a implementuje je vrstva UI/Infrastructure.
V dílu After Four Years shrnul Palermo vzor do čtyř tezí [6]. Aplikace stojí kolem nezávislého objektového modelu. Vnitřní vrstvy definují rozhraní, vnější je implementují. Každá vazba míří do středu. A jádro se dá zkompilovat a spustit bez infrastruktury. Tamtéž odmítá běžné čtení, že jde o „DDD architekturu“: Onion podle něj funguje s DDD vzory i bez nich, s CQRS stejně jako s prostými formuláři nad daty, a obejde se i bez IoC kontejneru. Výklad přes agregáty a doménové služby, který zde následuje, je tedy jedno z možných čtení, ne definice vzoru.
Rozdíl proti Hexagonal
Onion a Hexagonal stojí na téže myšlence: izolovat doménu a obrátit závislosti dovnitř. V běžné implementaci jsou v Symfony nerozlišitelné. Tři jemné odlišnosti:
- Vrstvení uvnitř. Onion explicitně rozlišuje Domain Services a Application Services jako dvě samostatné vrstvy. Hexagonal je topologicky střídmější – port + adaptér, žádné vnitřní vrstvení.
- Statický vs. dynamický pohled. Onion popisuje vrstvy a kdo na koho závisí. Hexagonal se dívá dynamicky: porty, adaptéry a cesta dat skrz ně.
- Driving vs. driven porty. V Onion je v UI vrstvě i HTTP controller (driving) i Doctrine repository (driven). Z pohledu Hexagonal je to nepřesné – driving adaptér volá aplikaci, driven adaptér je volán doménou.
Pokud váš projekt používá Hexagonal slovník (port, adapter, driving, driven), ale uvnitř má dvě vrstvy služeb (Domain Service, Application Service), děláte hybrid Hexagonal+Onion. To je v pořádku. Málokdo implementuje jeden styl „čistě“.
Příklad: Domain Service vs. Application Service
Domain Service obsahuje doménovou logiku, která nepatří do agregátu (typicky proto, že pracuje s více agregáty najednou nebo vyžaduje data, která agregát nemá k dispozici). Application Service je orchestrátor. Řídí transakci, načítá agregáty z repository, volá doménovou logiku a publikuje výstupy.
1<?php2 3declare(strict_types=1);4 5namespace App\Pricing\Domain\Service;6 7use App\Pricing\Domain\Model\Cart;8use App\Pricing\Domain\Model\Customer;9use App\Pricing\Domain\Model\DiscountPolicy;10use App\SharedKernel\Domain\Money;11 12/**13 * Domain Service – výpočet ceny vyžaduje data z více agregátů14 * (Cart, Customer, DiscountPolicy). Logika je čistě doménová,15 * žádný framework, žádná persistence.16 */17final class PriceCalculator18{19 public function calculate(20 Cart $cart,21 Customer $customer,22 DiscountPolicy $policy,23 ): Money {24 $subtotal = $cart->subtotal();25 $discount = $policy->applyTo($subtotal, $customer->loyaltyTier());26 $net = $subtotal->subtract($discount);27 $vat = $net->percentage(21);28 29 return $net->add($vat);30 }31}
1<?php2 3declare(strict_types=1);4 5namespace App\Pricing\Application\Service;6 7use App\Pricing\Domain\Port\CartRepository;8use App\Pricing\Domain\Port\CustomerRepository;9use App\Pricing\Domain\Port\DiscountPolicyRepository;10use App\Pricing\Domain\Service\PriceCalculator;11use App\Pricing\Domain\ValueObject\CartId;12use App\SharedKernel\Domain\Money;13 14/**15 * Application Service – orchestrace use casu „Spočítej cenu košíku“.16 * Vlastní logika je v Domain Service; aplikační vrstva jen řídí transakci17 * a načítá agregáty z repository.18 */19final class CalculateCartPrice20{21 public function __construct(22 private readonly CartRepository $carts,23 private readonly CustomerRepository $customers,24 private readonly DiscountPolicyRepository $policies,25 private readonly PriceCalculator $calculator,26 ) {27 }28 29 public function execute(string $cartId): Money30 {31 // get() hází CartNotFoundException / CustomerNotFoundException32 // z Domain\Exception, když agregát neexistuje.33 $cart = $this->carts->get(CartId::fromString($cartId));34 $customer = $this->customers->get($cart->customerId());35 36 $policy = $this->policies->forCustomer($customer);37 38 return $this->calculator->calculate($cart, $customer, $policy);39 }40}
Obě třídy se liší přístupem k datům. PriceCalculator repository nezná a bere si již načtené objekty. CalculateCartPrice k nim přistupuje přes porty a řídí načtení i předání dat. Když obě odpovědnosti slijete do jedné třídy, přijdete o možnost testovat výpočet ceny izolovaně, bez databáze.
Onion struktura v Symfony
1src/2├── Pricing/ # Bounded Context3│ ├── Domain/ # Vnitřní prsten (jádro)4│ │ ├── Model/5│ │ │ ├── Cart.php6│ │ │ ├── Customer.php7│ │ │ └── DiscountPolicy.php8│ │ ├── ValueObject/9│ │ │ └── CartId.php10│ │ ├── Port/ # Repository interfaces11│ │ │ ├── CartRepository.php12│ │ │ ├── CustomerRepository.php13│ │ │ └── DiscountPolicyRepository.php14│ │ └── Service/ # 2. prsten – Domain Services15│ │ └── PriceCalculator.php16│ ├── Application/ # 3. prsten – Application Services17│ │ └── Service/18│ │ ├── CalculateCartPrice.php19│ │ └── ApplyCouponToCart.php20│ └── Infrastructure/ # Vnější prsten – UI a infra21│ ├── Persistence/22│ │ └── DoctrineCartRepository.php23│ └── Http/24│ └── CartPriceController.php25└── SharedKernel/26 └── Domain/27 └── Money.php
Symfony auto-wiring funguje pro Onion stejně jako pro Hexagonal. Application Service závisí na Domain Service a portech, vnější HTTP adapter na Application Service. Žádná třída v Domain/ nepoužívá use Doctrine\… a ze Symfony jen komponentu Symfony\Component\Uid, která generuje identifikátory. Ostatní use v jádře míří na třídy z Domain/, ze sdíleného jádra (SharedKernel/Domain/) a na identifikátory cizích kontextů (App\*\Domain\ValueObject\*Id).
Kdy se Onion hodí
- Domény s rozsáhlými Domain Services – pricing engine, risk scoring, tax calculation, kde hodně logiky pracuje s víc agregáty najednou.
- Týmy, které mají rády explicitní vrstvení – Onion má jasné jméno pro každou vrstvu a směr závislostí hlídá statická analýza v CI. V PHP se k tomu používá Deptrac, typicky právě s vrstvami Domain / Application / Infrastructure; postup je v kapitole Testování DDD.
- Enterprise aplikace s 100+ use casy – kde rozdělení Domain Services a Application Services brání monolitickým „God service“ třídám.
Kdy Onion nedává smysl
Doména s hrstkou doménových služeb dvě vrstvy služeb neuživí; Hexagonal pak stačí. Druhá překážka je zkušenost týmu. Hranice mezi Domain Service a Application Service není intuitivní a špatné zařazení jedné třídy protáhne infrastrukturní závislost až do jádra.
09.05 Clean Architecture (Robert C. Martin 2012)#
Robert C. Martin („Uncle Bob“) shrnul společné rysy Hexagonal, Onion, vlastní Screaming Architecture, DCI a BCE (Boundary-Control-Entity od Ivara Jacobsona) do jednoho modelu. Výsledkem byl blogový post Clean Architecture z roku 2012 [7]. O pět let později ho rozvedl do knihy Clean Architecture: A Craftsman's Guide to Software Structure and Design (Prentice Hall, 2017). Ta vzor doplňuje o kapitoly k hranicím komponent a k organizaci balíčků.
Čtyři prsteny Clean Architecture
- Entities – doménové objekty s nejhlubšími invarianty. Odpovídá DDD agregátům a hodnotovým objektům. Nezávisí na ničem.
- Use Cases – obchodní pravidla specifická pro aplikaci. V běžné implementaci je každý use case třída s jednou veřejnou metodou (
execute()nebohandle()). Závisí jen na Entities. - Interface Adapters – Controllers (pro vstup), Presenters (pro výstup), Gateways (pro outbound). Překlápějí mezi formátem use casu a formátem vnějšího světa.
- Frameworks & Drivers – Symfony, Doctrine, HTTP klienty, databázové ovladače. Vnější prsten, kde žije všechno framework-specifické.
Dependency Rule: závislosti ve zdrojovém kódu směřují jen dovnitř. Vnější vrstva smí odkazovat na třídy vnitřní vrstvy, nikdy naopak. Potřebuje-li vnitřní vrstva něco z vnější (např. uložit objednávku), použije Dependency Inversion: rozhraní definuje u sebe a vnější vrstva ho implementuje.
Počet čtyři přitom není závazný. Martin sám píše, že prstence jsou schéma a aplikace jich může potřebovat víc [7]. Druhé pravidlo se týká toho, co hranici překračuje: jednoduché datové struktury, nikdy ORM entity ani databázové řádky. Request a Response DTO v ukázkách níže jsou přesně tím.
Co Clean přidává proti Onion a Hexagonal
Hexagonal a Onion jednotlivé use casy samostatně nepojmenovávají: Hexagonal mluví o „inbound portech“, Onion o „Application Services“. Clean Architecture povyšuje use case na prvotřídní koncept. Každý use case je jedna třída s jednou metodou a vlastním Request/Response DTO, takže kód sám tvoří seznam schopností aplikace.
V DDD termínech: Use Case z Clean Architecture ≈ DDD Application Service ≈ CQRS Command Handler. Při Symfony Messengeru jako Command Busu (viz kapitolu CQRS) plní roli Clean Use Case váš PlaceOrderHandler.
Příklad: Use Case s Request/Response DTO
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\UseCase\PlaceOrder;6 7/**8 * Request DTO – vstup do use casu, framework-agnostický.9 * Žádné Symfony Request, žádné Doctrine entity, žádné HTTP detaily.10 */11final readonly class PlaceOrderRequest12{13 /**14 * @param list<array{productId: string, quantity: int, unitPriceInCents: int}> $items15 */16 public function __construct(17 public string $customerId,18 public array $items,19 public string $shippingAddress,20 ) {21 }22}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\UseCase\PlaceOrder;6 7/**8 * Response DTO – výstup z use casu. Žádné view, žádný JSON.9 * Adaptér (Controller, CLI Command) si zformátuje výstup sám.10 */11final readonly class PlaceOrderResponse12{13 public function __construct(14 public string $orderId,15 public string $status,16 public int $totalAmount,17 ) {18 }19}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\UseCase\PlaceOrder;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\Port\CustomerRepository;9use App\Ordering\Domain\Port\EventPublisher;10use App\Ordering\Domain\Port\OrderRepository;11use App\Ordering\Domain\ValueObject\CustomerId;12use App\Ordering\Domain\ValueObject\OrderId;13use App\Ordering\Domain\ValueObject\ProductId;14use App\SharedKernel\Domain\Currency;15use App\SharedKernel\Domain\Money;16 17final class PlaceOrderUseCase18{19 public function __construct(20 private readonly OrderRepository $orders,21 private readonly CustomerRepository $customers,22 private readonly EventPublisher $events,23 ) {24 }25 26 public function execute(PlaceOrderRequest $request): PlaceOrderResponse27 {28 // Neexistující zákazník skončí CustomerNotFoundException z get().29 $customer = $this->customers->get(CustomerId::fromString($request->customerId));30 31 // Kanonický Order::place() bere jen identitu a vlastníka; položky32 // se přidávají metodou, která u každé kontroluje invariant.33 $order = Order::place(34 OrderId::generate(),35 $customer->id, // reference na jiný agregát vede přes ID36 );37 38 foreach ($request->items as $item) {39 $order->addItem(40 ProductId::fromString($item['productId']),41 $item['quantity'],42 new Money($item['unitPriceInCents'], Currency::CZK),43 );44 }45 46 $this->orders->save($order);47 48 // Synchronní publikace stačí pro vývoj. Produkčně sem patří49 // Outbox, jinak se událost ztratí při pádu mezi zápisem a publikací.50 foreach ($order->releaseEvents() as $event) {51 $this->events->publish($event);52 }53 54 return new PlaceOrderResponse(55 orderId: $order->id->value,56 status: $order->status->value,57 totalAmount: $order->totalAmount()->amountInCents,58 );59 }60}
PlaceOrderUseCase je jediný vstupní bod této aplikační schopnosti. HTTP Controller, CLI Command, Messenger Handler, GraphQL Resolver i test používají stejný kontrakt: PlaceOrderRequest dovnitř, PlaceOrderResponse ven.
Adaptér: Symfony HTTP Controller jako Interface Adapter
Kontroler níže je varianta toho z hexagonální sekce, ne druhý soubor. Má stejný namespace, stejné jméno třídy i stejnou routu /api/orders, takže v projektu může existovat jen jeden z nich.
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Http;6 7use App\Ordering\UseCase\PlaceOrder\PlaceOrderRequest;8use App\Ordering\UseCase\PlaceOrder\PlaceOrderUseCase;9use Symfony\Component\HttpFoundation\JsonResponse;10use Symfony\Component\HttpFoundation\Request;11use Symfony\Component\Routing\Attribute\Route;12 13final class PlaceOrderController14{15 public function __construct(16 private readonly PlaceOrderUseCase $useCase,17 ) {18 }19 20 #[Route('/api/orders', methods: ['POST'])]21 public function __invoke(Request $request): JsonResponse22 {23 $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);24 25 $useCaseRequest = new PlaceOrderRequest(26 customerId: $payload['customerId'],27 items: $payload['items'],28 shippingAddress: $payload['shippingAddress'],29 );30 31 $response = $this->useCase->execute($useCaseRequest);32 33 return new JsonResponse([34 'orderId' => $response->orderId,35 'status' => $response->status,36 'totalAmount' => $response->totalAmount,37 ], 201);38 }39}
Controller dělá přesně tři věci: dekóduje HTTP vstup do PlaceOrderRequest, zavolá use case a zformátuje výstup zpět do JSON. Žádná doménová logika, žádné rozhodování. Stejný use case obslouží CLI command na pár řádcích, Messenger handler, nebo ho přímo zavolá PHPUnit test bez frameworku.
Kdy se Clean hodí
- Aplikace s explicitním seznamem use casů – kde má každá schopnost svoje jméno a kontrakt (např. ERP systémy, finanční aplikace).
- Více vstupních kanálů. HTTP API, CLI, Messenger i GraphQL sdílejí stejné use casy.
- Tým s vyšší zkušeností – kde dodatečné vrstvení a DTO ping-pong nezpomalí vývoj.
- Aplikace, kde je důležitý audit „co aplikace umí“ – Use Case třídy jsou tím seznamem.
Kdy Clean nedává smysl
U aplikace s třiceti endpointy se DTO ping-pong (Request → Domain → Response) nezaplatí. Clean navíc stojí na inverzi závislostí ještě víc než Hexagonal, takže tým bez praxe s Dependency Injection se v něm ztratí. Nad tenkou doménou pak zůstane z use casů jen rituál.
09.06 Vertical Slice Architecture (a horizontální vs. vertikální dělení)#
Vrstvové architektury mají skrytou cenu: běžný use case se rozprostře do 5–7 souborů (Controller, Service, Domain Service, Repository interface, Repository impl, DTO, Mapper) a změna jediné funkce sahá do každého z nich. Na to reaguje Vertical Slice Architecture, kterou Jimmy Bogard popsal v roce 2018 [8].
Vertical Slice Architecture organizuje kód podle feature, ne podle vrstvy. Každá feature dostane svůj adresář, ve kterém žije všechno potřebné: Command/Query, Handler, Validátor, read model, Controller. Slice je vertikální „sloupec“ přes všechny technické vrstvy aplikace.
Horizontální dělení – tradiční vrstvený přístup
Tradiční vrstvené DDD člení projekt podle technických vrstev. Každá vrstva má svůj adresář a soubory podobného typu leží pohromadě. Typický src/:
1src/2├── Presentation/ # Prezentační vrstva3│ └── Controller/UserController.php4├── Application/ # Aplikační vrstva5│ ├── Service/UserService.php6│ └── DTO/UserDTO.php7├── Domain/ # Doménová vrstva8│ ├── Model/User.php9│ ├── Repository/UserRepository.php10│ └── Service/DomainUserService.php11└── Infrastructure/ # Infrastrukturní vrstva12 ├── Repository/DoctrineUserRepository.php13 └── Persistence/Doctrine/Mapping/User.orm.xml
Vrstvy leží horizontálně nad sebou; každá poskytuje služby té nad sebou. Doménové stavební kameny (entity, hodnotové objekty, agregáty, doménové služby) jsou stejné jako u jakéhokoli jiného architektonického stylu.
Vertikální dělení – Vertical Slice
Vertikální slice obrací členění. Jednotkou není vrstva, ale feature. Každá funkce (registrace uživatele, vytvoření objednávky, generování faktury) má svůj adresář se vším, co její implementace potřebuje. Sdílený doménový model zůstává v {BC}/Domain/, per feature se dělí aplikační a prezentační logika a část infrastruktury.
1src/2├── UserManagement/ # Bounded Context3│ ├── Domain/ # Sdílený doménový model BC4│ │ ├── Model/User.php5│ │ ├── ValueObject/{UserId, Email}.php6│ │ ├── Event/UserRegistered.php7│ │ └── Repository/UserRepository.php8│ ├── Infrastructure/ # Sdílená infrastruktura BC9│ │ └── Repository/DoctrineUserRepository.php10│ ├── Registration/ # Feature: Registrace11│ │ ├── Command/{RegisterUser, RegisterUserHandler}.php12│ │ └── Controller/RegistrationController.php13│ └── Profile/ # Feature: Profil14│ ├── Query/{GetUserProfile, GetUserProfileHandler}.php15│ ├── Controller/ProfileController.php16│ └── ViewModel/UserProfileViewModel.php17└── SharedKernel/Domain/Exception/DomainException.php
Vazby mezi funkcemi klesají a soudržnost uvnitř každé z nich roste [8]. Bounded Contexts i sdílený doménový model přitom zůstávají.
Bogard jde ve svém článku dál, než tato struktura ukazuje. Vadí mu povinný řetěz „controller musí volat službu, která musí použít repozitář“, a tvrdí, že uvnitř slice většina abstrakcí odpadá. Vzor doménové logiky se podle něj volí per slice: triviální slice může být Transaction Script, složitý bohatý model [8].
Konvence této knihy je vědomě měkčí. Doménový model zůstává sdílený uvnitř Bounded Contextu a slice krájí jen aplikační, prezentační a část infrastrukturní vrstvy. Důvod jsou invarianty. Agregát, který si každý slice modeluje po svém, přestane být jediným místem, kde doménová pravidla platí.
Co Vertical Slice mění
- Adresářová struktura – místo
Controller/, Service/, Domain/, Infrastructure/máteOrdering/PlaceOrder/, Ordering/CancelOrder/, Ordering/GetOrderHistory/. - Závislosti mezi feature klesají na minimum. Každá feature je téměř samostatná; sdílí se jen agregáty, hodnotové objekty a sběrnice (event bus, command bus).
- Diff jedné feature sedí většinou v jednom adresáři, takže recenzent vidí při code review celý use case na jednom místě. Mimo slice sahá, jen když mění sdílený doménový model.
- Akceptační test pokryje celý slice najednou (HTTP request → response) bez mockování jednotlivých vrstev.
Srovnání horizontálního a vertikálního dělení
| Aspekt | Horizontální (vrstvený) | Vertikální slice |
|---|---|---|
| Organizace kódu | Podle technických vrstev | Podle funkcí (features) |
| Vazby | Silné mezi vrstvami | Silné uvnitř funkce, slabé mezi funkcemi |
| Změna jednoho use casu | Úpravy v 5–7 souborech napříč vrstvami | Úpravy v adresáři slice, při změně pravidla i ve sdíleném Domain/ |
| Testovatelnost | Vyžaduje více mocků (vrstvy mezi sebou) | Méně mocků, závislosti jsou lokální |
| Škálovatelnost na microservices | Vyžaduje přeorganizování všech vrstev | Feature lze přesunout jako celek |
| Pochopení na začátku | Jednodušší (tradičnější) | Vyžaduje pochopení slice jako jednotky |
| Vhodnost pro CQRS | CQRS vyžaduje dodatečnou práci | Přirozeně podporuje CQRS [9] |
Kdy zvolit který přístup
Horizontální (vrstvený) přístup se vyplatí týmu, který má dlouhou zkušenost s vrstvenou architekturou a CQRS neplánuje. Sedí aplikaci s 10–30 endpointy a malou doménovou složitostí. Hodí se i tam, kde doménový model nese invarianty sdílené mnoha funkcemi a musí je vymáhat jednotně. Vyhovuje i týmu, který dá přednost explicitnímu oddělení technických vrstev před organizací podle funkcí.
Vertikální slice se vyplatí, když:
- Aplikace má 50+ funkcí s nezávislými use casy.
- Tým plánuje CQRS nebo je už zavedlo (Symfony Messenger jako Command/Query Bus).
- Aplikace se v budoucnu rozdělí do microservices a feature jde vyjmout jako celek.
- Preferujete rychlou iteraci s minimální koordinací mezi vrstvami.
Třetí osa dělení: modul
Vrstva a slice nejsou jediné jednotky členění. Martin Fowler píše, že jakmile některá vrstva naroste, patří nejvyšší úroveň rozdělit na doménově orientované moduly a vrstvit až uvnitř nich [10]. Vrstvení tedy podle něj není správná dekompozice nejvyšší úrovně. Ve stejném textu varuje před organizací týmů podle vrstev.
Modulární monolit tu myšlenku dotahuje na úroveň nasazení. Kamil Grzybek popisuje modul třemi vlastnostmi [11]. Je nezávislý a zaměnitelný. Pokrývá kompletní business funkčnost, tedy moduly jako vertikální slice, ne technické vrstvy. A má definované rozhraní: všechno, co ven sdílí, se stává jeho veřejným API. Simon Brown jde na totéž ze strany balíčkování a v package by component doporučuje spoléhat na překladač, ne na disciplínu týmu.
PHP takovou oporu nedá, hranice modulu proto hlídá statická analýza a code review. Prakticky to znamená jeden adresář na modul, sadu command a query zpráv jako veřejné API a zákaz importů do vnitřku cizího modulu. Detail v kapitole DDD a microservices.
Vertical Slice a Hexagonal jsou ortogonální
Hexagonal/Onion/Clean popisují, jak strukturovat závislosti uvnitř jedné feature. Vertical Slice určuje, jak organizovat feature mezi sebou. Oba přístupy jdou kombinovat. Každý vertikální slice může uvnitř používat Hexagonal port-adapter strukturu, tedy mít vlastní Port, vlastní Adapter i vlastní Domain Service. Nebo nemusí. Některé slice jsou tak triviální, že stačí jediná třída.
Kombinace Hexagonal + Vertical Slice je v Symfony projektech rozšířenou výchozí volbou. Bounded Context má sdílený doménový model (agregáty, value objekty, repository interfaces), aplikační vrstva se dělí do feature slice. Každý slice má svůj Command/Handler (nebo Query/Handler) a svůj HTTP Controller. Kombinace vyvažuje testovatelnost a přehlednost struktury.
09.07 Praktické srovnání – co si vybrat v Symfony 8#
Volba stylu závisí na velikosti aplikace, zkušenosti týmu, plánovaném horizontu produktu a na tom, kolik se vyplatí investovat do izolace. Rozhodovací matice níže shrnuje typická kritéria. Čísla v ní jsou autorské pravidlo palce, ne měřený údaj. Slouží k porovnání stylů mezi sebou, ne jako hranice, o kterou by šlo opřít rozhodnutí samo o sobě.
| Faktor | Layered | Hexagonal | Onion | Clean | Vertical Slice |
|---|---|---|---|---|---|
| Křivka učení | nízká | střední | střední | vysoká | nízká |
| Vhodnost pro juniory | ✓✓✓ | ✓ | ✓ | ✗ | ✓✓ |
| Izolace domény v testech | nízká | vysoká | vysoká | vysoká | střední |
| Vazba na Doctrine | těsná (atributy na entitě) | volná (přes adaptér) | volná | volná | podle slice |
| Více vstupních kanálů | náročné (duplicita) | přirozené | přirozené | přirozené | přirozené |
| Boilerplate (DTO, mappery) | nízký | střední | střední | vysoký | nízký |
| Doporučená velikost projektu | < 50 endpointů | 50–500 | 100+ | enterprise (200+) | 50–500 |
| Soulad s CQRS | vyžaduje úpravy | vysoká (port = command bus) | střední | vysoká (use case = handler) | velmi vysoká |
| Změna jedné feature | 5–7 souborů | 4–6 souborů | 5–7 souborů | 6–8 souborů | 1 adresář (+ Domain/ při změně pravidla) |
Doporučená výchozí volba pro Symfony 8
Pro středně velký projekt vychází jako výchozí volba:
Hexagonal + Vertical Slice s CQRS přes Symfony Messenger.
Konkrétně: Bounded Context má vlastní adresář (src/Ordering/). Uvnitř Domain/ leží agregáty, hodnotové objekty a repository interfaces (porty); Infrastructure/ obsahuje Doctrine adaptéry. Každá feature má svůj slice (PlaceOrder/, CancelOrder/) s Command/Query, Handler (= Clean Use Case) a HTTP Controller. Tato kombinace nabízí:
- Doménové testy bez databáze – agregáty jsou čisté PHP, mockují se jen porty.
- Jednoduché code review – diff jedné feature je většinou v jednom adresáři.
- CLI/HTTP/Messenger paritu – Symfony Messenger Bus dispatchuje stejný Command z libovolného adaptéru.
- Symfony idiomatičnost – Messenger je prvotřídní komponenta, vlastní bus psát nemusíte.
Univerzální pravda to není. Interní administrativní aplikaci s 20 endpointy stačí obyčejná Layered struktura ze Symfony skeletonu, i když má desetiletý horizont. Tým s ní nejspíš iteruje rychleji. U enterprise CRM s 500+ use casy a 15 vývojáři se naopak vyplatí Clean Architecture s explicitním katalogem use casů.
09.08 Hybridní přístup – Hexagonal core, Layered okraje#
Reálný projekt málokdy potřebuje jediný styl pro celou kódovou bázi. Častěji se vyplatí diferencovat investici podle typu subdomény. Core Domain dostane plný Hexagonal s čistými agregáty a porty. Supporting subdoména si vystačí s Layered DDD se zjednodušeným modelováním. Generic subdoména je tenký adaptér na externí SaaS. Odpovídá to Evansovu doporučení z knihy DDD: modelovací úsilí patří tam, kde přináší konkurenční výhodu, ne všude stejně.
Detail klasifikace subdomén (Core / Supporting / Generic) je v kapitole Subdomény: Core, Supporting, Generic.
Příklad: e-shop s diferencovanou architekturou
Klasifikace kontextů sleduje e-shop z kapitoly Subdomény. Konkurenční výhodu v něm nese tvorba cen a slev, proto plný Hexagonal dostává Pricing. Správa objednávek je tam podpůrná subdoména.
1src/2├── Pricing/ # CORE DOMAIN – plný Hexagonal3│ ├── Domain/4│ │ ├── Model/ # Bohatý model cen a slev5│ │ │ ├── Cart.php6│ │ │ └── DiscountPolicy.php7│ │ ├── ValueObject/8│ │ │ └── CartId.php9│ │ ├── Event/10│ │ │ └── CouponApplied.php11│ │ ├── Service/12│ │ │ └── PriceCalculator.php # Domain Service13│ │ └── Port/ # Porty (interfaces)14│ │ ├── CartRepository.php15│ │ └── EventPublisher.php16│ ├── Application/17│ │ └── UseCase/18│ │ ├── ApplyCouponToCart/19│ │ │ ├── ApplyCouponToCart.php # Command20│ │ │ └── ApplyCouponToCartHandler.php21│ │ └── CalculateCartPrice/22│ │ ├── CalculateCartPrice.php # Query23│ │ └── CalculateCartPriceHandler.php24│ └── Infrastructure/25│ ├── Persistence/26│ │ ├── DoctrineCartRepository.php27│ │ └── CartOrmEntity.php # Persistence-friendly mapping28│ └── Http/29│ └── CartPriceController.php30│31├── Ordering/ # SUPPORTING – Layered DDD32│ ├── Controller/ # Symfony skeleton struktura33│ │ └── OrderController.php34│ ├── Service/35│ │ └── OrderService.php36│ ├── Entity/ # Doctrine entity přímo37│ │ └── Order.php38│ └── Repository/39│ └── OrderRepository.php40│41├── Notifications/ # GENERIC – tenký adapter na SaaS42│ ├── Service/43│ │ └── NotificationService.php44│ └── Provider/45│ ├── SendGridAdapter.php # Obal nad externím HTTP API46│ └── TwilioAdapter.php47│48└── SharedKernel/ # Sdílené koncepty mezi BC49 ├── Domain/50 │ ├── Money.php51 │ └── DomainException.php52 └── Bus/53 ├── CommandBus.php # Interface54 └── EventBus.php
Pravidla hybridního přístupu
- Core Domain dostává plný Hexagonal, Vertical Slice a CQRS. Sem patří modelovací úsilí, čas na refaktoring i investice do testů.
- Supporting subdomény mají Layered strukturu – controller, service, entity, repository. Taková struktura se rychle píše a pro tento typ subdomény stačí.
- Generic subdomény jsou tenké adaptéry. Žádné agregáty, žádné domain services – jen obal kolem externí knihovny nebo SaaS API.
- Uvnitř jednoho Bounded Contextu se styly nemíchají. Jeden BC = jeden styl. Hybrid znamená „různé BC mají různé styly“, ne „jeden BC má polovinu Hexagonal a polovinu Layered“.
Cena vs. zisk hybridního přístupu
Cena: tým musí umět víc stylů a vědět, kdy který použít. Hybrid proto potřebuje aspoň jednoho seniora, který architekturu hlídá. Mezi BC jsou nutně rozdílné konvence a čtenáře kódu to může mást.
Zisk: nejvyšší ROI z modelovacího úsilí. V Core Domain (kde se rozhoduje o konkurenční výhodě) máte čistý model a rychlé testy. V Generic části (kde vendor lock-in není problém, protože SaaS si stejně neměníte každý měsíc) neinvestujete do izolace, která by se nevrátila.
09.09 Anti-vzory napříč styly#
Většina problémů nepramení z volby stylu, ale z jeho implementace. Následujících šest anti-vzorů se v Symfony projektech opakuje nejčastěji.
Anti-vzor 1: Hexagonal kult
Tým přečte Cockburnův článek a každý CRUD endpoint dostane port + adapter. GET /api/products/{id} má port FindProductById, adapter FindProductByIdHttpAdapter, repository port ProductRepository, adapter DoctrineProductRepository, mapper ProductMapper a use case FindProductByIdUseCase. Pro nejtriviálnější operaci máte šest souborů místo dvou.
Náprava: Hexagonal aplikujte jen tam, kde je doménová logika. Pro čisté CRUD endpointy (žádné invarianty, žádné stavové přechody, žádné doménové pravidlo) stačí přímý Doctrine dotaz v controlleru. Architektonický styl se používá, když přináší hodnotu; povinnost to není.
Anti-vzor 2: Domain leakage přes Doctrine atributy
Klasický Layered problém přenesený do Hexagonal. Tým má Domain/Port/OrderRepository, ale třída Domain/Model/Order.php má #[ORM\Entity], #[ORM\Column], #[ORM\OneToMany]. Doména dál závisí na knihovně Doctrine a cíl izolace padá.
Náprava (pro Hexagonal/Onion): zaveďte separátní persistenční třídu (OrderOrmEntity) a Mapper, tedy vzor Persisted Object Pattern. Cena je dvojí třída a explicitní mapping, zisk čistá doména. Pokud projekt Hexagonal hranici reálně nepotřebuje, atributy přímo na agregátu jsou pragmatický kompromis (viz rozhodnutí o mappingu).
Anti-vzor 3: Anemic Hexagonal / Anemic Clean
Strukturálně dokonalý Hexagonal nad anémickou doménou plnou getterů, setterů a logiky odsunuté do handlerů. Podrobný popis je v calloutu Anti-vzor: Anemic Hexagonal v sekci 09.03; totéž platí pro Clean.
Náprava: Před zavedením architektonického stylu zkontrolujte, zda váš doménový model má skutečné chování. Pokud ne, vyřešte nejprve modelování. Hexagonal nad anémickým modelem izolaci nepřinese, jen zkomplikuje code review.
Anti-vzor 4: Port na technologii
Port se jmenuje RedisOrderCache, SendGridMailer nebo RabbitMqPublisher. Rozhraní kopíruje jméno knihovny, kterou obaluje, a často i tvar jejího API. Cockburn to označuje za hlavní chybu, kterou u svého vzoru v praxi vidí [4]. Jedna technologie na port ruší celý smysl portu, tedy záměnu technologie beze změny jádra. Opačný extrém popisuje callout o Anemic Hexagonal. Portem je tam jen repozitář a ostatní výstupní závislosti domény žádné rozhraní nemají.
Náprava: Port pojmenujte podle konverzace, kterou doména vede, ne podle technologie na druhém konci: OrderCache, Mailer, EventPublisher. Pod každým z nich může viset víc adaptérů včetně in-memory varianty pro testy. Rozhraní dostanou i zbylé výstupní závislosti (PaymentGateway, EmailSender), ne jen repozitář.
Anti-vzor 5: Předčasná inverze závislostí
Tým si přečte „Dependency Inversion Principle“ a začne otáčet závislosti i tam, kde to nemá smysl. Vznikají rozhraní s jedinou implementací, která se nikdy nemockují. Čtení kódu se zhoršuje („musím skočit do interface a pak najít implementaci“), aniž by to přineslo testovatelnost.
Náprava: Inverze závislostí má cenu jen tam, kde existuje aspoň jeden ze dvou důvodů: (1) chcete v testech mockovat tu závislost, (2) plánujete víc implementací (Doctrine + InMemory, SendGrid + Twilio). Když neplatí ani jeden, je rozhraní zbytečné.
Anti-vzor 6: Architecture astronaut (astronaut architektury)
Tým investuje měsíce do „dokonalé architektury“, do osmivrstvové Clean s explicitními BCE rolemi, formálními use case katalogy, presenter třídami a gateway hierarchiemi. Koncový uživatel pořád čeká na první funkci. Architektura se stala cílem sama o sobě.
Náprava: Architektura má vracet investici. Každá vrstva, vzor i abstrakce musí mít pro projekt konkrétní zisk. Pokud nedokážete za pět minut vysvětlit, jaký reálný problém daná abstrakce řeší, pravděpodobně neřeší žádný a měla by se odstranit.
Detail dalších anti-vzorů (Anemic Domain Model, God Service, Smart UI, Leaky Abstractions) je v samostatné kapitole Anti-vzory a typické chyby.
09.10 Symfony 8 specifika všech stylů#
Ať zvolíte kterýkoli styl, v Symfony 8 pracujete se stejnými nástroji: Service Container, Messenger, Doctrine, Form, Security. Liší se jen konvence jejich použití.
Bundle vs. namespace organizace
Symfony dřív stavělo na bundlech jako jednotce modularity. Oficiální Best Practices dnes radí opak: bundly na vlastní aplikační logiku nezakládat a strukturovat src/ přímo přes namespacy pod App\. Bundle se hodí jen pro znovupoužitelné knihovny publikované jako Composer packages, ne pro aplikační moduly. Pravidlo platí pro všechny architektonické styly: aplikačnímu kódu bundly nedají nic, co by nezvládly namespacy a autowiring.
Konfigurace per-context v Symfony 8
Pokud máte víc Bounded Contexts (Ordering, Billing, Customer, …), můžete pro každý mít vlastní YAML konfiguraci v config/packages/contexts/. To je užitečné zejména v hybridním přístupu, kde různé BC mají různé úrovně izolace. Příklad: jen Core Domain BC má explicitní binding portů, ostatní BC spoléhají na auto-wiring.
1# config/services.yaml2imports:3 - { resource: 'packages/contexts/ordering.yaml' }4 - { resource: 'packages/contexts/billing.yaml' }5 - { resource: 'packages/contexts/customer.yaml' }6 7services:8 _defaults:9 autowire: true10 autoconfigure: true11 12 App\:13 resource: '../src/'14 exclude:15 - '../src/Kernel.php'16 - '../src/**/Domain/Model/' # Doménové modely nejsou služby17 - '../src/**/Domain/Event/' # Události také ne18 - '../src/**/Application/Dto/' # DTO také ne
Doménové modely se z auto-registrace v Service Containeru vylučují. Agregáty, hodnotové objekty a doménové události nejsou služby, ale data. Kdyby v kontejneru zůstaly, nic by se nerozbilo: nepoužité privátní služby Symfony při sestavení odstraní a do entity nikdo nic neinjektuje. Vyloučení je hygiena. Kontejner je menší, konfigurace čitelnější a agregát si nikdo omylem nevyautowiruje jako závislost.
Vyloučení má jeden vedlejší efekt. Vyloučený adresář vypadne i z automatického aliasování rozhraní popsaného výše. Port, který v takovém adresáři leží, pak potřebuje alias zapsaný ručně.
Symfony Messenger jako Command Bus
Pro všechny styly kromě Layered je Symfony Messenger vhodný nástroj na Command Bus a Event Bus. V Layered se aplikační služba typicky volá přímo z controlleru, takže sběrnici nepotřebuje. V Hexagonal a Clean Architecture se use case zpravidla dispatchuje jako Command a handler je buď driving adaptér, nebo přímo use case. Konfigurace jednotlivých sběrnic:
1# config/packages/messenger.yaml2framework:3 messenger:4 # Při více sběrnicích je default_bus povinný, jinak kontejner5 # odmítne konfiguraci celou.6 default_bus: command.bus7 8 buses:9 command.bus:10 middleware:11 # Vyžaduje symfony/validator, jinak kontejner spadne12 # na „The Validation middleware is only available…“.13 - validation14 - doctrine_transaction15 query.bus:16 middleware:17 - validation18 event.bus:19 default_middleware:20 enabled: true21 allow_no_handlers: true # Eventy mohou mít 0+ konzumentů22 23 transports:24 async_events: '%env(MESSENGER_TRANSPORT_DSN)%'25 26 routing:27 # Doménové události zůstávají synchronní; ven jde integrační tvar28 # (viz kapitola Outbox Pattern).29 App\Ordering\Application\IntegrationEvent\OrderPlacedIntegrationEvent: async_events
query.bus si kromě validace vystačí s výchozím chováním. Díky allow_no_handlers: false se dotaz bez handleru prozradí hned při dispatchi. Druhý přepínač allow_no_senders musí zůstat na výchozí hodnotě true. Při false vyhodí NoSenderForMessageException každá zpráva bez transportu, tedy každý synchronní dotaz.
Tři sběrnice (command, query, event) jsou v DDD aplikaci s CQRS obvyklé. Detail konfigurace Messengeru pro DDD je v kapitole CQRS a v kapitole Implementace v Symfony 8.
09.11 Shrnutí#
- Architektonický styl ≠ DDD. DDD je modelovací technika; architektonický styl je rozhodnutí o uspořádání kódu. Lze je kombinovat libovolně – DDD funguje v Layered, Hexagonal, Onion, Clean i Vertical Slice.
- Čtyři vrstvové styly stojí na téže myšlence, izolaci domény, ale liší se slovníkem i granularitou. Hexagonal mluví o portech a adaptérech, Onion o koncentrických vrstvách, Clean o use casech jako prvotřídním konceptu. V praxi se často kombinují do jednoho hybridního stylu.
- Vertical Slice je ortogonální k vrstvovým stylům. Popisuje, jak organizovat feature mezi sebou, ne jak strukturovat závislosti uvnitř feature. Hexagonal + Vertical Slice + CQRS je rozšířená výchozí volba v Symfony 8 projektech.
- Hybridní přístup (různé styly pro různé subdomény) je pragmatický a DDD literatura ho doporučuje. Modelovací úsilí patří do Core Domain; Supporting a Generic si vystačí s jednodušší strukturou. Každá vrstva architektury musí projektu vrátit, co stojí.
Časté otázky
Hexagonal vs. Onion – jaký je praktický rozdíl?
V běžné Symfony implementaci jsou téměř nerozlišitelné: oba mají interfaces v doméně, implementace v infrastruktuře, závislosti směřují dovnitř. Tři jemné odlišnosti: Hexagonal explicitně dělí driving (inbound) a driven (outbound) porty; Onion staví Domain Services a Application Services jako dvě samostatné vrstvy; Onion je „statický“ model závislostí, Hexagonal „dynamický“ model toku dat. Pokud váš projekt používá Hexagonal slovník (port, adapter), ale uvnitř má Domain Service i Application Service, děláte hybrid. To je v pořádku. Detail v sekci o Onion Architecture.
Můžu použít Hexagonal bez DDD?
Ano, technicky to funguje. Hexagonal řeší jak strukturovat závislosti, zatímco DDD popisuje jak modelovat doménu. Jde o ortogonální dimenze. Můžete mít Hexagonal nad anémickým CRUD modelem a žádné DDD principy nepoužívat. Praktický zisk je ale omezený. Bez bohatého doménového modelu uvnitř je Hexagonal jen vrstvení rituálu, které zhoršuje code review a zpomaluje vývoj. Anti-vzor „Anemic Hexagonal“ je v reálných projektech běžný. Detail v anti-vzorech.
Jak migrovat z Layered na Hexagonal v existujícím Symfony projektu?
Strangler Fig Pattern: místo velkého přepisu postupujte po částech. Vyberte jeden Bounded Context (ideálně Core Domain) a v něm jednu feature. Pro tu feature zaveďte port (interface v Domain/Port/) a adapter (implementace v Infrastructure/), původní Doctrine entitu rozdělte na čistou doménovou třídu + persistenční OrmEntity + Mapper. Otestujte. Iterujte na další feature. Když je hotová celá Core Domain, druhý BC může zůstat v Layered (hybridní přístup). Migrace všeho najednou nese vysoké riziko regresí. Detail Strangler Fig Pattern v kapitole Migrace z CRUD na DDD.
Co je „Port“ přesně a jak se liší od běžného PHP interface?
Port je interface s explicitní architektonickou rolí: definuje hranici mezi doménou a vnějším světem. Technicky je to běžný PHP interface, ale konvenčně žije v adresáři Domain/Port/, nemá framework závislosti a má smysluplné jméno z doménového jazyka (OrderRepository, ne OrderRepositoryInterface). Cockburn rozlišuje driving porty (vnější svět volá doménu) a driven porty (doména volá vnější svět). V Symfony se port na implementaci napojuje aliasem, ten ale u rozhraní s jedinou objevenou implementací vzniká automaticky. Ručně ho zapíšete (v services.yaml nebo atributem #[AsAlias]) až tehdy, když implementací je víc nebo když je adresář vyloučený z resource. Detail v sekci o Hexagonal.
Vyplatí se Clean Architecture v malé Symfony aplikaci?
Spíše ne. Clean Architecture vyžaduje DTO ping-pong: Request DTO → Use Case → Response DTO → Adapter překládá zpět. Je to znatelná režie, pro každou funkci tři až čtyři další třídy. V malé aplikaci s 20–30 endpointy je to čistá ztráta. Vyplatí se až v aplikacích s explicitním seznamem use casů (200+ schopností). Tam je podstatné, aby šlo doložit, „co aplikace umí“, a vstupních kanálů bývá víc (HTTP, CLI, Messenger, GraphQL). Pro malou Symfony aplikaci stačí Layered nebo Hexagonal s méně rituálem. Detail v rozhodovací matici.
Jak Vertical Slice zapadá mezi Hexagonal/Onion/Clean?
Vertical Slice je ortogonální k vrstvovým stylům. Hexagonal/Onion/Clean popisují jak strukturovat závislosti uvnitř jedné feature; Vertical Slice popisuje jak organizovat feature mezi sebou. Tyto dvě dimenze lze kombinovat. Každý vertikální slice může uvnitř používat Hexagonal port-adapter strukturu, nebo nemusí. V Symfony projektech je rozšířená kombinace Hexagonal + Vertical Slice + CQRS přes Symfony Messenger. Bounded Context má sdílený doménový model, ale aplikační vrstva je rozdělená do feature slice. Detail v sekci 09.06 výše.
09.12 Další četba a citace#
- Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). Originální definice DDD a doporučení vrstvené architektury.
- Martin Fowler – Patterns of Enterprise Application Architecture (2002). Service Layer, Domain Model, Data Mapper a další základní vzory.
- Alistair Cockburn – Hexagonal Architecture (Ports and Adapters) (2005). Originální článek o Hexagonal architektuře.
- Juan Manuel Garrido de Paz – Interview with Alistair Cockburn (2020). Cockburn upřesňuje granularitu portů a nejčastější chyby ve výkladu vzoru.
- Jeffrey Palermo – The Onion Architecture: Part 1 (2008). První ze čtyř blogových postů zavádějících Onion model.
- Jeffrey Palermo – Onion Architecture: Part 4 – After Four Years (2013). Čtyři teze vzoru a odstup od DDD, CQRS i IoC kontejneru.
- Robert C. Martin – The Clean Architecture (2012). Původní článek o Clean Architecture, který zobecňuje Hexagonal a Onion.
- Jimmy Bogard – Vertical Slice Architecture (2018). Feature-first přístup k organizaci kódu.
- Microsoft – Apply simplified CQRS and DDD patterns in a microservice (.NET microservices architecture guide). Jak feature-orientovaná struktura přirozeně podporuje CQRS.
- Martin Fowler – PresentationDomainDataLayering (2015). Argument pro moduly na nejvyšší úrovni a vrstvy až uvnitř nich.
- Kamil Grzybek – Modular Monolith: A Primer (2019). Definice modulu a jeho veřejného API v modulárním monolitu.
- Vaughn Vernon – Implementing Domain-Driven Design (2013). Praktický průvodce DDD s ukázkami architektonických stylů.
- Herberto Graça – DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together (2017). Hybridní pohled na kombinaci stylů.
- Martin Fowler – Anemic Domain Model (2003). Klasický článek popisující anti-vzor anémického modelu.
- Cockburn, A. & Garrido de Paz, J. M. (2024). Hexagonal Architecture Explained: How the Ports & Adapters Architecture Simplifies Your Life, and How to Implement It. Humans & Technology Press. ISBN 978-1-7375197-8-2. Knižní zpracování vzoru od jeho autora.
- Martin, R. C. (2017). Clean Architecture: A Craftsman's Guide to Software Structure and Design. Prentice Hall. Knižní rozvedení článku z roku 2012.