Architektonické styly – Hexagonal, Onion, Clean
DDD vám říká co modelovat. Architektonický styl říká kam to modelované strčit. Čtyři školy – klasická vrstvená, Hexagonální (Cockburn), Onion (Palermo), Clean (Martin) – a Vertical Slice jako pátá. Kapitola srovnává jejich odlišnosti, podobnosti a co vybrat v Symfony 8 projektu.
Obsah kapitoly
Když tým poprvé pronese „přejdeme na DDD“, pod tím slovem se schovávají dvě věci najednou: budeme líp modelovat doménu a zároveň přerovnáme adresářovou strukturu. Tato dvě rozhodnutí jsou ve skutečnosti ortogonální. Domain-Driven Design je modelovací technika; architektonický styl je rozhodnutí o uspořádání kódu a směru závislostí. DDD lze provozovat ve vrstvené architektuře, v Hexagonální, v Onion, v Clean i ve Vertical Slice. A naopak. Hexagonální architektura postavená nad anémickým CRUD modelem nemá s DDD nic společného.
Následující sekce srovnávají čtyři vrstvové styly (Layered, Hexagonal, Onion, Clean) s pátým, feature-orientovaným Vertical Slice, a ukazují, jak konkrétně každý vypadá v Symfony 8 projektu. Žádný styl tu neprohlašujeme za vítěze. Každý má svůj kontext, kde dává smysl. Smyslem srovnání je dát vám rozhodovací kritéria a varovat před nejčastějšími anti-vzory, které z dobré teorie udělají špatný kód.
09.01 Proč architektonický styl není totéž co DDD#
Nejčastější zdroj zmatku v DDD literatuře je 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, aplikuje validaci a zapíše zpět? Druhé pak otevírá otázku uspořádání kódu: členit projekt podle technických vrstev, přes porty a adaptéry, do koncentrických prstenců, nebo podle feature?
Tato dvě rozhodnutí lze kombinovat libovolně. Najdete 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). Najdete 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] popisuje doporučenou „layered architecture“ jen v jedné krátké kapitole. Explicitně říká, že DDD je primárně o modelování. Strukturální vrstvy jsou způsob, jak ten model chránit před technickými detaily, ne cíl sám o sobě. Pozdější autoři (Vernon, Khononov, Millett & Tune) ukazují DDD ve stylech vrstvových, hexagonálních i feature-first. Všechny fungují, pokud doménový model uvnitř má skutečný obsah.
Pokud je vaše doména triviální (CRUD nad několika tabulkami, žádné invarianty, žádné stavové přechody), žádný architektonický styl vám nepomůže, protože není co chránit. Pokud je vaše doména bohatá, ale neoddělíte ji od framework-specifických věcí (Doctrine anotace, Symfony Request/Response objekty, externí HTTP klienti), získáte na první pohled „čistý“ kód. Ten se ale nedá testovat bez celé infrastruktury.
Následuje katalog stylů v pořadí od nejjednoduššího k nejkomplexnějšímu. U každého: co styl říká, jak vypadá v Symfony, kdy se hodí, kdy ne, a jaký je nejčastější anti-vzor.
09.02 Layered (klasická vrstvená)#
Vrstvená architektura je výchozí způsob, jak v podnikové aplikaci uspořádat kód. Martin Fowler v Patterns of Enterprise Application Architecture (2002) [2] pracuje se třemi principiálními vrstvami: Presentation, Domain (doménová logika) a Data Source. Eric Evans v Domain-Driven Design (2003) schéma upravil na čtyři vrstvy: User Interface (Presentation), Application, Domain a Infrastructure. Přidal k tomu pravidlo, ž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é framework závislosti. 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 anotacemi → leak)9│ ├── Order.php10│ ├── OrderLine.php11│ └── Customer.php12├── Repository/ # Infrastructure13│ ├── OrderRepository.php14│ └── CustomerRepository.php15└── Form/ # Presentation (vstupy)16 └── OrderType.php
Tato struktura je výchozí Symfony skeleton. Commandy make:entity, make:controller a make:repository ji generují automaticky. Pro junior tým je dobře čitelná. Každý soubor má své místo a přidání nového use casu je triviální (controller + service + entity + 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, a to je kvalitní DDD modelování. Ale třída zároveň závisí na Doctrine ORM přes atributy #[ORM\Entity], #[ORM\Column]. Doménové pravidlo „nelze potvrdit prázdnou objednávku“ žije v doménovém kódu, jenže ten kód zároveň ví, že se ukládá přes Doctrine. Z pohledu Hexagonal/Onion architektury je to domain leak. Doménová vrstva potřebuje knihovnu z Infrastructure, aby se vůbec dala zkompilovat. Pragmatický pohled (Layered, který tu rozebíráme) tento kompromis přijímá a je to i výchozí volba knihy, jak rozebírá Implementace v Symfony. 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í. Není co amortizovat. A má-li produkt krátký horizont (MVP, prototyp, interní nástroj), je Doctrine vendor lock-in teoretické riziko, protože migrace nikdy nepřijde. Týmu, který Symfony ovládá plynně, by dodatečná vrstva jen brzdila práci, aniž by řešila problém, který skutečně má.
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 anotace z entit pak znamená rozsáhlou migraci.
- Doménová pravidla potřebují žít v jednom místě. Ve vrstveném modelu se rozptýlí mezi controllery, service vrstvou a entity třídami.
- 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
Pro úplnost ukázka, jak vypadá orchestrační kód v Layered architektuře. Controller volá Application Service, ten načte Doctrine entitu z repository, zavolá doménovou metodu a flushne změny. Žádné porty, žádné DTO mappery, žádné 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}
Tento kód je čitelný, krátký a v Symfony idiomu standardní. Cena je v testech. Pro test OrderController::confirm() potřebujete buď WebTestCase s celým bootem aplikace, nebo komplikované nastavení s mockováním OrderRepository i OrderService. V Hexagonal struktuře byste místo toho jen zavolali use case bez controlleru.
09.03 Hexagonal Architecture (Ports & Adapters, Cockburn 2005)#
V klasické tří-vrstvé struktuře (UI / Logic / Database) testy aplikační logiky nutně procházely buď přes UI, nebo přes databázi. To Alistairu Cockburnovi vadilo a v roce 2005 článkem Hexagonal Architecture (Ports and Adapters) [3] navrhl jiné uspořádání. Jeho teze: aplikační jádro (doména) 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.
Vzor má od roku 2024 knižní zpracování. Cockburn ho vydal spolu s Juanem Manuelem Garridem de Paz pod názvem Hexagonal Architecture Explained. 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:
PlaceOrder,CancelOrder,GetOrderHistory. - 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. Není to ale Cockburnova definice a ten rozdíl stojí za pojmenování. Co kapitola nazývá portem, je u něj spíš jeden adaptér uvnitř širší konverzace.
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 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│ │ │ └── OrderId.php # Value Object8│ │ ├── Event/9│ │ │ └── OrderConfirmed.php10│ │ └── Port/ # Outbound porty (interfaces)11│ │ ├── OrderRepository.php12│ │ └── EventPublisher.php13│ ├── Application/ # Inbound porty + use casy14│ │ ├── UseCase/15│ │ │ ├── PlaceOrder.php # Inbound port (interface)16│ │ │ └── PlaceOrderHandler.php # Implementace use casu17│ │ └── Dto/18│ │ └── PlaceOrderInput.php19│ └── Infrastructure/ # Adaptéry (driving + driven)20│ ├── Http/ # Driving adapter21│ │ └── PlaceOrderController.php22│ ├── Cli/ # Driving adapter23│ │ └── PlaceOrderCommand.php24│ └── Persistence/ # Driven adapter25│ ├── DoctrineOrderRepository.php26│ └── OrderOrmEntity.php # Mapper na databázi27└── Shared/28 └── Domain/29 └── DomainException.php
Z této struktury plyne několik věcí:
- Adresář
Domain/neobsahuje žádný import z Doctrine, Symfony, Twig ani jiné knihovny. Pouze čisté PHP a vlastní typy. - 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 (
PlaceOrder). 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\Model\Order;8use App\Ordering\Domain\Model\OrderId;9 10interface OrderRepository11{12 public function get(OrderId $id): ?Order;13 14 public function save(Order $order): void;15 16 /**17 * @return list<Order>18 */19 public function findByCustomer(string $customerId): array;20}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Persistence;6 7use App\Ordering\Domain\Model\Order;8use App\Ordering\Domain\Model\OrderId;9use App\Ordering\Domain\Port\OrderRepository;10use Doctrine\ORM\EntityManagerInterface;11 12final class DoctrineOrderRepository implements OrderRepository13{14 public function __construct(15 private readonly EntityManagerInterface $em,16 private readonly OrderMapper $mapper,17 ) {18 }19 20 public function get(OrderId $id): ?Order21 {22 $orm = $this->em->find(OrderOrmEntity::class, $id->value);23 24 return $orm === null ? null : $this->mapper->toDomain($orm);25 }26 27 public function save(Order $order): void28 {29 // Ukázka pokrývá insert. Reálná implementace při update nejprve30 // najde existující OrderOrmEntity přes find() a přepíše její pole –31 // persist() nové instance by skončil kolizí primárního klíče.32 //33 // Flush je tu proto, že kontroler volá port přímo. Jakmile příkazy34 // půjdou přes command bus s doctrine_transaction middleware,35 // transakci vlastní ten a flush odsud zmizí. Bez jednoho nebo36 // druhého API vrátí 201 a v databázi nezůstane nic.37 $orm = $this->mapper->toOrm($order);38 $this->em->persist($orm);39 $this->em->flush();40 }41 42 /**43 * @return list<Order>44 */45 public function findByCustomer(string $customerId): array46 {47 $rows = $this->em->getRepository(OrderOrmEntity::class)48 ->findBy(['customerId' => $customerId]);49 50 return array_map(fn (OrderOrmEntity $r) => $this->mapper->toDomain($r), $rows);51 }52}
Doménová třída Order je čisté PHP bez jediné Doctrine anotace. OrderOrmEntity je samostatná persistenční třída s Doctrine mapováním a OrderMapper překlápí mezi nimi. Cena: dvojí třída a explicitní mapování. Zisk: doménový model je testovatelný v paměti bez databáze, lze ho serializovat do JSON Event Storu beze změny tvaru, a změna persistence vrstvy nezasáhne doménu.
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 lze také definovat explicitně jako interface, který má jediný handler jako implementaci.
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 PlaceOrder16{17 public function handle(PlaceOrderInput $input): PlaceOrderOutput;18}
Oba DTO jsou obyčejné neměnné struktury bez chování. Port jimi vymezuje, co dovnitř vstupuje a co ven vystupuje:
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 adapter pak nezná konkrétní třídu handleru, jen rozhraní portu. Na implementaci ho naváže alias v konfiguraci kontejneru (viz sekci o Service Containeru níže). Tím získáte schopnost handler v testech 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\PlaceOrder;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 PlaceOrder $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 mezi načtenými službami najde právě jednu 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.
Explicitní alias potřebujete ve dvou situacích. Buď je implementací víc než jedna, nebo adresář s rozhraním či s implementací nespadá do resource, typicky když ho vyloučíte (viz Konfigurace per-context). Psát alias i tam, kde by vznikl sám, není chyba. 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 PlaceOrder18{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}
Nabízel by se i atribut #[Autowire(service: DoctrineOrderRepository::class)] přímo v konstruktoru handleru. To 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ší, ale ne jediný outbound port. Druhým častým kandidátem je publikace doménových událostí. Doména volá EventPublisher::publish($event) a nestará se, kdo eventy konzumuje. Možnosti: Symfony Messenger, RabbitMQ, in-memory dispatcher pro testy, nebo nikdo (event bus může být no-op v jednoduchých scénářích).
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 si napíšete InMemoryEventPublisher, který eventy pouze sbírá do pole a umožní v testu zkontrolovat, jaké eventy doména publikovala. Žádné Symfony Messenger, žádný RabbitMQ, žádná infrastruktura. Test běží v 5 milisekundách místo 500.
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
Hexagonal nedává smysl u CRUDu nad několika tabulkami. Port, adaptér a mapper pro každou entitu je 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 roce 2008 v blogové sérii [5]. První tři díly vyšly v roce 2008, čtvrtý (Part 4 – After Four Years) následoval v roce 2013. Je to vylepšení vrstvené architektury, které explicitně staví doménový model do středu a zavádí Dependency Rule: závislosti smí směřovat pouze dovnitř, nikdy ven. Geometrickou 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. Žádné závislosti. Žádný framework. Žádná persistence.
- Domain Services – bezstavové třídy s doménovou logikou, která nepatří do žádné konkrétní entity. Závisí jen na Domain Model.
- 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 vertikálně poskládané (nahoře UI, dole DB), ale soustředné, s jádrem uprostřed a vnějším světem kolem. To řeší jeden problém klasické vrstvené architektury. Ve vrstvené struktuře může Domain záviset na Infrastructure (čte z databáze), v Onion to dovolené není. Repozitáře deklaruje jádro 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 nezávisí na DDD, na CQRS ani na IoC kontejneru. Výklad přes agregáty a doménové služby, který používá tato kapitola, 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\Exception\CartNotFoundException;8use App\Pricing\Domain\Exception\CustomerNotFoundException;9use App\Pricing\Domain\Port\CartRepository;10use App\Pricing\Domain\Port\CustomerRepository;11use App\Pricing\Domain\Port\DiscountPolicyRepository;12use App\Pricing\Domain\Service\PriceCalculator;13use App\SharedKernel\Domain\Money;14 15/**16 * Application Service – orchestrace use casu „Spočítej cenu košíku“.17 * Vlastní logika je v Domain Service; aplikační vrstva jen řídí transakci18 * a načítá agregáty z repository.19 */20final class CalculateCartPrice21{22 public function __construct(23 private readonly CartRepository $carts,24 private readonly CustomerRepository $customers,25 private readonly DiscountPolicyRepository $policies,26 private readonly PriceCalculator $calculator,27 ) {28 }29 30 public function execute(string $cartId): Money31 {32 $cart = $this->carts->get($cartId)33 ?? throw new CartNotFoundException($cartId);34 35 $customer = $this->customers->get($cart->customerId())36 ?? throw new CustomerNotFoundException($cart->customerId());37 38 $policy = $this->policies->forCustomer($customer);39 40 return $this->calculator->calculate($cart, $customer, $policy);41 }42}
Rozdíl je v přístupu k datům. PriceCalculator repository nezná a bere si již načtené objekty. CalculateCartPrice je zná přes porty a orchestruje načtení i předání dat. Pokud byste obě zodpovědnosti slili do jedné třídy, ztratíte schopnost 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│ │ ├── Port/ # Repository interfaces9│ │ │ ├── CartRepository.php10│ │ │ ├── CustomerRepository.php11│ │ │ └── DiscountPolicyRepository.php12│ │ └── Service/ # 2. prsten – Domain Services13│ │ └── PriceCalculator.php14│ ├── Application/ # 3. prsten – Application Services15│ │ └── Service/16│ │ ├── CalculateCartPrice.php17│ │ └── ApplyCouponToCart.php18│ └── Infrastructure/ # Vnější prsten – UI a infra19│ ├── Persistence/20│ │ └── DoctrineCartRepository.php21│ └── Http/22│ └── CartPriceController.php23└── Shared/24 └── Domain/25 └── 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 Symfony\… ani use Doctrine\…; jediné use v jádře jsou na vlastní třídy z Domain/.
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 Architektonické testy.
- 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 (známý pod přezdívkou „Uncle Bob“) chtěl zobecnit společné rysy Hexagonal, Onion, DCI a BCE (Boundary-Control-Entity od Ivara Jacobsona) do jednoho srozumitelného 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. Každý use case je třída s jednou public 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: zdrojový kód směřuje jen směrem dovnitř. Vnější vrstva smí odkazovat na třídy vnitřní vrstvy, ale nikdy naopak. Pokud vnitřní vrstva potřebuje něco z vnější (např. uložit objednávku), použije Dependency Inversion a definuje rozhraní v sobě, které vnější vrstva 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 nepojmenovávají jednotlivé use casy explicitně. 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. Tím se aplikace stává explicitním seznamem schopností, které poskytuje.
V DDD termínech: Use Case z Clean Architecture ≈ DDD Application Service ≈ CQRS Command Handler. Pokud používáte Symfony Messenger pro Command Bus (viz kapitolu CQRS), váš PlaceOrderHandler plní roli Clean Use Case.
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}> $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\Exception\CustomerNotFoundException;8use App\Ordering\Domain\Model\Order;9use App\Ordering\Domain\Port\CustomerRepository;10use App\Ordering\Domain\Port\EventPublisher;11use App\Ordering\Domain\Port\OrderRepository;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 $customer = $this->customers->get($request->customerId)29 ?? throw new CustomerNotFoundException($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}
Use Case PlaceOrderUseCase je jediný vstupní bod pro tuto aplikační schopnost. Ať už ho zavolá HTTP Controller, CLI Command, Messenger Handler, GraphQL Resolver nebo testovací suite, všichni 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 jednom projektu
mohou existovat jen jeden, nebo druhý.
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, zformátuje výstup zpět do JSON. Žádná doménová logika, žádné rozhodování. Stejný use case lze obsloužit z CLI commandu pár řádky kódu, ze Symfony Messengeru jako CommandHandler, nebo zavolat přímo z PHPUnit testu bez celého 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ě silněji než Hexagonal, takže tým bez praxe s Dependency Injection se v něm ztratí. A nad tenkou doménou zůstane z use casů jen vrstvení rituálu.
09.06 Vertical Slice Architecture (a horizontální vs. vertikální dělení)#
Vrstvové architektury mají skrytou daň: jeden běžný use case se rozprostírá přes 5–7 souborů (Controller, Service, Domain Service, Repository interface, Repository impl, DTO, Mapper). Změna jediné funkce vyžaduje úpravy v každém z nich. Na tuto bolest reaguje Vertical Slice Architecture od Jimmyho Bogarda z roku 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 kompletní vertikální „sloupec“ přes všechny technické vrstvy aplikace. Tradiční vrstvený (horizontální) přístup naopak člení kód podle technické odpovědnosti (Controller / Service / Repository / Entity). Jeden use case se pak rozprostírá napříč všemi vrstvami.
Horizontální dělení – tradiční vrstvený přístup
V tradičním vrstveném DDD je projekt organizovaný podle technických vrstev. Každá vrstva má svůj adresář, soubory podobného typu žijí spolu. 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ář, který obsahuje všechny vrstvy potřebné pro svou implementaci. Sdílený doménový model zůstává v {BC}/Domain/, ale aplikační, prezentační a infrastrukturní logika se dělí per feature.
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└── Shared/Domain/Exception/DomainException.php
Tento přístup minimalizuje vazby mezi jednotlivými funkcemi a maximalizuje soudržnost uvnitř každé z nich [8]. Zároveň zachovává principy DDD, tedy respektuje Bounded Contexts i sdílený doménový model.
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 jednom adresáři, takže recenzent vidí při code review celý use case na jednom místě.
- Akceptační test pokryje celý slice najednou (HTTP request → response) bez mockování sedmi 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 jednom adresáři |
| 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í. Dává smysl i tam, kde doménový model nese silně sdílené invarianty napříč více 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 mikroslužeb 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 Kdy zvolit modular monolith.
Vertical Slice a Hexagonal jsou ortogonální
Hexagonal/Onion/Clean popisují jak strukturovat závislosti uvnitř jedné feature. Vertical Slice popisuje jak organizovat feature mezi sebou. Tyto dva přístupy lze 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. Tato kombinace dává vyvážený poměr testovatelnosti, organizace a srozumitelnosti pro tým.
09.07 Praktické srovnání – co si vybrat v Symfony 8#
Žádný styl není univerzálně lepší. Volba závisí na velikosti aplikace, zkušenosti týmu, plánovaném horizontu produktu a tom, kolik se vyplatí investovat do izolace. Následující rozhodovací matice shrnuje typická kritéria a směruje na vhodný styl. Čí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á |
| Junior friendly | ✓✓✓ | ✓ | ✓ | ✗ | ✓✓ |
| Test isolation domény | nízká | vysoká | vysoká | vysoká | střední |
| Doctrine integrace | tight (anotace na entity) | loose (přes adapter) | loose | loose | flexibilní (per 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 |
| CQRS přirozenost | vyžaduje úpravy | vysoká (port = command bus) | střední | vysoká (use case = handler) | velmi vysoká |
| Refactoring jedné feature | 5–7 souborů | 4–6 souborů | 5–7 souborů | 6–8 souborů | 1 adresář |
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 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.
Tato volba není univerzální pravda. Má-li váš projekt 20 endpointů a jde o interní administrativní aplikaci s desetiletým horizontem, stačí obyčejná Layered struktura ze Symfony skeletu. Tým s ní pravděpodobně iteruje rychleji. Pokud je váš projekt enterprise CRM s 500+ use casy a 15 vývojáři, Clean Architecture s explicitním Use Case katalogem se vyplatí.
09.08 Hybridní přístup – Hexagonal core, Layered okraje#
Realistické projekty zřídka používají jediný styl pro celou kódovou bázi. Mnohem č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. Tento přístup je v souladu s tím, co Eric Evans doporučuje v knize DDD: investovat modelovací úsilí 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. Následuje ukázka, jak hybridní přístup vypadá ve struktuře Symfony projektu.
Příklad: e-shop s diferencovanou architekturou
1src/2├── Ordering/ # CORE DOMAIN – plný Hexagonal3│ ├── Domain/4│ │ ├── Model/ # Bohatý agregát Order5│ │ │ ├── Order.php6│ │ │ ├── OrderLine.php7│ │ │ └── OrderId.php8│ │ ├── Event/9│ │ │ ├── OrderPlaced.php10│ │ │ └── OrderConfirmed.php11│ │ └── Port/ # Porty (interfaces)12│ │ ├── OrderRepository.php13│ │ └── EventPublisher.php14│ ├── Application/15│ │ └── UseCase/16│ │ ├── PlaceOrder/17│ │ │ ├── PlaceOrderCommand.php18│ │ │ └── PlaceOrderHandler.php19│ │ └── CancelOrder/20│ │ ├── CancelOrderCommand.php21│ │ └── CancelOrderHandler.php22│ └── Infrastructure/23│ ├── Persistence/24│ │ ├── DoctrineOrderRepository.php25│ │ └── OrderOrmEntity.php # Persistence-friendly mapping26│ └── Http/27│ └── PlaceOrderController.php28│29├── Customer/ # SUPPORTING – Layered DDD30│ ├── Controller/ # Symfony skeleton struktura31│ │ └── CustomerController.php32│ ├── Service/33│ │ └── CustomerService.php34│ ├── Entity/ # Doctrine entity přímo35│ │ └── Customer.php36│ └── Repository/37│ └── CustomerRepository.php38│39├── Notifications/ # GENERIC – tenký adapter na SaaS40│ ├── Service/41│ │ └── NotificationService.php42│ └── Provider/43│ ├── SendGridAdapter.php # Wrap kolem externí HTTP API44│ └── TwilioAdapter.php45│46└── Shared/ # Sdílené koncepty mezi BC47 ├── Domain/48 │ ├── Money.php49 │ └── DomainException.php50 └── Bus/51 ├── CommandBus.php # Interface52 └── EventBus.php
Pravidla hybridního přístupu
- Core Domain dostává plný Hexagonal, Vertical Slice a CQRS. Sem teče modelovací úsilí, sem teče čas na refaktoring, sem teče investice do testů.
- Supporting subdomény mají Layered strukturu – controller, service, entity, repository. Dostatečně dobré, rychlé k napsání, čitelné.
- Generic subdomény jsou tenké adaptéry. Žádné agregáty, žádné domain services – jen wrap kolem externí knihovny nebo SaaS API.
- Nemíchejte styly uvnitř jednoho Bounded Contextu. 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. Junior to nezvládne. Hybrid 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 projekt vyhrává konkurenční bitvu) 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) ušetříte stovky hodin nepotřebné izolace.
09.09 Anti-vzory napříč styly#
Většina problémů s architektonickými styly nepramení ze špatné volby stylu, ale ze špatné implementace. Následuje šest nejčastějších anti-vzorů, které se v reálných Symfony projektech opakují.
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 sedm 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 query v controlleru. Architektonický styl se používá, když přináší hodnotu; povinnost to není.
Anti-vzor 2: Domain leakage přes Doctrine anotace
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 stále závisí na Doctrine knihovně. 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 getrů, setrů 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: Premature 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í abstraktní interfaces, které mají jednu jedinou implementaci a nikdy se 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). Pokud ani jeden, interface je 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, každý pattern, každá abstrakce musí mít konkrétní zisk pro projekt. 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ů#
Bez ohledu na to, který styl zvolíte, v Symfony 8 budete pracovat se stejnou sadou nástrojů: Service Container, Messenger, Doctrine, Form, Security. Liší se pouze konvence, jak je v projektu používat. Následují tři praktické tipy, které platí pro všechny architektonické styly.
Bundle vs. namespace organizace
Symfony historicky stavěl 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. Bundly nepřinášejí žádnou výhodu, kterou by neposkytovaly namespacy + auto-wiring.
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 vylučte z auto-registrace v Service Containeru. Agregáty, hodnotové objekty a doménové události nejsou služby, ale data. Kontejner sám o sobě problém nedělá. Nepoužité privátní služby Symfony při sestavení odstraní, takže 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.
Jeden vedlejší efekt stojí za pozor. 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 každý use case dispatchujete jako Command, handler je váš inbound adaptér nebo přímo use case. Konfigurace per-bus:
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: ~ # výchozí middleware stačí16 event.bus:17 default_middleware:18 allow_no_handlers: true # Eventy mohou mít 0+ konzumentů19 20 transports:21 async: '%env(MESSENGER_TRANSPORT_DSN)%'22 23 routing:24 App\Ordering\Domain\Event\OrderPlaced: async25 App\Ordering\Domain\Event\OrderCancelled: async
query.bus žádné nastavení nepotřebuje. Výchozí allow_no_handlers: false odhalí překlep v názvu dotazu už 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 doporučená praxe v CQRS-friendly DDD aplikaci. 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 se musí vrátit v projektu.
Č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: nezačínejte velký rewrite, ale postupně. 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. Pokud Core Domain doženete celý, druhý BC možná stačí ponechat v Layered (hybridní přístup). Nikdy nemigrujte všechno najednou. Riziko regresí je vysoké. Detail strangler fig v kapitole Migrace z CRUD.
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 domain language (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í layered architecture.
- Martin Fowler – Patterns of Enterprise Application Architecture (2002). Service Layer, Domain Model, Data Mapper a další foundational patterns.
- Alistair Cockburn – Hexagonal Architecture (Ports and Adapters) (2005). Originální článek o Hexagonal architektuře.
- Jeffrey Palermo – The Onion Architecture: Part 1 (2008). První ze čtyř blogových postů zavádějících Onion model.
- Robert C. Martin – The Clean Architecture (2012). Original Clean Architecture article zobecňující Hexagonal a Onion.
- Jimmy Bogard – Vertical Slice Architecture (2018). Feature-first přístup k organizaci kódu.
- 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.
- Microsoft – Apply simplified CQRS and DDD patterns in a microservice (.NET microservices architecture guide). Jak feature-orientovaná struktura přirozeně podporuje CQRS.
- 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.
- 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.
- Jeffrey Palermo – Onion Architecture: Part 4 – After Four Years (2013). Čtyři teze vzoru a odstup od DDD, CQRS i IoC kontejneru.
- 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.
- 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.