HardТеория21 min

Hexagonal Architecture (Ports & Adapters)

Гексагональная архитектура: порты, адаптеры, primary/secondary, отличие от Clean Architecture, пример на PHP

Hexagonal Architecture (Ports & Adapters)

Происхождение

Hexagonal Architecture (гексагональная архитектура) предложена Алистером Кокбёрном (Alistair Cockburn) в 2005 году. Другое название -- Ports and Adapters.

Главная идея: приложение не должно зависеть от способа доставки данных (HTTP, CLI, очередь) или способа хранения (PostgreSQL, файлы, API).

Основная диаграмма

                    PRIMARY SIDE
                  (driving side)
                       │
         ┌─────────────┼─────────────┐
         │             │             │
    ┌────▼────┐  ┌─────▼─────┐  ┌───▼─────┐
    │  REST   │  │    CLI    │  │  Queue  │
    │ Adapter │  │  Adapter  │  │ Adapter │
    └────┬────┘  └─────┬─────┘  └───┬─────┘
         │             │             │
    ┌────▼─────────────▼─────────────▼────┐
    │         INPUT PORTS                  │
    │    (интерфейсы Use Cases)            │
    ├──────────────────────────────────────┤
    │                                      │
    │          APPLICATION CORE            │
    │                                      │
    │    ┌──────────────────────────┐      │
    │    │     DOMAIN MODEL        │      │
    │    │  Entities, Value Objects │      │
    │    │  Domain Services        │      │
    │    └──────────────────────────┘      │
    │                                      │
    ├──────────────────────────────────────┤
    │         OUTPUT PORTS                 │
    │    (интерфейсы репозиториев,         │
    │     внешних сервисов)                │
    └────┬─────────────┬─────────────┬────┘
         │             │             │
    ┌────▼────┐  ┌─────▼─────┐  ┌───▼─────┐
    │Postgres │  │   Redis   │  │  Email  │
    │ Adapter │  │  Adapter  │  │ Adapter │
    └─────────┘  └───────────┘  └─────────┘
         │             │             │
                    SECONDARY SIDE
                   (driven side)

Ключевые концепции

Порты (Ports)

Порт -- это интерфейс, который определяет контракт взаимодействия с приложением. Порты бывают двух видов:

Тип порта Направление Назначение Пример
Primary (Input) Внешний мир → Приложение Определяет что приложение умеет делать CreateOrderUseCase
Secondary (Output) Приложение → Внешний мир Определяет что приложению нужно извне OrderRepositoryInterface

Адаптеры (Adapters)

Адаптер -- это реализация порта для конкретной технологии.

Тип адаптера Сторона Примеры
Primary (Driving) Входная REST Controller, CLI Command, GraphQL Resolver
Secondary (Driven) Выходная PostgreSQL Repository, Redis Cache, SMTP Mailer

Полный пример на PHP

Output Port (интерфейс)

<?php

declare(strict_types=1);

namespace App\Domain\Port;

use App\Domain\Entity\Order;
use App\Domain\ValueObject\OrderId;

// Output Port: определяет ЧТО нужно, но не КАК
interface OrderRepositoryInterface
{
    public function save(Order $order): void;

    public function findById(OrderId $id): ?Order;

    /** @return Order[] */
    public function findByCustomerId(string $customerId): array;

    public function nextIdentity(): OrderId;
}
### Domain Entity
<?php

declare(strict_types=1);

namespace App\Domain\Entity;

use App\Domain\ValueObject\OrderId;
use App\Domain\ValueObject\Money;
use App\Domain\Event\OrderCreatedEvent;

// Domain Entity: чистая бизнес-логика, никаких зависимостей от инфраструктуры
final class Order
{
    private string $status = 'pending';

    /** @var OrderCreatedEvent[] */
    private array $domainEvents = [];

    public function __construct(
        private readonly OrderId $id,
        private readonly string $customerId,
        private Money $totalAmount,
        private readonly \DateTimeImmutable $createdAt,
    ) {
        $this->domainEvents[] = new OrderCreatedEvent($this->id, $this->customerId);
    }

    public function confirm(): void
    {
        if ($this->status !== 'pending') {
            throw new \DomainException(
                sprintf('Cannot confirm order in status "%s"', $this->status)
            );
        }

        $this->status = 'confirmed';
    }

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

        $this->status = 'cancelled';
    }

    public function applyDiscount(int $percentage): void
    {
        if ($percentage < 0 || $percentage > 50) {
            throw new \DomainException('Discount must be between 0 and 50%');
        }

        $this->totalAmount = $this->totalAmount->multiply(1 - $percentage / 100);
    }

    public function getId(): OrderId { return $this->id; }
    public function getStatus(): string { return $this->status; }
    public function getTotalAmount(): Money { return $this->totalAmount; }

    /** @return OrderCreatedEvent[] */
    public function pullDomainEvents(): array
    {
        $events = $this->domainEvents;
        $this->domainEvents = [];
        return $events;
    }
}
### Input Port (Use Case Interface)
<?php

declare(strict_types=1);

namespace App\Application\Port;

use App\Application\DTO\CreateOrderCommand;
use App\Application\DTO\OrderResult;

// Input Port: определяет ЧТО приложение умеет делать
interface CreateOrderUseCaseInterface
{
    public function execute(CreateOrderCommand $command): OrderResult;
}
### Application Service (реализация Input Port)
<?php

declare(strict_types=1);

namespace App\Application\Service;

use App\Application\Port\CreateOrderUseCaseInterface;
use App\Application\DTO\CreateOrderCommand;
use App\Application\DTO\OrderResult;
use App\Domain\Entity\Order;
use App\Domain\Port\OrderRepositoryInterface;
use App\Domain\Port\EventDispatcherInterface;
use App\Domain\ValueObject\Money;

// Application Service: оркестрирует бизнес-логику
final readonly class CreateOrderService implements CreateOrderUseCaseInterface
{
    public function __construct(
        private OrderRepositoryInterface $orderRepository,
        private EventDispatcherInterface $eventDispatcher,
    ) {}

    public function execute(CreateOrderCommand $command): OrderResult
    {
        $orderId = $this->orderRepository->nextIdentity();

        $order = new Order(
            id: $orderId,
            customerId: $command->customerId,
            totalAmount: Money::fromAmount($command->amount, $command->currency),
            createdAt: new \DateTimeImmutable(),
        );

        if ($command->discountPercent > 0) {
            $order->applyDiscount($command->discountPercent);
        }

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

        // Publish domain events
        foreach ($order->pullDomainEvents() as $event) {
            $this->eventDispatcher->dispatch($event);
        }

        return new OrderResult(
            id: (string) $orderId,
            status: $order->getStatus(),
            amount: $order->getTotalAmount()->getAmount(),
        );
    }
}
### Primary Adapter (REST Controller)
<?php

declare(strict_types=1);

namespace App\Infrastructure\Adapter\Primary;

use App\Application\Port\CreateOrderUseCaseInterface;
use App\Application\DTO\CreateOrderCommand;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\HttpKernel\Attribute\MapRequestPayload;

// Primary Adapter: преобразует HTTP-запрос в команду для Use Case
final class OrderHttpAdapter
{
    public function __construct(
        private readonly CreateOrderUseCaseInterface $createOrder,
    ) {}

    #[Route('/api/orders', methods: ['POST'])]
    public function create(
        #[MapRequestPayload] CreateOrderCommand $command,
    ): JsonResponse {
        $result = $this->createOrder->execute($command);

        return new JsonResponse($result, Response::HTTP_CREATED);
    }
}
### Secondary Adapter (PostgreSQL Repository)
<?php

declare(strict_types=1);

namespace App\Infrastructure\Adapter\Secondary;

use App\Domain\Entity\Order;
use App\Domain\Port\OrderRepositoryInterface;
use App\Domain\ValueObject\OrderId;
use Doctrine\DBAL\Connection;

// Secondary Adapter: реализует Output Port через PostgreSQL
final readonly class PostgresOrderRepository implements OrderRepositoryInterface
{
    public function __construct(
        private Connection $connection,
    ) {}

    public function save(Order $order): void
    {
        $this->connection->executeStatement(
            'INSERT INTO orders (id, customer_id, total_amount, status, created_at)
             VALUES (:id, :customer_id, :amount, :status, :created_at)
             ON CONFLICT (id) DO UPDATE SET
                status = :status,
                total_amount = :amount',
            [
                'id' => (string) $order->getId(),
                'customer_id' => $order->getCustomerId(),
                'amount' => $order->getTotalAmount()->getAmount(),
                'status' => $order->getStatus(),
                'created_at' => $order->getCreatedAt()->format('c'),
            ]
        );
    }

    public function findById(OrderId $id): ?Order
    {
        $row = $this->connection->fetchAssociative(
            'SELECT * FROM orders WHERE id = :id',
            ['id' => (string) $id]
        );

        return $row ? $this->hydrate($row) : null;
    }

    public function findByCustomerId(string $customerId): array
    {
        $rows = $this->connection->fetchAllAssociative(
            'SELECT * FROM orders WHERE customer_id = :cid ORDER BY created_at DESC',
            ['cid' => $customerId]
        );

        return array_map($this->hydrate(...), $rows);
    }

    public function nextIdentity(): OrderId
    {
        return OrderId::generate();
    }

    private function hydrate(array $row): Order
    {
        // Reconstruct domain entity from database row
        return new Order(
            id: OrderId::fromString($row['id']),
            customerId: $row['customer_id'],
            totalAmount: Money::fromAmount((int) $row['total_amount'], 'RUB'),
            createdAt: new \DateTimeImmutable($row['created_at']),
        );
    }
}
## Hexagonal vs Clean Architecture
    HEXAGONAL                          CLEAN
    ┌─────────────┐              ┌─────────────────┐
    │  Adapters   │              │  Frameworks &   │
    │  ┌───────┐  │              │  Drivers        │
    │  │ Ports │  │              │  ┌───────────┐  │
    │  │ ┌───┐ │  │              │  │ Interface │  │
    │  │ │App│ │  │              │  │ Adapters  │  │
    │  │ └───┘ │  │              │  │ ┌───────┐ │  │
    │  └───────┘  │              │  │ │UseCases│ │  │
    └─────────────┘              │  │ │┌─────┐│ │  │
                                 │  │ ││Entit││ │  │
    2 уровня:                    │  │ │└─────┘│ │  │
    - Ports                      │  │ └───────┘ │  │
    - Adapters                   │  └───────────┘  │
                                 └─────────────────┘

                                 4 уровня:
                                 - Entities
                                 - Use Cases
                                 - Interface Adapters
                                 - Frameworks
Аспект Hexagonal Clean Architecture
Автор Alistair Cockburn (2005) Robert Martin (2012)
Фокус Изоляция от I/O Dependency Rule
Слои 2 (Core + Adapters) 4 (Entities, Use Cases, Adapters, Frameworks)
Порты Явные (Primary/Secondary) Неявные (через Dependency Inversion)
Use Cases Часть Application Core Отдельный слой
Гибкость Выше (меньше правил) Ниже (строгие правила слоёв)

Преимущества

Преимущество Описание
Тестируемость Вся бизнес-логика тестируется без БД, HTTP, файлов
Заменяемость Замена PostgreSQL на MongoDB -- только новый адаптер
Гибкость входа Один Use Case доступен через REST, CLI, очередь
Чистый домен Domain Model не содержит аннотаций ORM, HTTP-зависимостей
Независимость от фреймворка Можно мигрировать Symfony → Laravel, заменив только адаптеры

Недостатки

Недостаток Описание
Больше кода Интерфейсы + реализации для каждого порта
Сложность для новичков Труднее понять чем Layered
Overhead для простых CRUD Для findById → return json слишком много абстракций
Mapping Нужно преобразовывать DTO ↔ Entity ↔ DB Row

Кто использует

Компания Контекст
Netflix Многие микросервисы на Hexagonal
Spotify Backend-сервисы
Zalando E-commerce платформа
ThoughtWorks Рекомендуют для DDD-проектов

Структура каталогов

src/
├── Domain/                    # Ядро (нет зависимостей)
│   ├── Entity/
│   │   └── Order.php
│   ├── ValueObject/
│   │   ├── OrderId.php
│   │   └── Money.php
│   ├── Event/
│   │   └── OrderCreatedEvent.php
│   └── Port/                  # Output Ports
│       ├── OrderRepositoryInterface.php
│       └── EventDispatcherInterface.php
│
├── Application/               # Use Cases
│   ├── Port/                  # Input Ports
│   │   └── CreateOrderUseCaseInterface.php
│   ├── Service/
│   │   └── CreateOrderService.php
│   └── DTO/
│       ├── CreateOrderCommand.php
│       └── OrderResult.php
│
└── Infrastructure/            # Адаптеры
    └── Adapter/
        ├── Primary/           # Driving Adapters
        │   ├── OrderHttpAdapter.php
        │   └── OrderCliAdapter.php
        └── Secondary/         # Driven Adapters
            ├── PostgresOrderRepository.php
            └── SymfonyEventDispatcher.php

Проверь себя

В каком году была предложена Hexagonal Architecture и кем?

Чем Primary Adapter отличается от Secondary Adapter?

Какой главный недостаток Hexagonal Architecture для простых CRUD-приложений?

Почему Domain Entity в Hexagonal Architecture не должен содержать аннотации ORM?

Что такое 'порт' в Hexagonal Architecture?