Новое в журнале. Маленькие pull request: почему их быстрее ревьюят и как резать задачу
Принципы и архитектура

Гексагональная архитектура в PHP: порты, адаптеры и когда она нужна

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

14 апреля 202611 мин чтенияПродвинутым
Гексагональная архитектура в PHP: порты, адаптеры и когда она нужна
2 стороныпорты входа и выхода
1 правилозависимости смотрят внутрь

Гексагональную архитектуру, она же «порты и адаптеры», предложил Алистер Коберн как способ описать систему, где ядро с бизнес-логикой не зависит от конкретных технических деталей: веб-фреймворка, базы данных, очереди сообщений, внешнего API. Шестиугольник на диаграммах не несёт особого смысла, кроме наглядности: он просто показывает, что у домена может быть несколько равноправных сторон взаимодействия с внешним миром, а не одна линия «фронтенд сверху, база данных снизу».

Домен, порты и адаптеры

В центре архитектуры лежит домен: классы и сервисы, которые реализуют бизнес-правила проекта и не содержат ни строчки, завязанной на HTTP, SQL или конкретный транспорт. Домен общается с внешним миром через порты, интерфейсы, которые описывают, что домену нужно от внешней среды или что домен предоставляет снаружи, но не как это реализовано технически.

Адаптеры реализуют порты конкретными технологиями. Один и тот же порт OrderRepository может иметь адаптер на MySQL для продакшена и адаптер в памяти для тестов, и домен не замечает разницы, потому что работает только с интерфейсом. Порты делятся на входящие, через которые внешний мир обращается к домену, например контроллер HTTP или консольная команда, и исходящие, через которые домен обращается во внешний мир, например к базе данных или к внешнему платёжному API.

// Домен: порт и бизнес-логика без технических деталей
namespace App\Domain\Order;

interface OrderRepository
{
    public function save(Order $order): void;
    public function findById(string $id): ?Order;
}

final class PlaceOrderUseCase
{
    public function __construct(private readonly OrderRepository $orders) {}

    public function execute(OrderPayload $payload): Order
    {
        $order = Order::create($payload);
        $this->orders->save($order);

        return $order;
    }
}

Класс PlaceOrderUseCase описывает сценарий «разместить заказ» целиком на уровне бизнес-логики и ничего не знает о том, как заказ попадёт в хранилище. Это и есть главная практическая выгода архитектуры: сценарий можно читать и тестировать как чистый бизнес-процесс, без поднятия базы данных, HTTP-сервера или очереди сообщений.

Главная мысльГексагональная архитектура не про шестиугольник на диаграмме, а про то, что бизнес-логика не знает о существовании базы данных, HTTP и очереди сообщений.

Адаптер со стороны хранения

Адаптер для MySQL реализует интерфейс порта и инкапсулирует всё, что связано с SQL-запросами, маппингом строк в объекты домена и управлением соединением. Он находится во внешнем слое приложения и зависит от домена, а не наоборот: доменный интерфейс не знает об адаптере, но адаптер обязан соответствовать контракту, который объявил домен.

namespace App\Infrastructure\Persistence;

use App\Domain\Order\Order;
use App\Domain\Order\OrderRepository;

final class MySqlOrderRepository implements OrderRepository
{
    public function __construct(private readonly \PDO $connection) {}

    public function save(Order $order): void
    {
        $stmt = $this->connection->prepare(
            'INSERT INTO orders (id, status, total) VALUES (:id, :status, :total)'
        );
        $stmt->execute([
            'id' => $order->id(),
            'status' => $order->status()->value,
            'total' => $order->total(),
        ]);
    }

    public function findById(string $id): ?Order { /* ... */ }
}

Замена MySQL на PostgreSQL или на внешний сервис затрагивает только этот класс и файл конфигурации внедрения зависимостей. Ни контроллеры, ни бизнес-логика, ни тесты сценариев не требуют правок, потому что все они работают через интерфейс OrderRepository, а не через конкретную реализацию хранения.

Правило направления зависимостей

Ключевое правило гексагональной архитектуры простое по формулировке и строгое на практике: зависимости всегда направлены внутрь, от адаптеров к домену, и никогда наоборот. Домен не импортирует ничего из слоя инфраструктуры, не содержит атрибутов конкретного ORM и не знает, какой фреймворк обслуживает HTTP-запросы снаружи.

СлойЧто внутриНа что может ссылаться
ДоменБизнес-правила, сущности, порты (интерфейсы)Ни на что внешнее, только на себя
Приложение / сценарииОркестрация доменных операцийНа домен и его порты
АдаптерыHTTP-контроллеры, репозитории БД, клиенты внешних APIНа домен и порты, реализуя их контракты

Нарушение этого правила легко не заметить на старте: достаточно один раз использовать атрибут Doctrine прямо в доменном классе или вызвать $_SERVER внутри бизнес-правила, и граница начинает размываться. Через несколько месяцев такой практики домен оказывается незаметно сросшимся с конкретным фреймворком, и вынести бизнес-логику в консольный скрипт или в очередь сообщений без HTTP-контекста становится почти невозможно без переписывания.

Простая проверка на практике: может ли юнит-тест сценария выполниться без поднятой базы данных, без HTTP-клиента и без контейнера зависимостей фреймворка? Если нет, граница между доменом и инфраструктурой где-то нарушена.
Вы в начале разбораДальше: разбор компромиссов, чек-лист и ответы на частые вопросы.

Входящие порты: не только HTTP

Полезное следствие архитектуры в том, что один и тот же сценарий домена можно вызвать из разных входящих адаптеров без дублирования логики: из HTTP-контроллера веб-приложения, из консольной команды для пакетной обработки, из обработчика сообщения очереди. Все три адаптера просто вызывают один и тот же PlaceOrderUseCase с нужными данными, а бизнес-правило размещения заказа существует в коде единожды.

Это снимает классическую проблему, когда одно и то же правило валидации или расчёта реализовано отдельно в контроллере API и отдельно в консольной команде импорта, и со временем они расходятся, потому что правили только одну из копий. В гексагональной архитектуре обе точки входа ведут к одному доменному сценарию, и расхождение становится структурно невозможным.

Когда архитектура оправдана, а когда избыточна

Полная гексагональная архитектура с явным разделением на домен, приложение и адаптеры добавляет заметный объём кода: интерфейсы портов, отдельные namespace для каждого слоя, фабрики для сборки объектов без контейнера фреймворка внутри домена. Для небольшого внутреннего сервиса с одним потребителем и единственной базой данных, которая не планирует меняться, эта стоимость обычно не окупается, и обычный слоистый MVC-подход фреймворка работает быстрее и понятнее новому разработчику.

  • Проект с несколькими точками входа в одну и ту же бизнес-логику (API, консоль, очередь) выигрывает от явного разделения домена и адаптеров
  • Проект, где база данных или внешний провайдер реально менялись хотя бы раз за историю, получает ощутимую экономию от портов
  • Небольшой CRUD-сервис с одной точкой входа и стабильным стеком технологий обычно не окупает стоимость полного разделения слоёв
  • Команда из одного-двух разработчиков должна соотносить объём инфраструктуры архитектуры с реальным размером бизнес-логики, которую она защищает

Разумный средний путь, который часто встречается в PHP-проектах на Symfony или Laravel: не выносить вообще всё в отдельные пакеты по канону, а последовательно применять только главное правило, направление зависимостей, оставляя контроллеры фреймворка тонкими точками входа, а бизнес-логику в отдельных классах-сервисах без атрибутов ORM внутри. Это даёт большую часть практической выгоды архитектуры без полного переписывания структуры проекта под канонические три слоя.

Тестирование сценариев без инфраструктуры

Практическая выгода от разделения на домен и адаптеры заметнее всего именно в тестах. Сценарий PlaceOrderUseCase можно проверить юнит-тестом, подставив вместо настоящего MySqlOrderRepository простую реализацию порта в памяти, без поднятия контейнера с базой данных и без ожидания сетевых запросов. Такой тест выполняется за миллисекунды и остаётся стабильным независимо от состояния внешней инфраструктуры.

final class InMemoryOrderRepository implements OrderRepository
{
    private array $orders = [];

    public function save(Order $order): void
    {
        $this->orders[$order->id()] = $order;
    }

    public function findById(string $id): ?Order
    {
        return $this->orders[$id] ?? null;
    }
}

$useCase = new PlaceOrderUseCase(new InMemoryOrderRepository());
$order = $useCase->execute($payload);

Эта же реализация в памяти пригодна и для интеграционных тестов приложения целиком, если подменить реальный адаптер в контейнере зависимостей только на время тестового прогона. Команда получает быстрые тесты бизнес-логики без необходимости держать тестовую базу данных для каждого сценария, а для действительно важных случаев, например проверки самого SQL-запроса, отдельно пишутся более медленные тесты конкретно адаптера хранения.

Сборка объектов без контейнера внутри домена

Частая ошибка при внедрении архитектуры: протащить контейнер зависимостей фреймворка внутрь доменных классов, чтобы они сами могли «достать» нужный сервис. Это разворачивает направление зависимости в обратную сторону и фактически делает домен зависимым от конкретного фреймворка, даже если формально интерфейсы портов остаются чистыми. Правильное место для сборки объектов, конфигурация контейнера или фабрика, находится во внешнем слое приложения, а не внутри домена.

На практике это означает, что доменные классы получают все зависимости явно через конструктор, а решение о том, какая конкретная реализация порта будет передана, принимается один раз на границе приложения, обычно в конфигурации контейнера фреймворка или во входной точке скрипта. Домен никогда не спрашивает контейнер напрямую, он просто получает готовые объекты через свой собственный конструктор.

Частые вопросы

Чем порт отличается от адаптера в гексагональной архитектуре?

Порт это интерфейс, который описывает, что домену нужно от внешнего мира или что домен предоставляет снаружи. Адаптер это конкретная реализация порта под определённую технологию, например MySQL или HTTP.

Можно ли использовать гексагональную архитектуру в обычном Laravel или Symfony проекте?

Да, частично. Можно оставить контроллеры фреймворка тонкими точками входа и перенести бизнес-логику в отдельные сервисы без зависимостей от ORM и HTTP, не вынося всё в отдельные пакеты по канону.

Для каких проектов гексагональная архитектура избыточна?

Для небольших сервисов с одной точкой входа и стабильным технологическим стеком, где база данных или внешние интеграции не менялись и менять их не планируется. Стоимость разделения слоёв в таком случае обычно не окупается.

Код, который не стыдно передать

Начните с принципов, а дальше двигайтесь по рубрикам. Каждый разбор заканчивается чек-листом.

Случайный разбор