Kapitola 09 · Základy · Architektonické styly – Hexagonal, Onion, Clean

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.

Autor M. Katuščák
Doba čtení ≈ 22 min
Náročnost pokročilá
Publikováno · Aktualizováno ·
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 není prohlašován 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 ji v Patterns of Enterprise Application Architecture (2002) [2] popsal jako „Service Layer + Domain Model + Data Source Layer“. Pozdější DDD literatura schéma zjednodušila na čtyři vrstvy: Presentation, Application, Domain, Infrastructure. Eric Evans v Domain-Driven Design (2003) totéž rozdělení převzal a přidal pravidlo, že vrstva smí záviset jen na vrstvách pod sebou, nikdy nahoru.

Č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/ nebo src/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

bash src/ (Symfony Layered konvence)
src/
├── Controller/                      # Presentation
│   ├── OrderController.php
│   └── CustomerController.php
├── Service/                          # Application
│   ├── OrderService.php
│   └── CustomerService.php
├── Entity/                           # Domain (s Doctrine anotacemi → leak)
│   ├── Order.php
│   ├── OrderLine.php
│   └── Customer.php
├── Repository/                       # Infrastructure
│   ├── OrderRepository.php
│   └── CustomerRepository.php
└── Form/                             # Presentation (vstupy)
    └── 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

php src/Entity/Order.php
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\OrderRepository;
use Doctrine\Common\Collections\Collection;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: OrderRepository::class)]
#[ORM\Table(name: 'orders')]
class Order
{
    #[ORM\Id]
    #[ORM\Column(type: 'string', length: 36)]
    private string $id;

    #[ORM\Column(type: 'string', length: 32)]
    private string $status = 'draft';

    #[ORM\OneToMany(mappedBy: 'order', targetEntity: OrderLine::class, cascade: ['persist'])]
    private Collection $lines;

    public function confirm(): void
    {
        if ($this->status !== 'draft') {
            throw new \DomainException('Only draft orders can be confirmed.');
        }
        if ($this->lines->isEmpty()) {
            throw new \DomainException('Cannot confirm an empty order.');
        }
        $this->status = 'confirmed';
    }

    public function cancel(): void
    {
        if ($this->status === 'shipped') {
            throw new \DomainException('Cannot cancel a shipped order.');
        }
        $this->status = 'cancelled';
    }
}

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 , ž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) – Doctrine anotace na entitách jsou pak masivní migrace.
  • 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.

php src/Controller/OrderController.php
<?php

declare(strict_types=1);

namespace App\Controller;

use App\Repository\OrderRepository;
use App\Service\OrderService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class OrderController extends AbstractController
{
    public function __construct(
        private readonly OrderRepository $repository,
        private readonly OrderService $service,
    ) {
    }

    #[Route('/orders/{id}/confirm', methods: ['POST'])]
    public function confirm(string $id): JsonResponse
    {
        $order = $this->repository->find($id);
        if ($order === null) {
            throw $this->createNotFoundException("Order {$id} not found.");
        }

        $this->service->confirm($order);

        return new JsonResponse(['status' => $order->getStatus()]);
    }
}

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 kolem jádra je 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 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

bash src/ (Symfony Hexagonal struktura)
src/
├── Ordering/                           # Bounded Context
│   ├── Domain/                         # Doménové jádro (žádné framework deps)
│   │   ├── Model/
│   │   │   ├── Order.php               # Aggregate Root – ČISTÉ PHP
│   │   │   ├── OrderLine.php
│   │   │   └── OrderId.php             # Value Object
│   │   ├── Event/
│   │   │   └── OrderConfirmed.php
│   │   └── Port/                       # Outbound porty (interfaces)
│   │       ├── OrderRepository.php
│   │       └── EventPublisher.php
│   ├── Application/                    # Inbound porty + use casy
│   │   ├── UseCase/
│   │   │   ├── PlaceOrder.php          # Inbound port (interface)
│   │   │   └── PlaceOrderHandler.php   # Implementace use casu
│   │   └── Dto/
│   │       └── PlaceOrderInput.php
│   └── Infrastructure/                 # Adaptéry (driving + driven)
│       ├── Http/                       # Driving adapter
│       │   └── PlaceOrderController.php
│       ├── Cli/                        # Driving adapter
│       │   └── PlaceOrderCommand.php
│       └── Persistence/                # Driven adapter
│           ├── DoctrineOrderRepository.php
│           └── OrderOrmEntity.php      # Mapper na databázi
└── Shared/
    └── Domain/
        └── 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 v Domain/Port/; jeho implementace (DoctrineOrderRepository) žije v Infrastructure/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

php src/Ordering/Domain/Port/OrderRepository.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Port;

use App\Ordering\Domain\Model\Order;
use App\Ordering\Domain\Model\OrderId;

interface OrderRepository
{
    public function get(OrderId $id): ?Order;

    public function save(Order $order): void;

    /**
     * @return list<Order>
     */
    public function findByCustomer(string $customerId): array;
}
php src/Ordering/Infrastructure/Persistence/DoctrineOrderRepository.php
<?php

declare(strict_types=1);

namespace App\Ordering\Infrastructure\Persistence;

use App\Ordering\Domain\Model\Order;
use App\Ordering\Domain\Model\OrderId;
use App\Ordering\Domain\Port\OrderRepository;
use Doctrine\ORM\EntityManagerInterface;

final class DoctrineOrderRepository implements OrderRepository
{
    public function __construct(
        private readonly EntityManagerInterface $em,
        private readonly OrderMapper $mapper,
    ) {
    }

    public function get(OrderId $id): ?Order
    {
        $orm = $this->em->find(OrderOrmEntity::class, $id->toString());

        return $orm === null ? null : $this->mapper->toDomain($orm);
    }

    public function save(Order $order): void
    {
        $orm = $this->mapper->toOrm($order);
        $this->em->persist($orm);
        $this->em->flush();
    }

    /**
     * @return list<Order>
     */
    public function findByCustomer(string $customerId): array
    {
        $rows = $this->em->getRepository(OrderOrmEntity::class)
            ->findBy(['customerId' => $customerId]);

        return array_map(fn (OrderOrmEntity $r) => $this->mapper->toDomain($r), $rows);
    }
}

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 dispatchovaný přes Messenger Bus. Port lze také definovat explicitně jako interface, který má jediný handler jako implementaci.

php src/Ordering/Application/UseCase/PlaceOrder.php
<?php

declare(strict_types=1);

namespace App\Ordering\Application\UseCase;

use App\Ordering\Application\Dto\PlaceOrderInput;
use App\Ordering\Application\Dto\PlaceOrderOutput;

/**
 * Inbound port (driving) – kontrakt aplikační schopnosti
 * „umístit objednávku". HTTP adaptér, CLI command i testy
 * volají přes tento port; konkrétní implementace je v handleru.
 */
interface PlaceOrder
{
    public function handle(PlaceOrderInput $input): PlaceOrderOutput;
}

HTTP adapter pak nezná konkrétní třídu handleru – zná jen rozhraní portu, a Symfony DI ho automaticky napojí na implementaci. Tím získáte schopnost handler v testech vyměnit za fake bez celé aplikační vrstvy.

php src/Ordering/Infrastructure/Http/PlaceOrderController.php
<?php

declare(strict_types=1);

namespace App\Ordering\Infrastructure\Http;

use App\Ordering\Application\Dto\PlaceOrderInput;
use App\Ordering\Application\UseCase\PlaceOrder;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class PlaceOrderController
{
    public function __construct(
        private readonly PlaceOrder $useCase,
    ) {
    }

    #[Route('/api/orders', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);

        $input = new PlaceOrderInput(
            customerId: $payload['customerId'],
            items: $payload['items'],
        );

        $output = $this->useCase->handle($input);

        return new JsonResponse([
            'orderId' => $output->orderId,
            'status' => $output->status,
        ], 201);
    }
}

Symfony Service Container a auto-wiring

Symfony Dependency Injection automaticky binduje rozhraní na implementaci, pokud je jen jedna implementace daného rozhraní. Pokud je víc (např. InMemoryOrderRepository pro testy a DoctrineOrderRepository pro produkci), explicitně určíte mapování v config/services.yaml:

yaml config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # Explicitní binding portu na výchozí adaptér
    App\Ordering\Domain\Port\OrderRepository:
        alias: App\Ordering\Infrastructure\Persistence\DoctrineOrderRepository

    # Pro testy lze přepsat v config/services_test.yaml

Alternativa s atributem #[Autowire] přímo v konstruktoru use casu:

php src/Ordering/Application/UseCase/PlaceOrderHandler.php
<?php

declare(strict_types=1);

namespace App\Ordering\Application\UseCase;

use App\Ordering\Domain\Port\OrderRepository;
use App\Ordering\Infrastructure\Persistence\DoctrineOrderRepository;
use Symfony\Component\DependencyInjection\Attribute\Autowire;

final class PlaceOrderHandler implements PlaceOrder
{
    public function __construct(
        #[Autowire(service: DoctrineOrderRepository::class)]
        private readonly OrderRepository $orders,
    ) {
    }
}

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).

php src/Ordering/Domain/Port/EventPublisher.php
<?php

declare(strict_types=1);

namespace App\Ordering\Domain\Port;

use App\Ordering\Domain\Event\DomainEvent;

interface EventPublisher
{
    public function publish(DomainEvent $event): void;

    /**
     * @param iterable<DomainEvent> $events
     */
    public function publishAll(iterable $events): void;
}
php src/Ordering/Infrastructure/Messaging/MessengerEventPublisher.php
<?php

declare(strict_types=1);

namespace App\Ordering\Infrastructure\Messaging;

use App\Ordering\Domain\Event\DomainEvent;
use App\Ordering\Domain\Port\EventPublisher;
use Symfony\Component\Messenger\MessageBusInterface;

final class MessengerEventPublisher implements EventPublisher
{
    public function __construct(
        private readonly MessageBusInterface $eventBus,
    ) {
    }

    public function publish(DomainEvent $event): void
    {
        $this->eventBus->dispatch($event);
    }

    public function publishAll(iterable $events): void
    {
        foreach ($events as $event) {
            $this->eventBus->dispatch($event);
        }
    }
}

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.

php tests/Ordering/Doubles/InMemoryEventPublisher.php
<?php

declare(strict_types=1);

namespace App\Tests\Ordering\Doubles;

use App\Ordering\Domain\Event\DomainEvent;
use App\Ordering\Domain\Port\EventPublisher;

final class InMemoryEventPublisher implements EventPublisher
{
    /** @var list<DomainEvent> */
    private array $published = [];

    public function publish(DomainEvent $event): void
    {
        $this->published[] = $event;
    }

    public function publishAll(iterable $events): void
    {
        foreach ($events as $event) {
            $this->publish($event);
        }
    }

    /**
     * @return list<DomainEvent>
     */
    public function published(): array
    {
        return $this->published;
    }
}
FIG. 09.3-A Čtyři architektonické styly aplikované na DDD

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

  • CRUD nad několika tabulkami – port + adaptér + mapper pro každou entitu je over-engineering bez návratnosti.
  • Tým neumí Dependency Injection – Hexagonal stojí na principu inverze závislostí; bez něj je struktura jen kosmetická.
  • Krátký horizont produktu – investice do izolace se nezaplatí, pokud projekt zanikne za rok.

09.04 Onion Architecture (Palermo 2008)

Onion Architecture vznikla v roce 2008 ve čtyřdílné blogové sérii Jeffreyho Palerma [4]. 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

  1. Domain Model (jádro) – entity, hodnotové objekty, agregáty, doménové události. Žádné závislosti. Žádný framework. Žádná persistence.
  2. Domain Services – bezstavové třídy s doménovou logikou, která nepatří do žádné konkrétní entity. Závisí jen na Domain Model.
  3. Application Services – orchestrace use casů, transakce, mapování DTO. Závisí na Domain Services a Domain Model.
  4. 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 popisuje porty, adaptéry a kudy data tečou.
  • 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 kvůli tomu, že pracuje s víc 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.

php src/Pricing/Domain/Service/PriceCalculator.php
<?php

declare(strict_types=1);

namespace App\Pricing\Domain\Service;

use App\Pricing\Domain\Model\Cart;
use App\Pricing\Domain\Model\Customer;
use App\Pricing\Domain\Model\DiscountPolicy;
use App\Pricing\Domain\Model\Money;

/**
 * Domain Service – výpočet ceny vyžaduje data z více agregátů
 * (Cart, Customer, DiscountPolicy). Logika je čistě doménová,
 * žádný framework, žádná persistence.
 */
final class PriceCalculator
{
    public function calculate(
        Cart $cart,
        Customer $customer,
        DiscountPolicy $policy,
    ): Money {
        $subtotal = $cart->subtotal();
        $discount = $policy->applyTo($subtotal, $customer->loyaltyTier());
        $vat = $subtotal->subtract($discount)->multiply(0.21);

        return $subtotal->subtract($discount)->add($vat);
    }
}
php src/Pricing/Application/Service/CalculateCartPrice.php
<?php

declare(strict_types=1);

namespace App\Pricing\Application\Service;

use App\Pricing\Domain\Port\CartRepository;
use App\Pricing\Domain\Port\CustomerRepository;
use App\Pricing\Domain\Port\DiscountPolicyRepository;
use App\Pricing\Domain\Service\PriceCalculator;
use App\Shared\Domain\Money;

/**
 * Application Service – orchestrace use casu „Spočítej cenu košíku".
 * Vlastní logika je v Domain Service; aplikační vrstva jen řídí transakci
 * a načítá agregáty z repository.
 */
final class CalculateCartPrice
{
    public function __construct(
        private readonly CartRepository $carts,
        private readonly CustomerRepository $customers,
        private readonly DiscountPolicyRepository $policies,
        private readonly PriceCalculator $calculator,
    ) {
    }

    public function execute(string $cartId): Money
    {
        $cart = $this->carts->get($cartId)
            ?? throw new \DomainException("Cart {$cartId} not found.");

        $customer = $this->customers->get($cart->customerId())
            ?? throw new \DomainException("Customer not found.");

        $policy = $this->policies->forCustomer($customer);

        return $this->calculator->calculate($cart, $customer, $policy);
    }
}

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

bash src/ (Symfony Onion struktura)
src/
├── Pricing/                            # Bounded Context
│   ├── Domain/                         # Vnitřní prsten (jádro)
│   │   ├── Model/
│   │   │   ├── Cart.php
│   │   │   ├── Customer.php
│   │   │   └── DiscountPolicy.php
│   │   ├── Port/                       # Repository interfaces
│   │   │   ├── CartRepository.php
│   │   │   ├── CustomerRepository.php
│   │   │   └── DiscountPolicyRepository.php
│   │   └── Service/                    # 2. prsten – Domain Services
│   │       └── PriceCalculator.php
│   ├── Application/                    # 3. prsten – Application Services
│   │   └── Service/
│   │       ├── CalculateCartPrice.php
│   │       └── ApplyCouponToCart.php
│   └── Infrastructure/                 # Vnější prsten – UI a infra
│       ├── Persistence/
│       │   └── DoctrineCartRepository.php
│       └── Http/
│           └── CartPriceController.php
└── Shared/
    └── Domain/
        └── 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í způsobí silně provázané kostky.

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

  1. Entities – doménové objekty s nejhlubšími invarianty. Odpovídá DDD agregátům a hodnotovým objektům. Nezávisí na ničem.
  2. Use Cases – obchodní pravidla specifická pro aplikaci. Každý use case je třída s jednou public metodou (execute() nebo handle()). Závisí jen na Entities.
  3. 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.
  4. 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 může citovat třídy z 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

php src/Ordering/UseCase/PlaceOrder/PlaceOrderRequest.php
<?php

declare(strict_types=1);

namespace App\Ordering\UseCase\PlaceOrder;

/**
 * Request DTO – vstup do use casu, framework-agnostický.
 * Žádné Symfony Request, žádné Doctrine entity, žádné HTTP detaily.
 */
final readonly class PlaceOrderRequest
{
    /**
     * @param list<array{productId: string, quantity: int}> $items
     */
    public function __construct(
        public string $customerId,
        public array $items,
        public string $shippingAddress,
    ) {
    }
}
php src/Ordering/UseCase/PlaceOrder/PlaceOrderResponse.php
<?php

declare(strict_types=1);

namespace App\Ordering\UseCase\PlaceOrder;

/**
 * Response DTO – výstup z use casu. Žádné view, žádný JSON.
 * Adaptér (Controller, CLI Command) si zformátuje výstup sám.
 */
final readonly class PlaceOrderResponse
{
    public function __construct(
        public string $orderId,
        public string $status,
        public int $totalAmount,
    ) {
    }
}
php src/Ordering/UseCase/PlaceOrder/PlaceOrderUseCase.php
<?php

declare(strict_types=1);

namespace App\Ordering\UseCase\PlaceOrder;

use App\Ordering\Domain\Model\Order;
use App\Ordering\Domain\Model\OrderId;
use App\Ordering\Domain\Port\CustomerRepository;
use App\Ordering\Domain\Port\OrderRepository;
use App\Shared\Domain\EventPublisher;

final class PlaceOrderUseCase
{
    public function __construct(
        private readonly OrderRepository $orders,
        private readonly CustomerRepository $customers,
        private readonly EventPublisher $events,
    ) {
    }

    public function execute(PlaceOrderRequest $request): PlaceOrderResponse
    {
        $customer = $this->customers->get($request->customerId)
            ?? throw new \DomainException('Customer not found.');

        $order = Order::place(
            OrderId::generate(),
            $customer,
            $request->items,
            $request->shippingAddress,
        );

        $this->orders->save($order);

        foreach ($order->releaseEvents() as $event) {
            $this->events->publish($event);
        }

        return new PlaceOrderResponse(
            orderId: $order->id()->toString(),
            status: $order->status(),
            totalAmount: $order->totalAmount()->toMinorUnits(),
        );
    }
}

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

php src/Ordering/Infrastructure/Http/PlaceOrderController.php
<?php

declare(strict_types=1);

namespace App\Ordering\Infrastructure\Http;

use App\Ordering\UseCase\PlaceOrder\PlaceOrderRequest;
use App\Ordering\UseCase\PlaceOrder\PlaceOrderUseCase;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;

final class PlaceOrderController
{
    public function __construct(
        private readonly PlaceOrderUseCase $useCase,
    ) {
    }

    #[Route('/api/orders', methods: ['POST'])]
    public function __invoke(Request $request): JsonResponse
    {
        $payload = json_decode($request->getContent(), true, flags: JSON_THROW_ON_ERROR);

        $useCaseRequest = new PlaceOrderRequest(
            customerId: $payload['customerId'],
            items: $payload['items'],
            shippingAddress: $payload['shippingAddress'],
        );

        $response = $this->useCase->execute($useCaseRequest);

        return new JsonResponse([
            'orderId' => $response->orderId,
            'status' => $response->status,
            'totalAmount' => $response->totalAmount,
        ], 201);
    }
}

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 ve všech sedmi. 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. To je v ostrém kontrastu s tradičním vrstveným (horizontálním) přístupem, kde se kód člení podle technické odpovědnosti (Controller / Service / Repository / Entity), a jeden use case se 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/:

bash src/ (tradiční DDD struktura)
src/
├── Presentation/                # Prezentační vrstva
│   └── Controller/UserController.php
├── Application/                 # Aplikační vrstva
│   ├── Service/UserService.php
│   └── DTO/UserDTO.php
├── Domain/                      # Doménová vrstva
│   ├── Model/User.php
│   ├── Repository/UserRepository.php
│   └── Service/DomainUserService.php
└── Infrastructure/              # Infrastrukturní vrstva
    ├── Repository/DoctrineUserRepository.php
    └── Persistence/Doctrine/Mapping/User.orm.xml

Vrstvy jsou organizovány horizontálně. Každá vrstva poskytuje služby vrstvě nad ní. 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 organizuje kód podle 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.

bash src/ (Vertical Slice struktura)
src/
├── UserManagement/             # Bounded Context
│   ├── Domain/                 # Sdílený doménový model BC
│   │   ├── Model/User.php
│   │   ├── ValueObject/{UserId, Email}.php
│   │   ├── Event/UserRegistered.php
│   │   └── Repository/UserRepository.php
│   ├── Infrastructure/         # Sdílená infrastruktura BC
│   │   └── Repository/DoctrineUserRepository.php
│   ├── Registration/           # Feature: Registrace
│   │   ├── Command/{RegisterUser, RegisterUserHandler}.php
│   │   └── Controller/RegistrationController.php
│   └── Profile/                # Feature: Profil
│       ├── Query/{GetUserProfile, GetUserProfileHandler}.php
│       ├── Controller/ProfileController.php
│       └── ViewModel/UserProfileViewModel.php
└── Shared/Domain/Exception/DomainException.php

Tento přístup minimalizuje vazby mezi jednotlivými funkcemi a maximalizuje vazby uvnitř funkce [7]. 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áte Ordering/PlaceOrder/, Ordering/CancelOrder/, Ordering/GetOrderHistory/.
  • Závislosti mezi feature jsou minimální – 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. Code review se zjednoduší – recenzent vidí celý use case na jednom místě.
  • Akceptační test může pokrýt celý slice najednou (HTTP request → response), aniž by bylo nutné mockovat sedm 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 [8]

Kdy zvolit který přístup

Horizontální (vrstvený) přístup se vyplatí, když:

  • Tým má dlouhou zkušenost s vrstvenou architekturou a CQRS není v plánu.
  • Aplikace má 10–30 endpointů a malou doménovou složitost.
  • Doménový model má silně sdílené invarianty napříč více funkcemi, které je třeba jednotně vymáhat.
  • Preference týmu je 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 současných 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.

FIG. 09.7-A Layered vs. Hexagonal vs. Onion vs. Clean - vrstvy a směr závislostí
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 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: investujte 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

bash src/ (hybridní rozložení e-shopu)
src/
├── Ordering/                           # CORE DOMAIN – plný Hexagonal
│   ├── Domain/
│   │   ├── Model/                      # Bohatý agregát Order
│   │   │   ├── Order.php
│   │   │   ├── OrderLine.php
│   │   │   └── OrderId.php
│   │   ├── Event/
│   │   │   ├── OrderPlaced.php
│   │   │   └── OrderConfirmed.php
│   │   └── Port/                       # Porty (interfaces)
│   │       ├── OrderRepository.php
│   │       └── EventPublisher.php
│   ├── Application/
│   │   └── UseCase/
│   │       ├── PlaceOrder/
│   │       │   ├── PlaceOrderCommand.php
│   │       │   └── PlaceOrderHandler.php
│   │       └── CancelOrder/
│   │           ├── CancelOrderCommand.php
│   │           └── CancelOrderHandler.php
│   └── Infrastructure/
│       ├── Persistence/
│       │   ├── DoctrineOrderRepository.php
│       │   └── OrderOrmEntity.php      # Persistence-friendly mapping
│       └── Http/
│           └── PlaceOrderController.php
│
├── Customer/                           # SUPPORTING – Layered DDD
│   ├── Controller/                     # Symfony skeleton struktura
│   │   └── CustomerController.php
│   ├── Service/
│   │   └── CustomerService.php
│   ├── Entity/                         # Doctrine entity přímo
│   │   └── Customer.php
│   └── Repository/
│       └── CustomerRepository.php
│
├── Notifications/                      # GENERIC – tenký adapter na SaaS
│   ├── Service/
│   │   └── NotificationService.php
│   └── Provider/
│       ├── SendGridAdapter.php         # Wrap kolem externí HTTP API
│       └── TwilioAdapter.php
│
└── Shared/                             # Sdílené koncepty mezi BC
    ├── Domain/
    │   ├── Money.php
    │   └── DomainException.php
    └── Bus/
        ├── CommandBus.php              # Interface
        └── 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 není povinnost – je to nástroj, který se používá, když přináší hodnotu.

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#[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, ale doménové třídy jsou anémické – getry, setry, žádná logika. Veškerá logika sedí v handlerech a service vrstvě. Hexagonal/Clean bez DDD modelování jsou jen vrstvení rituálu kolem prázdné domény.

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

Tým definuje jen OrderRepository jako port (interface v doméně, implementace v infrastructure). Ostatní výstupní závislosti – e-mail mailer, externí HTTP klient, publisher událostí – žijí v App\Service\ bez rozhraní. Doména pak má závislost na konkrétní implementaci e-mailového maileru, což porušuje princip Hexagonal stejně jako Doctrine anotace.

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 nejsou nikdy mock-ované. Č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.

yaml config/services.yaml
# config/services.yaml
imports:
    - { resource: 'packages/contexts/ordering.yaml' }
    - { resource: 'packages/contexts/billing.yaml' }
    - { resource: 'packages/contexts/customer.yaml' }

services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'
        exclude:
            - '../src/Kernel.php'
            - '../src/**/Domain/Model/'        # Doménové modely nejsou služby
            - '../src/**/Domain/Event/'        # Události také ne
            - '../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 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:

yaml config/packages/messenger.yaml
# config/packages/messenger.yaml
framework:
    messenger:
        buses:
            command.bus:
                middleware:
                    - validation
                    - doctrine_transaction
            query.bus:
                default_middleware:
                    allow_no_handlers: false
                    allow_no_senders: false
            event.bus:
                default_middleware:
                    allow_no_handlers: true   # Eventy mohou mít 0+ konzumentů

        transports:
            async: '%env(MESSENGER_TRANSPORT_DSN)%'

        routing:
            App\Ordering\Domain\Event\OrderConfirmed: async
            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 nejen pragmatický, ale i doporučený autoritami DDD literatury. Investujte modelovací úsilí 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 auto-wiringu je port automaticky napojen na svou jedinou implementaci, nebo můžete explicitně mapovat v services.yaml. 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 významný overhead – 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á auditability „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 současných 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 Vertical Slice v samostatné kapitole.

09.12 Další četba a citace

  1. Eric Evans – Domain-Driven Design: Tackling Complexity in the Heart of Software (2003). Originální definice DDD a doporučení layered architecture.
  2. Martin Fowler – Patterns of Enterprise Application Architecture (2002). Service Layer, Domain Model, Data Mapper a další foundational patterns.
  3. Alistair Cockburn – Hexagonal Architecture (Ports and Adapters) (2005). Originální článek o Hexagonal architektuře.
  4. Jeffrey Palermo – The Onion Architecture: Part 1 (2008). První ze čtyř blogových postů zavádějících Onion model.
  5. Robert C. Martin – The Clean Architecture (2012). Original Clean Architecture article zobecňující Hexagonal a Onion.
  6. Jimmy Bogard – Vertical Slice Architecture (2018). Feature-first přístup k organizaci kódu.
  7. Vaughn Vernon – Implementing Domain-Driven Design (2013). Praktický průvodce DDD s ukázkami architektonických stylů.
  8. Herberto Graça – DDD, Hexagonal, Onion, Clean, CQRS, … How I put it all together (2017). Hybridní pohled na kombinaci stylů.
  9. Martin Fowler – Anemic Domain Model (2003). Klasický článek popisující anti-vzor anémického modelu.