Hexagonal (Ports & Adapters) и Onion

Общее

Гексагональная архитектура (Hexagonal Architecture, она же Ports & Adapters — «порты и адаптеры») и луковая архитектура (Onion Architecture) — два родственных архитектурных паттерна, объединённых одной идеей: бизнес-логика приложения образует ядро, полностью изолированное от внешнего мира, а всё остальное — пользовательский интерфейс, базы данных, очереди сообщений, сторонние сервисы — подключается к ядру снаружи, через строго определённые границы. Авторы паттернов — Алистер Коубёрн (Alistair Cockburn; эссе Hexagonal Architecture, каноническая версия датируется 2005 годом) и Джеффри Палермо (Jeffrey Palermo; цикл записей The Onion Architecture, 2008) — описали одну и ту же конструкцию в разных метафорах: Коубёрн изобразил приложение гексагоном с «портами», к которым «адаптеры» подключаются симметрично с любой стороны, Палермо — «луковицей» концентрических слоёв, сквозь которые все зависимости направлены строго внутрь. Механика у обоих одна: принцип инверсии зависимостей, применённый не к отдельным классам, а к границам всего приложения. Именно эта общая механика делает паттерны прямыми предшественниками Clean Architecture, а их названия в индустрии регулярно употребляются чуть ли не как синонимы.

Исторически оба паттерна — среднее звено долгой эволюции, начавшейся задолго до них. Идея «ядра и периферии» восходит к архитектуре BCE (Boundary-Control-Entity) Ивара Якобсона, описанной в книге Object-Oriented Software Engineering (1992): в любой системе Якобсон различал граничные объекты, общающиеся с внешним миром, и внутреннюю логику, которую эти объекты лишь обслуживают, — периферия не должна просачиваться в ядро. Классическая слоистая архитектура довела разделение ответственности до индустриального стандарта, но закрепила и врождённый дефект: домен в ней располагается над слоем доступа к данным и потому зависит от него — в бизнес-логику просачиваются схема базы, SQL и детали ORM. Hexagonal и Onion появились как прямой ответ на этот дефект: оба паттерна сохраняют идею слоёв, но переворачивают зависимость между ядром и инфраструктурой. Завершает линию Clean Architecture Роберта Мартина (пост The Clean Architecture, 2012; книга, 2017): Мартин честно называет Коубёрна и Палермо своими предшественниками и открывает канонический пост признанием, что все перечисленные подходы «суть вариации одной темы».

ВехаАвтор, годЧто добавила к идее «ядра и периферии»
BCEИвар Якобсон, 1992Граничные объекты отделены от логики управления и сущностей; периферия обслуживает ядро
Слоистая архитектураДейкстра, Парнас и традиция структурного проектирования, 1968–1990-еСлои как индустриальный стандарт; но домен оказался над доступом к данным и зависим от него
Hexagonal (Ports & Adapters)Алистер Коубёрн, 2005Порты и адаптеры; симметрия доставок; ядро одинаково вызывается из HTTP, CLI и теста
Onion ArchitectureДжеффри Палермо, 2008Концентрические кольца вокруг доменной модели; зависимости строго внутрь
DCIДжеймс Коплиен и Трюгве Реенскоуг, 2009Разделение «что система есть» (домен) и «что система делает» (сценарии)
Clean ArchitectureРоберт Мартин, 2012 / 2017Синтез: четыре кольца, правило зависимостей, связь с SOLID

Коубёрн — один из авторов Agile-манифеста и исследователь методологий разработки — пришёл к своему паттерну из практики консультирования: в проектах конца 1990-х он раз за разом видел одну и ту же болезнь, когда одну и ту же бизнес-логику должны были одинаково вызывать человек через графический интерфейс, другая система через интеграционный шлюз, пакетное задание по расписанию и автоматический тест, — а могла она, как правило, только та, под которую была написана. В эссе цель сформулирована прямо: архитектура должна допускать равное обращение с пользователями, программами, автоматическими тестами и пакетными заданиями и позволять разрабатывать и тестировать приложение в изоляции от его будущих устройств и баз данных. Терминология заимствована из мира операционных систем и железа: порт — по аналогии с портом ОС, логическая точка подключения с протоколом, определённым на стороне ядра; адаптер — по аналогии с адаптером периферийного устройства, переводящим внешний мир на язык этого протокола.

Сама шестиугольная форма — сознательная условность, о которой Коубёрн предупреждает сам: число шесть никакой нагрузки не несёт, важна лишь фигура «с более чем двумя сторонами». У многоугольника нет «верха» и «низа», поэтому диаграмма не подталкивает к иллюзии, будто интерфейс «главнее» базы данных или наоборот, — в отличие от прямоугольника слоёв, чья геометрия молча кодирует иерархию. Палермо же — американский .NET-архитектор и консультант — публиковал свой паттерн в 2008 году циклом из четырёх записей в блоге, и его исходная аудитория была конкретной: корпоративная разработка на C#, где к концу 2000-х стандартная слоистая схема выродилась в шаблон «доменная сущность = строка таблицы». Модели, обвешанные атрибутами ORM и знающие о соединениях с базой, не давали ни изолированно проверить бизнес-правила, ни сменить хранилище; ответ Палермо сохранил порты и адаптеры Коубёрна, но сместил акцент с равноправия внешних акторов на внутреннее устройство ядра — доменную модель с поведением в центре и всю инфраструктуру на внешнем контуре.

Общая механика обоих паттернов — инверсия зависимостей на уровне приложения, то есть Dependency Inversion Principle из принципов SOLID, поднятый с уровня классов до уровня архитектуры. Ядро объявляет интерфейсы своих потребностей — хранилище, уведомления, платежи — в собственных терминах; инфраструктура реализует эти интерфейсы и потому зависит от ядра, а не наоборот. Вызов в рантайме при этом вполне может идти наружу (сценарий сохраняет заказ в базу), но зависимость в исходном коде направлена внутрь. Из этой механики напрямую следует главный практический выигрыш обоих паттернов: ядро тестируется без базы данных и HTTP — на место адаптеров подставляются заглушки, тесты выполняются за доли секунды и проверяют именно бизнес-правила, а не склеивающий код фреймворка.

Второй общий тезис стоит зафиксировать отдельно, потому что он отличает оба паттерна от классической слоистой традиции: домен изолирован от инфраструктуры и доставки. Ядро не знает ни о существовании HTTP, ни о схеме хранения; смена базы данных, протокола или UI-фреймворка не является для него событием. Вся разница между Hexagonal и Onion — в том, на что каждый из них смотрит пристальнее: Коубёрн — на границу и симметрию внешних акторов, Палермо — на слоистое устройство самого ядра. Подробный разбор обоих ракурсов — ниже; их синтез в виде четырёх колец и правила зависимостей — в статье о Clean Architecture.

Стоит сразу снять и частую путаницу: оба паттерна — стиль организации кода, а не стиль развёртывания. Они ничего не говорят о том, монолитна система или распределена, и одинаково применяются внутри монолита и внутри каждого отдельного микросервиса; граница проходит не между процессами, а между частями кодовой базы. Это наследие слоистой традиции, описывавшей уровни одного приложения, — но перевёрнутое по направлению зависимостей.

Названия паттернов заслуживают отдельной оговорки, потому что регулярно сбивают с толку. «Гексагональная» не означает, что компонентов должно быть шесть или что приложение делится на шесть частей: форма многоугольника выбрана лишь для того, чтобы у схемы не было «верха» и «низа» — сам паттерн носит двойное имя Ports & Adapters, и оно точнее передаёт механику. «Луковая» отсылает к способу чистки луковицы: чтобы добраться до сердцевины, приходится снимать слои один за другим, и порядок этот не случаен.

В этой базе знаний паттерны рассматриваются как пара и как предыстория Clean Architecture. Читать их имеет смысл после слоистой архитектуры, от которой оба отталкиваются, и рядом с DDD: тактические паттерны Эванса — сущности, агрегаты, доменные сервисы — естественно наполняют ядро, описанное и Коубёрном, и Палермо, а сам словарь Палермо (Domain Model, Domain Services) прямо оттуда.

Hexagonal: порты и адаптеры

Механика паттерна

Гексагональная архитектура изображает приложение как многоугольник — традиционно шестиугольник, — внутри которого живут доменные объекты и сценарии приложения: всё, что система «есть» и «делает» по существу, без упоминания технологий доставки и хранения. Стены гексагона пронизаны портами — интерфейсами, определёнными в терминах ядра: каждый порт описывает либо сценарий, который можно исполнить, либо потребность ядра, которую нужно удовлетворить. Снаружи к портам подключаются адаптеры — классы, переводящие конкретную технологию в вызовы ядра и обратно: веб-фреймворк в вызов сценария, результат сценария в HTTP-ответ; SQL и ORM — в ответ на запрос хранилища.

   драйверы (primary)                                  управляемые адаптеры (secondary)

┌──────────────────────┐         ┌─────────────────┐         ┌────────────────────────┐
│ REST-контроллер      │         │                 │         │ PostgreSQL-репозиторий │
│ CLI-команда          │ primary │   Приложение    │secondary│ SMTP-шлюз              │
│ Консьюмер очереди    │──порт──▶│     и домен     │──порт──▶│ Издатель сообщений     │
│ Юнит-тест            │         │   («гексагон»)  │         │ Клиент стороннего API  │
└──────────────────────┘         └─────────────────┘         └────────────────────────┘

Стрелки на схеме показывают направление вызовов в рантайме: слева акторы запускают ядро, справа ядро обращается к внешнему миру. Направление зависимостей в исходном коде при этом одинаково у всех участников: от адаптеров — к ядру. Этот разрыв между потоком вызовов и потоком зависимостей и есть механизм изоляции, а обеспечивающая его конструкция — интерфейс (порт), принадлежащий ядру, и реализация (адаптер), зависящая от него.

Ключевая деталь, из которой следует всё остальное: порты принадлежат ядру. Интерфейс репозитория объявляет не инфраструктурный модуль, а сам сценарий, которому нужно хранилище; сигнатура порта описана в терминах ядра и меняется только вместе с ним. Инфраструктура подключается к порту, а не наоборот. В этом Hexagonal совпадает и с Onion, и с Clean Architecture — и принципиально отличается от распространённой имитации «интерфейса над драйвером БД», которая по форме абстракционна, а по существу привязана к базе: сигнатуры её методов дышат схемой хранения и живут в инфраструктурном модуле.

Первичные и вторичные порты

Первичные порты (primary ports; в исходном эссе — «левая сторона», в позднейшей литературе — driving) — интерфейсы, через которые внешний мир вызывает приложение. Это сценарии вроде PlaceOrder или CancelSubscription: их сигнатуры описаны на языке предметной области и не содержат ни HTTP-типов, ни SQL. Первичные адаптеры, или драйверы, — то, что эти порты запускает: REST-контроллер, gRPC-сервер, консольная команда, консьюмер сообщений из очереди, пакетное задание, автоматический тест. Равноправие драйверов — не риторика, а рабочее свойство: тест для Коубёрна — такой же полноправный драйвер приложения, как веб-сервер, и это прямо следует из мотивации паттерна.

Вторичные порты (secondary ports; «правая сторона», driven) — интерфейсы, через которые приложение само обращается к внешнему миру: репозиторий заказов, платёжный шлюз, нотификатор. Ядро объявляет их в своих терминах — IOrderRepository, а не OrdersTable — и не знает, кто стоит за реализацией. Вторичные адаптеры, или управляемые, выполняют обращение к конкретной технологии: PostgreSQL-репозиторий поверх ORM, SMTP-шлюз поверх почтового сервера, издатель сообщений поверх брокера, клиент поверх стороннего API.

СторонаПорт (в ядре)Адаптер (снаружи)Технология
PrimaryIPlaceOrderREST-контроллерASP.NET Core, Spring, Django
PrimaryIPlaceOrdergRPC-серверgRPC, protobuf
PrimaryIPlaceOrderКонсольная командаCLI-фреймворк
PrimaryIPlaceOrderКонсьюмер очередиKafka, RabbitMQ
PrimaryIPlaceOrderПакетное заданиеcron, планировщик
PrimaryIPlaceOrderАвтоматический тестxUnit, JUnit, pytest
SecondaryIOrderRepositoryРепозиторийPostgreSQL + ORM
SecondaryINotifierПочтовый шлюзSMTP
SecondaryIPaymentGatewayПлатёжный шлюзStripe, ЮKassa
SecondaryIEventPublisherИздатель событийKafka, RabbitMQ

Важно увидеть асимметрию вызовов при симметрии зависимостей. Слева актор вызывает ядро; справа ядро вызывает адаптер; но в обоих случаях зависимость в исходном коде направлена от адаптера к ядру: оба типа портов объявлены внутри гексагона, оба адаптера импортируют типы ядра. Сводно:

КритерийPrimary (driving)Secondary (driven)
Направление вызоваАктор вызывает ядроЯдро вызывает адаптер
Что описывает портСценарий приложенияПотребность ядра во внешнем мире
Пример портаIPlaceOrderIOrderRepository
Типичные адаптерыКонтроллер, CLI, тест, консьюмерРепозиторий, SMTP-шлюз, издатель сообщений
Направление зависимостиАдаптер → ядроАдаптер → ядро
Аналог в Clean ArchitectureController + Use CaseGateway + Frameworks & Drivers
Замена адаптера затрагиваетТолько доставкуТолько инфраструктуру

О двух практических решениях, которые принимает каждая команда, стоит сказать заранее, потому что на них спотыкаются чаще всего. Первое — зернистость портов: здравый смысл и Interface Segregation рекомендуют узкие порты под сценарий или конкретную потребность, а не один «широкий» сервисный интерфейс на всё приложение; широкие порты незаметно срастаются с единственным потребителем, и заменяемость, ради которой всё строилось, умирает. Второе — судьба доменных сущностей на границе: чем дальше от ядра, тем больше оснований передавать через порт простые структуры данных (request/response-модели), а не протаскивать наружу сами сущности — иначе любое внутреннее изменение модели начинает ломать внешних потребителей.

Пример: порт, сценарий, адаптеры

Миниатюрный пример на C# (стек выбран для преемственности со статьёй о Clean Architecture) показывает раскладку сил: ядро объявляет оба типа портов и реализует сценарий; всё технологичное остаётся за пределами гексагона.

// ── Ядро (внутри гексагона) ───────────────────────────────────

// Первичный порт: сценарий на языке домена, без HTTP и SQL
public interface IPlaceOrder
{
    PlaceOrderResult Execute(int customerId, IReadOnlyList<int> itemIds);
}

// Вторичный порт: потребность ядра во внешнем мире
public interface IOrderRepository
{
    Customer? FindCustomer(int id);
    void Save(Order order);
}

// Реализация сценария: знает только домен и порты
public class PlaceOrder : IPlaceOrder
{
    private readonly IOrderRepository _repository;

    public PlaceOrder(IOrderRepository repository) => _repository = repository;

    public PlaceOrderResult Execute(int customerId, IReadOnlyList<int> itemIds)
    {
        var customer = _repository.FindCustomer(customerId);
        if (customer is null)
            return PlaceOrderResult.Fail("customer not found");

        var order = customer.PlaceOrder(itemIds); // бизнес-правила — в домене
        _repository.Save(order);
        return PlaceOrderResult.Ok(order.Id);
    }
}
// ── Адаптеры (снаружи гексагона) ──────────────────────────────

// Драйвер: HTTP-запрос → вызов первичного порта
[HttpPost("/orders")]
public IActionResult PlaceOrder([FromBody] PlaceOrderDto dto)
{
    var result = _placeOrder.Execute(dto.CustomerId, dto.ItemIds);
    return result.Success ? Ok(new { orderId = result.OrderId })
                          : BadRequest(new { error = result.Error });
}

// Управляемый адаптер: вторичный порт поверх PostgreSQL
public class PgOrderRepository : IOrderRepository
{
    private readonly AppDbContext _db; // ORM, соединения, SQL — всё здесь

    public PgOrderRepository(AppDbContext db) => _db = db;

    public Customer? FindCustomer(int id) => _db.Customers.Find(id);
    public void Save(Order order) => _db.Orders.Add(order);
}

Направление зависимостей в примере однозначно: контроллер и PgOrderRepository импортируют типы ядра (IPlaceOrder, IOrderRepository, PlaceOrderResult), ядро не импортирует ничего внешнего. Компоновка — связывание портов с адаптерами — выполняется на самом краю, в точке сборки приложения (в простейшем случае — в Main), и ядро о ней не знает. В реальном проекте у одного первичного порта обычно несколько адаптеров (REST + CLI + тест), а у ядра — несколько вторичных портов (хранилище, платежи, события); всё это — ровно те сменяемые детали, ради которых паттерн и строится. Консольный драйвер того же порта выглядит так:

// Ещё один драйвер того же первичного порта: консольная команда
public class PlaceOrderCommand : ConsoleCommand
{
    private readonly IPlaceOrder _placeOrder;

    public PlaceOrderCommand(IPlaceOrder placeOrder) => _placeOrder = placeOrder;

    public int Invoke(int customerId, int[] itemIds)   // ядро не менялось
    {
        var result = _placeOrder.Execute(customerId, itemIds);
        return result.Success ? 0 : 1;
    }
}

Задача Коубёрна и симметрия сторон

Задача, которую решал Коубёрн, формулировалась из боли. Приложение конца 1990-х жёстко связывало логику со способом её доставки: бизнес-правила жили в обработчиках кнопок и HTTP-хендлерах, и добавить вторую доставку — консольную утилиту для оператора, интеграцию с другой системой, автотест — означало продублировать логику или выкапывать её из недр интерфейса. Гексагон делает ядро доставко-агностичным: один и тот же PlaceOrder запускается HTTP-запросом, консольной командой, сообщением из очереди и юнит-тестом — меняется только адаптер, ядро не замечает подмены. Приложение, по Коубёрну, вообще не знает и не должно знать, кто и что его вызывает.

Тест заслуживает отдельного акцента, потому что для Коубёрна он — не довесок, а полноправный драйвер:

// Юнит-тест как драйвер: ядро без базы данных и веб-сервера
[Fact]
public void PlaceOrder_saves_order_for_valid_customer()
{
    var repository = new FakeOrderRepository();   // заглушка вторичного порта
    var useCase = new PlaceOrder(repository);

    var result = useCase.Execute(42, new[] { 1, 2, 3 });

    Assert.True(result.Success);
    Assert.Single(repository.Saved);
}

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

Центральное прозрение Коубёрна — симметрия: пользовательский интерфейс и база данных — равноправные адаптеры по разные стороны ядра. В классической слоистой диаграмме UI расположен «сверху», база — «снизу», и эта геометрия неявно кодирует и иерархию важности, и направление интеграции: логика пишется «под интерфейс», поверх «базы». Гексагон убирает обе иллюзии: у сторон многоугольника нет верха и низа, адаптеры равны по определению. Практическое следствие — дешевизна расширения: добавить gRPC-фронтенд рядом с REST, вторую очередь рядом с первой или батч-задание рядом с вебом — значит добавить ещё один адаптер, не тронув ядро; база данных перестаёт быть фундаментом, на котором стоит всё, и становится периферийным устройством, подключённым через порт.

Onion: слои-луковицы

Механика: кольца вокруг домена

Луковая архитектура изображает то же самое приложение концентрическими кольцами. В центре — Domain Model: сущности и агрегаты с поведением, инварианты бизнес-правил — самый стабильный и ценный код системы. Следующее кольцо — Domain Services: операции над несколькими сущностями, не помещающиеся целиком ни в одну из них. Вокруг — Application Services: сценарии и оркестрация, точка, где ядро объявляет интерфейсы инфраструктуры. Внешний контур — вся Infrastructure (ORM, СУБД, очереди, сторонние API), UI и даже Tests: всё изменчивое и технологичное. Единственное правило то же, что позже сформулирует Мартин: зависимости направлены строго внутрь — каждое кольцо может ссылаться только на кольца ближе к центру.

┌────────────────────────────────────────────────────────────────┐
│  Outer: Infrastructure, UI, Tests, Externalized Interfaces     │
│  (ORM, СУБД, брокеры сообщений, почта, сторонние API)          │
│                                                                │
│   ┌────────────────────────────────────────────────────────┐   │
│   │  Application Services: сценарии и оркестрация          │   │
│   │  (объявляют интерфейсы инфраструктуры)                 │   │
│   │                                                        │   │
│   │   ┌────────────────────────────────────────────────┐   │   │
│   │   │  Domain Services: операции над моделью         │   │   │
│   │   │  (интерфейсы репозиториев и внешних сервисов)  │   │   │
│   │   │                                                │   │   │
│   │   │   ┌────────────────────────────────────────┐   │   │   │
│   │   │   │              Domain Model              │   │   │   │
│   │   │   │    (сущности, агрегаты, инварианты)    │   │   │   │
│   │   │   └────────────────────────────────────────┘   │   │   │
│   │   │                                                │   │   │
│   │   └────────────────────────────────────────────────┘   │   │
│   │                                                        │   │
│   └────────────────────────────────────────────────────────┘   │
└────────────────────────────────────────────────────────────────┘
              все зависимости направлены строго внутрь ──▶
КольцоСодержимоеКто может ссылаться
Domain ModelСущности, агрегаты, объекты-значения, инвариантыDomain Services, Application Services, внешний контур
Domain ServicesОперации над несколькими сущностями доменаApplication Services, внешний контур
Application ServicesСценарии, оркестрация, объявление интерфейсов инфраструктурыВнешний контур
Внешний контурORM, СУБД, очереди, UI, тесты, сторонние APIНикто из внутренних колец

Правила Палермо

Из цикла 2008 года выводится компактный набор правил, которые Палермо формулирует для .NET-мира, но которые переносятся на любой стек. Приложение строится вокруг объектной модели с поведением: центр системы — доменные объекты с методами и инвариантами, а не «сущности-строки таблиц», обслуживаемые процедурами сервисного слоя. Внутренние кольца объявляют интерфейсы, внешние их реализуют: направление реализации всегда от периферии к центру, сцепление (coupling) направлено к центру — формулировка Палермо. Код внутренних колец не ссылается на внешние ни в каком виде: домен не импортирует ни ORM, ни типы веб-фреймворка, ни конфигурацию инфраструктуры. Наконец, ядро собирается отдельно от инфраструктуры: в .NET это отдельная сборка, физически не ссылающаяся на сборки данных и интерфейса, — нарушение правила ловится компилятором, а не только код-ревью.

Из правил следуют два важных уточнения. Первое: инфраструктура в Onion — не «нижний слой», а внешний контур; база данных, почта, брокеры и UI находятся на одном расстоянии от центра и в равной мере являются деталями — та же симметрия, которую Коубёрн выражал формой гексагона. Второе: тесты живут во внешнем контуре, но тестируют центр напрямую — без базы данных и веб-сервера; Палермо прямо опирается на инверсию зависимостей и IoC-контейнеры как рабочий инструмент склейки колец. По сути Onion — это порты и адаптеры Коубёрна, переложенные на язык слоёв и сборок, с добавленной онтологией самого ядра.

Физическая изоляция ядра — не .NET-эксклюзив: тот же приём работает в любом стеке, где есть модули или пакеты, — отдельный Maven-модуль в Java, отдельный пакет в TypeScript, отдельная библиотека в Go. Там, где границы сборок недоступны или неудобны, направление зависимостей проверяют статическими анализаторами — ArchUnit и NetArchTest для JVM и .NET, dependency-cruiser для TypeScript, import-linter для Python: правило «домен не импортирует инфраструктуру» превращается в автоматическую проверку в CI, а не в предмет памяти и код-ревью.

Отличия акцентов от Hexagonal

Механически Onion наследует Hexagonal всё: и порты с адаптерами, и инверсию зависимостей, и тестируемость без инфраструктуры. Различие — в фокусе. Гексагональная архитектура сосредоточена на границе и симметрии внешних акторов; внутреннее устройство гексагона паттерн не регламентирует — домен и сценарии могут лежать внутри как угодно. Луковая архитектура, наоборот, детализирует именно ядро: Domain Model → Domain Services → Application Services — явная слоистая модель домена, заимствованная из словаря DDD Эрика Эванса. Слои Палермо отвечают на вопрос «как устроено ядро», порты Коубёрна — «как ядро общается с миром»; зрелая реализация отвечает на оба вопроса сразу.

Соответствие словарей Onion и DDD почти взаимное, что и сделало связку «тактические паттерны DDD внутри луковицы» канонической в .NET-сообществе:

Элемент OnionЭлемент DDDПример
Domain ModelСущности, агрегаты, объекты-значенияOrder, Customer с инвариантами
Domain ServicesДоменные сервисыPricingService над несколькими агрегатами
Application ServicesПрикладной слойОркестрация сценария PlaceOrder
Интерфейсы инфраструктурыРепозитории, шлюзыIOrderRepository, IPaymentGateway
Внешний контурИнфраструктураРеализации репозиториев, брокеры, UI

Есть и различие происхождения, объясняющее акценты. Hexagonal вырос из задачи множественных доставок и интеграций — одно ядро, много способов вызова; Onion — из боли корпоративной .NET-разработки, где домен растворялся в ORM и «сущностях-таблицах», и главным лекарством была физическая изоляция ядра в отдельную сборку. Отсюда и историческая география популярности: Onion прижился прежде всего в экосистеме .NET, Hexagonal — в Java/JVM-сообществе и интеграционных проектах; к концу 2010-х оба растворились в общем потоке, который Мартин оформил как Clean Architecture.

Hexagonal vs Onion vs Clean

На уровне механизма все три подхода совпадают: домен в центре, детали снаружи, зависимости внутрь. Спорить «Hexagonal или Clean» бессмысленно — это вопрос словаря и акцентов, а не дизайна. Таблица ниже сводит различия; подробный разбор колец и правила зависимостей Clean Architecture — в соответствующей статье.

Hexagonal (Ports & Adapters)Onion ArchitectureClean Architecture
АвторАлистер КоубёрнДжеффри ПалермоРоберт Мартин
Год200520082012 (пост) / 2017 (книга)
Ключевая метафораГексагон с портами по сторонамЛуковица концентрических слоёвКонцентрические кольца
Механизм изоляцииПорты (интерфейсы ядра) + адаптеры снаружиЗависимости строго внутрь; ядро — отдельная сборкаПравило зависимостей + boundary-интерфейсы
Устройство ядраНе регламентируется: приложение + доменDomain Model → Domain Services → Application ServicesEntities → Use Cases → Interface Adapters
Отношение к фреймворкам и БДРавноправные адаптеры, деталиВнешний контур; домен их не видит«Детали»; самое внешнее кольцо
Симметрия UI и БДЯвная: обе стороны гексагонаСлабее: акцент на домене, а не на сторонахКольца без «верха» и «низа»
Историческая среда примененияКонсалтинг, Java/JVM, интеграцииКорпоративный .NET (ORM, IoC)Вся индустрия, включая Android
Связь с принципамиСимметрия интерфейсов, тестируемостьПрямая опора на DIP и IoCПродолжение SOLID на уровень приложения
Канонический текстЭссе Hexagonal ArchitectureЦикл записей The Onion ArchitectureПост The Clean Architecture, книга Clean Architecture

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

РольHexagonalOnionClean Architecture
Бизнес-правилаЯдро гексагона (домен)Domain Model + Domain ServicesEntities
Сценарий приложенияПервичный портApplication ServiceUse Case (Interactor)
Доступ к даннымВторичный порт + адаптерИнтерфейс в ядре, реализация в InfrastructureGateway (Interface Adapters → Frameworks)
REST-точка входаПервичный адаптерВнешний контур (UI)Controller (Interface Adapters)
ORM и СУБДВторичный адаптерInfrastructureFrameworks & Drivers
КомпоновкаТочка сборки приложенияIoC-контейнер / Composition RootMain

Чтение таблиц подтверждает: различия лежат в словаре и акцентах, а не в механике. Hexagonal даёт словарь для границ (порты и адаптеры) и культ равноправия доставок; Onion — словарь для ядра (кольца домена) и дисциплину физических сборок; Clean Architecture упаковывает и то и другое в четыре кольца с единым правилом зависимостей и связывает с SOLID, превращая набор приёмов в систему принципов. Общий стержень всех трёх — инверсия зависимостей: без DIP «зависимости внутрь» технически невозможны, бизнес-логика неизбежно импортирует драйвер БД. Показательно, что сам Мартин открывает канонический пост перечислением предшественников — Hexagonal и Onion входят в этот список наряду с BCE Якобсона и DCI. Инженер Херберту Граса (Herberto Graça) в известном цикле статей о наведении порядка в архитектурах показал, что перечисленные подходы не конкурируют, а складываются в одну схему: DDD поставляет модель, порты и адаптеры — механику границ, Onion и Clean — организацию слоёв.

Практический вывод — тот же, к которому приходит статья о Clean Architecture: команда выбирает один словарь, фиксирует его в архитектурных решениях (ADR) и не смешивает терминологии в одном проекте. Смешение («порт», «кольцо», «интерактор» и «шлюз» в одном кодстайле) не ломает механику, но дорого обходится онбордингу и ревью: люди спорят о словах, имея в виду одну и ту же конструкцию.

Плюсы

Тестируемость без инфраструктуры. Главный и самый ощутимый выигрыш. Ядро проверяется чистыми юнит-тестами: на место адаптеров подставляются заглушки, ни СУБД, ни веб-сервера, ни сети; тесты быстрые, детерминированные и не «мигают» из-за окружения. Для Коубёрна тест — первоклассный драйвер, поэтому регрессионная сетка естественным образом строится там, где ценность выше всего, — вокруг бизнес-правил.

Заменяемость адаптеров и деталей. Смена СУБД, протокола, почтового сервиса или ORM затрагивает один адаптер и точку сборки, не трогая ядро. Добавление новой доставки — CLI рядом с HTTP, gRPC рядом с REST, обработчик очереди — тоже локальная операция. Полные замены базы случаются реже, чем обещают лекции, но частичные операции того же класса происходят постоянно, и именно их стоимость паттерны радикально снижают.

Независимость от фреймворков. Фреймворк — подключённая библиотека на периферии, а не каркас, в который вписан код: Spring, ASP.NET или Django обслуживают адаптеры, но не определяют устройство ядра. Это устраняет vendor lock-in на уровне кода и защищает от главного риска экосистем — вынужденных миграций из-за окончания поддержки.

Явные границы. Порты — это явный, документированный контракт ядра: что система делает (первичные порты) и что ей нужно от мира (вторичные). Направление зависимостей проверяется статически — сборками, ArchUnit, dependency-cruiser — и нарушение ловится автоматически, а не обнаруживается через три года. Граница, которую можно проверить, живёт дольше границы, о которой просто помнят.

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

Предсказуемая структура и дешёвый онбординг. Соглашение о портах и кольцах интернационально: разработчик, знакомый с паттернами, открывает любой построенный на них проект — от бэкенда на Java до мобильного приложения — и мгновенно знает, где бизнес-правила, где сценарии, где клеящий код. Общий словарь снижает bus-factor, а спор о структуре превращается в проверяемый аргумент: «это нарушение направления зависимостей» — факт, а не вопрос вкуса.

Долгоживущий домен и отложенные решения. Ядро, изолированное от деталей, переносится между платформами, переиспользуется несколькими приложениями предприятия и десятилетиями накапливает ценность, вместо того чтобы растворяться в магии фреймворка конкретного года. Систему можно начинать строить, ещё не выбрав окончательно СУБД и способ доставки: внутренние кольца не зависят от этих решений, и их можно принять позже, когда появится информация.

Минусы

Стоимость входа. Паттерны требуют инженерной зрелости: понимания инверсии зависимостей и DI, привычки писать тесты с заглушками, дисциплины границ. Команда без этого фундамента получит дорогую имитацию — те же связи, только разнесённые по папкам и украшенные интерфейсами.

Бойлерплейт и маппинг. Интерфейсы портов, DTO, мапперы, точка сборки — постоянная плата за изоляцию. Типичная болезнь первых месяцев — «интерфейс ради интерфейса»: абстракция заводится на каждый класс без осмысленной причины замены и становится чистой ценой без выгоды. В простых системах маппинг легко съедает больше кода, чем бизнес-логика, ради которой всё затевалось.

Избыточность для простых CRUD. Подавляющее большинство приложений — формы над базой данных без сколько-нибудь сложных правил. Для них гексагон и луковица — пустой ритуал: сценарий без правил — транзит данных, порт к единственной таблице — лишняя косвенность. Честный controller → service → repository решает ту же задачу в разы дешевле.

Риск карго-культа. Популярность паттернов — особенно после успеха Clean Architecture — породила массовое копирование формы без содержания: папки domain/data/presentation без правила зависимостей, «гексагон» как логотип в README. Без автоматической проверки направления зависимостей структура деградирует за пару спринтов в распределённый по папкам большой шар грязи — хуже честного монолита, потому что создаёт иллюзию порядка.

Словарная путаница. Порты, адаптеры, кольца, слои, интеракторы, шлюзы — три словаря одной идеи, и команды регулярно тратят споры на «правильный» термин, смешивая концепции из разных статей. Лекарство простое: выбрать один словарь и зафиксировать его в архитектурных решениях, а спорить о дизайне, а не о словах.

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

Когда применять

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

Много доставок и интеграций. Конкретные видимые причины: логика, вызываемая одновременно из HTTP, CLI и очереди; несколько внешних систем вокруг ядра (платежи, документы, нотификации); несколько интерфейсов доступа к одной логике — web, API, партнёрский шлюз. Каждая доставка и каждая интеграция — это адаптер; ядро при этом остаётся одним.

Долгоживущий продукт. Горизонт жизни в годы и десятилетия почти гарантирует ротацию технологий доставки и хранения: UI-фреймворки приходят и уходят каждые несколько лет, ORM и версии платформ — ещё чаще. Если код переживёт эту ротацию, изоляция ядра окупается с процентами; для одноразового кода — прототипов, демо, временных лендингов — платить за ротацию вперёд бессмысленно.

Регрессионная защита бизнес-правил. Если ошибка в логике стоит дорого — финансы, медицина, договорные обязательства, — быстрый детерминированный юнит-тест на ядро остаётся самым дешёвым способом получить плотную сетку тестов именно на правила, а не на инфраструктуру вокруг них.

Организационная готовность. Честный критерий: паттерны требуют от команды базовых навыков — инверсии зависимостей, DI, тестов с заглушками. Если их нет, сначала выгоднее вырастить культуру на простой структуре, а границы вводить, когда команда к ним готова.

Когда избыточны. Зеркальный список: CRUD-приложения и админки без правил; прототипы и MVP, чья судьба — быть выброшенными; системы с горизонтом жизни в месяцы; маленькие команды на простом домене без перспективы роста сложности. Число слоёв и портов должно соответствовать числу разных причин изменения, а не эстетике диаграммы.

Применять частично — нормальная практика. Оба паттерна допускают эволюционное внедрение: сначала изолировать только самое ценное ядро расчётов, оставив CRUD-обвязку «грязной»; границы дорезать по мере роста боли. Управлять этим процессом удобно в рамке эволюционной архитектуры: границы проводятся там, где изменчивость доказана, и удерживаются fitness-функциями в CI.

Связанные статьи

  • Слоистая архитектура (Layered / N-tier) — исторический предшественник: те же слои, но с зависимостями сверху вниз; Hexagonal и Onion переворачивают зависимость между доменом и инфраструктурой.
  • SOLID — пять принципов проектирования; Dependency Inversion — механизм, на котором держатся оба паттерна.
  • Clean Architecture — синтез обоих подходов: четыре кольца, правило зависимостей, связь с SOLID.
  • Domain-Driven Design (DDD) — содержание ядра: тактические паттерны DDD естественно живут в Domain Model и в словаре Палермо.
  • CQRS и Event Sourcing — паттерны организации данных, часто применяемые поверх гексагонального ядра.
  • Эволюционная архитектура — управление изменениями: частичное внедрение границ и удержание их fitness-функциями.