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 více strukturálních stylech – vrstvové i hexagonální 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, Infrastructure – a přidal 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/.
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: 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\Collection;9use Doctrine\ORM\Mapping as ORM;10 11#[ORM\Entity(repositoryClass: OrderRepository::class)]12#[ORM\Table(name: 'orders')]13class Order14{15 #[ORM\Id]16 #[ORM\Column(type: 'string', length: 36)]17 private string $id;18 19 #[ORM\Column(type: 'string', length: 32)]20 private string $status = 'draft';21 22 #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]23 private Collection $lines;24 25 public function confirm(): void26 {27 if ($this->status !== 'draft') {28 throw new \DomainException('Only draft orders can be confirmed.');29 }30 if ($this->lines->isEmpty()) {31 throw new \DomainException('Cannot confirm an empty order.');32 }33 $this->status = 'confirmed';34 }35 36 public function cancel(): void37 {38 if ($this->status === 'shipped') {39 throw new \DomainException('Cannot cancel a shipped order.');40 }41 $this->status = 'cancelled';42 }43}
Třída Order má bohaté chování (confirm(), cancel()) a kontroluje invarianty – 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“ je definováno v doménovém kódu, ale zároveň ten kód 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á; Hexagonal trvá na separaci přes Persisted Object Pattern.
Kdy se Layered hodí
- Junior tým a rychlý start – Symfony skeleton,
make:*commandy, předvídatelná struktura. - Aplikace s 10–50 endpointy – kde investice do izolace nepřinese měřitelný přínos.
- Krátký horizont produktu (MVP, prototyp, interní nástroj) – kde Doctrine vendor lock-in není riziko, protože migrace nikdy nepřijde.
- Tým, který Symfony ovládá plynně – kde dodatečná složitost by jen brzdila, aniž by řešila reálný problé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) – odstranění Doctrine anotací 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->getStatus()]);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.
Geometrická metafora hexagonu (šestiúhelníku) je pouze grafická pomůcka – Cockburn původně chtěl ukázat, že jádro má víc než dvě strany (UI nahoře, DB dole), že portů může být libovolný počet. Číslo „šest“ nemá žádný význam; stejně dobře by to mohl být osmiúhelník, desetiúhelník nebo trojúhelník.
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.
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) – 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.
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 $orm = $this->mapper->toOrm($order);33 $this->em->persist($orm);34 // flush a commit řídí doctrine_transaction middleware command busu;35 // repozitář transakci neotevírá ani nevlastní36 }37 38 /**39 * @return list<Order>40 */41 public function findByCustomer(string $customerId): array42 {43 $rows = $this->em->getRepository(OrderOrmEntity::class)44 ->findBy(['customerId' => $customerId]);45 46 return array_map(fn (OrderOrmEntity $r) => $this->mapper->toDomain($r), $rows);47 }48}
Doménová třída Order nemá žádné Doctrine anotace – je to čisté PHP. 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}
HTTP adapter pak nezná konkrétní třídu handleru – zná 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 konkrétní třídy to funguje samo; u rozhraní nikoliv – kontejner neví, kterou službu má za typ OrderRepository dosadit, a to ani tehdy, když existuje jediná implementace. Port proto potřebuje alias na vybraný adaptér. 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\Port\OrderRepository;8 9final class PlaceOrderHandler implements PlaceOrder10{11 public function __construct(12 private readonly OrderRepository $orders,13 ) {14 }15}
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.
Pokud existuje víc implementací portu (např. InMemoryOrderRepository pro testy vedle DoctrineOrderRepository pro produkci), alias zároveň určuje výchozí adaptér; testovací prostředí ho přepíše v config/services_test.yaml.
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 7use App\Ordering\Domain\Event\DomainEvent;8 9interface EventPublisher10{11 public function publish(DomainEvent $event): void;12 13 /**14 * @param iterable<DomainEvent> $events15 */16 public function publishAll(iterable $events): void;17}
1<?php2 3declare(strict_types=1);4 5namespace App\Ordering\Infrastructure\Messaging;6 7use App\Ordering\Domain\Event\DomainEvent;8use App\Ordering\Domain\Port\EventPublisher;9use Symfony\Component\Messenger\MessageBusInterface;10 11final class MessengerEventPublisher implements EventPublisher12{13 public function __construct(14 private readonly MessageBusInterface $eventBus,15 ) {16 }17 18 public function publish(DomainEvent $event): void19 {20 $this->eventBus->dispatch($event);21 }22 23 public function publishAll(iterable $events): void24 {25 foreach ($events as $event) {26 $this->eventBus->dispatch($event);27 }28 }29}
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\Event\DomainEvent;8use App\Ordering\Domain\Port\EventPublisher;9 10final class InMemoryEventPublisher implements EventPublisher11{12 /** @var list<DomainEvent> */13 private array $published = [];14 15 public function publish(DomainEvent $event): void16 {17 $this->published[] = $event;18 }19 20 public function publishAll(iterable $events): void21 {22 foreach ($events as $event) {23 $this->publish($event);24 }25 }26 27 /**28 * @return list<DomainEvent>29 */30 public function published(): array31 {32 return $this->published;33 }34}
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, GraphQL – všechny 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 overhead zavedení portů je amortizovaný 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 [4] – 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é – jádro uprostřed, vnější svět 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 není dovoleno. Repozitáře jsou definovány jako rozhraní v jádře a implementovány v UI/Infrastructure vrstvě.
Rozdíl proti Hexagonal
Onion a Hexagonal mají stejnou základní myšlenku – izolovat doménu, závislosti dovnitř – a 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 de facto hybrid Hexagonal+Onion. To je v pořádku – málokdo dnes 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\Shared\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 $vat = $subtotal->subtract($discount)->multiply(0.21);27 28 return $subtotal->subtract($discount)->add($vat);29 }30}
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\Shared\Domain\Money;12 13/**14 * Application Service – orchestrace use casu „Spočítej cenu košíku".15 * Vlastní logika je v Domain Service; aplikační vrstva jen řídí transakci16 * a načítá agregáty z repository.17 */18final class CalculateCartPrice19{20 public function __construct(21 private readonly CartRepository $carts,22 private readonly CustomerRepository $customers,23 private readonly DiscountPolicyRepository $policies,24 private readonly PriceCalculator $calculator,25 ) {26 }27 28 public function execute(string $cartId): Money29 {30 $cart = $this->carts->get($cartId)31 ?? throw new \DomainException("Cart {$cartId} not found.");32 33 $customer = $this->customers->get($cart->customerId())34 ?? throw new \DomainException("Customer not found.");35 36 $policy = $this->policies->forCustomer($customer);37 38 return $this->calculator->calculate($cart, $customer, $policy);39 }40}
Rozdíl: PriceCalculator nezná repository – bere si již načtené objekty. CalculateCartPrice zná repository (přes porty) – orchestruje načtení a 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 závisí 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 každá závislost se dá zkontrolovat statickou analýzou.
- 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 má málo Domain Services – pak je Onion zbytečně složitý a Hexagonal stačí.
- Mladší tým – rozdíl mezi Domain Service a Application Service není intuitivní a chybné rozhodnutí vede k silně provázanému kódu.
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 [5], který v roce 2017 rozvedl do stejnojmenné knihy.
Č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 – definuje rozhraní v sobě, které vnější vrstva implementuje.
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 de facto 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\Model\Order;8use App\Ordering\Domain\Model\OrderId;9use App\Ordering\Domain\Port\CustomerRepository;10use App\Ordering\Domain\Port\OrderRepository;11use App\Ordering\Domain\Port\EventPublisher;12 13final class PlaceOrderUseCase14{15 public function __construct(16 private readonly OrderRepository $orders,17 private readonly CustomerRepository $customers,18 private readonly EventPublisher $events,19 ) {20 }21 22 public function execute(PlaceOrderRequest $request): PlaceOrderResponse23 {24 $customer = $this->customers->get($request->customerId)25 ?? throw new \DomainException('Customer not found.');26 27 $order = Order::place(28 OrderId::generate(),29 $customer->id(), // reference na jiný agregát vede přes ID30 $request->items,31 $request->shippingAddress,32 );33 34 $this->orders->save($order);35 36 foreach ($order->releaseEvents() as $event) {37 $this->events->publish($event);38 }39 40 return new PlaceOrderResponse(41 orderId: $order->id()->value,42 status: $order->status(),43 totalAmount: $order->totalAmount()->toMinorUnits(),44 );45 }46}
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
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, GraphQL – všechny 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
- Malá Symfony aplikace s ~30 endpointy – DTO ping-pong v Clean (Request → Domain → Response) představuje významnou režii.
- Tým bez zkušenosti s Dependency Injection – Clean stojí na Dependency Inversion ještě silněji než Hexagonal.
- Doména je velmi tenká – Use Case v Clean kolem prázdné domény je 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 [6].
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 je rozdělená 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 [6]. Zároveň zachovává principy DDD – respektuje Bounded Contexts a sdílený doménový model.
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 [10] |
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 je třeba je jednotně vymáhat – nebo kde tým preferuje explicitní 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 bude v budoucnu rozdělena do mikroslužeb – feature jako celek se snadněji extrahuje.
- Preferujete rychlou iteraci s minimální koordinací mezi vrstvami.
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 (slice má vlastní Port, vlastní Adapter, 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), ale aplikační vrstva je rozdělená 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.
| 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/ obsahuje 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, není nutné psát vlastní bus.
Tato volba není univerzální pravda. Pokud váš projekt má 20 endpointů a jde o interní administrativní aplikaci s desetiletým horizontem, obyčejná Layered struktura ze Symfony skeletu stačí a 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 – musíte mít aspoň jednoho seniora, který architekturu hlídá. Mezi BC jsou nutně rozdílné konvence, což může čtenáře kódu 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 – vzor Persisted Object Pattern. Cena je dvojí třída a explicitní mapping – zisk je č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 – getry, setry, logika v handlerech. 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í – zavedení Hexagonal nad anémickým modelem nepřinese izolaci, jen zkomplikuje code review.
Anti-vzor 4: Port jen pro Repository
Druhý vzor z téhož calloutu: port dostane jen OrderRepository, zatímco ostatní výstupní závislosti domény zůstávají bez rozhraní.
Náprava: Každá výstupní závislost domény dostane port. EmailSender, EventPublisher, PaymentGateway – všechno jsou interfaces v Domain/Port/, a infrastructure je implementuje.
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“ – osmivrstvová Clean s explicitními BCE rolemi, formálními use case katalogy, presenter třídami, 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. V Symfony 8 se v aplikačním kódu doporučuje bundly nepoužívat a místo toho strukturovat src/ přímo přes namespacy. 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. Doménové entity, hodnotové objekty a doménové eventy nejsou služby – jsou to data. Pokud je necháte registrovat jako služby, riskujete, že Symfony do nich zkusí injektovat závislosti, což porušuje DDD pravidla.
Symfony Messenger jako Command Bus
Pro všechny styly kromě Layered – kde se aplikační služba typicky volá přímo z controlleru – je Symfony Messenger vhodný nástroj pro implementaci Command Bus a Event Bus pattern. 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 buses:5 command.bus:6 middleware:7 - validation8 - doctrine_transaction9 query.bus:10 default_middleware:11 allow_no_handlers: false12 allow_no_senders: false13 event.bus:14 default_middleware:15 allow_no_handlers: true # Eventy mohou mít 0+ konzumentů16 17 transports:18 async: '%env(MESSENGER_TRANSPORT_DSN)%'19 20 routing:21 App\Ordering\Domain\Event\OrderConfirmed: async22 App\Ordering\Domain\Event\OrderCancelled: async
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 mají stejnou základní myšlenku – izolaci domény – ale jiný slovník a jinou granularitu. 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 v podstatě hybrid – což 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 – buď v services.yaml, nebo atributem #[AsAlias] na implementaci; samotný autowiring rozhraní na adaptér nenaváže. 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), což je významná 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í), kde je důležitá auditovatelnost toho, „co aplikace umí“ a kde je víc vstupních kanálů (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.