← назад к разделу

Hexagonal Architecture даёт сильные гарантии: бизнес-логика без зависимостей на chi, pgx или kafka-go; port-интерфейсы, которые изолируют инфраструктуру; тест в CI, который не пустит pgx-импорт в ядро. Но у этих гарантий есть цена — дополнительные пакеты, mapper-структуры между слоями и отдельный тест на импорты. Цена окупается только при определённых условиях.

Три уровня сложности Go-сервиса

Не каждый сервис нуждается в одной и той же структуре. В Go-проектах обычно выделяют три уровня зрелости:

Уровень 1 — UseCase, Handler и chi-роутер живут в одном пакете internal/<bc>/. Нет явных агрегатов, нет отдельных port-интерфейсов. Подходит для CRUD-сервисов, прототипов и первых итераций.

Уровень 2internal/<bc>/ разбит на отдельные пакеты aggregate/, usecase/ и repository/. Домен выделен, но отдельных пакетов adapter/in/ и adapter/out/ нет; инфраструктура соседствует с доменом.

Уровень 3 — Hexagonal. Пакеты internal/core/<bc>/, internal/adapter/in/*, internal/adapter/out/*, bootstrap/. Архитектурный тест в CI. Именно об этом уровне данная статья.

Перевести сервис первого уровня на Hexagonal — значит добавить 4–5 пакетов, 3–4 mapper-структуры и CI-тест ради того, что и так держится устной договорённостью. Затраты перевесят выгоду.

Когда Hexagonal оправдана

Вот пять сигналов, при которых структура начинает давать измеримый выигрыш:

1. Сервис интегрируется с двумя и более внешними системами. Типичный сетап — Postgres (sqlc + pgx) плюс ещё один или два внешних сервиса плюс Kafka. У каждой системы свои типы данных, свои таймауты, свои режимы падения. Без явных пакетов adapter/out/sber/, adapter/out/odna_kassa/ весь IO-код накапливается в одном OrderService, который становится трудно тестировать и читать.

2. Богатый агрегат с бизнес-правилами. Когда метод Order.Confirm(payment PaymentResult) error проверяет пять условий, обновляет статус и фиксирует результат — этот код хочется тестировать без Postgres. Чистый core/order/aggregate/ без зависимостей на pgx позволяет гонять unit-тесты агрегата за миллисекунды:

// core/order/aggregate/order_test.go
func TestConfirm_InsufficientPayment(t *testing.T) {
    order := newPendingOrder(Money{Amount: 5000, Currency: "RUB"})
    result := PaymentResult{Amount: Money{Amount: 3000, Currency: "RUB"}}
    err := order.Confirm(result)
    var e *InsufficientPaymentError
    require.ErrorAs(t, err, &e)
}

3. Несколько типов входа для одного домена. chi-роутер для клиента, chi-роутер для администратора, Kafka-consumer для событий от другого сервиса. Каждый тип входа — свой пакет со своим middleware-стеком:

// adapter/in/http/user/router.go   — JWT-аутентификация клиента
// adapter/in/http/admin/router.go  — mTLS для внутреннего API
// adapter/in/kafka/order_events.go — consumer без HTTP-аутентификации

Без разделения middleware аутентификации начинают пересекаться.

4. Тест ядра требует поднять реальный Postgres. Если handler в core/ напрямую импортирует adapter/out/persistence/ или sqlc-сгенерированные типы, тест тянет за собой весь pgx-стек. Hexagonal с port-интерфейсом OrderRepository в core/ позволяет подменить адаптер на in-memory реализацию.

5. Команда из трёх и более разработчиков. Архитектурные границы становятся общим соглашением. Тест TestCoreHasNoFrameworkImports поймает ситуацию «новый коллега добавил chi-зависимость в core/», когда код-ревью пропустит. На команде из одного-двух человек устная договорённость работает без автоматического теста.

Если хотя бы три из пяти пунктов совпадают — Hexagonal оправдана.

Когда Hexagonal добавит лишнюю работу

Один Postgres, нет внешних систем. Если внешний мир — только pgx-пул, отдельные adapter/ пакеты не нужны. Интерфейс OrderRepository в internal/order/ с реализацией через sqlc уже даёт нужную границу domain ↔ persistence.

Один-два разработчика, небольшая кодовая база. Пакетная конвенция держится устно; архитектурный тест — лишняя работа, когда всё ядро — три файла.

Бизнес-логика ещё не устоялась. Когда структура агрегата меняется каждые две недели (стартапная фаза), mapper-структуры между слоями замедляют итерации. Каждый «попробуем по-другому» — это переписывание OrderRequestMapper, PaymentMapper и OrderRepository-интерфейса. Сначала нужно найти устойчивую форму, потом переходить на Hexagonal.

Нет агрегатов с бизнес-правилами. Если домен — таблица транзакций, которую просто читают и пишут, Hexagonal ничего не защищает. Пустой Order со всей логикой в OrderService в hex-обёртке — самый дорогой вариант такого подхода.

Две частые ошибки

Hexagonal везде по умолчанию. Иногда вся команда переводит все Go-сервисы на hex-раскладку независимо от сложности. Сервис из четырёх эндпоинтов с одним Postgres в структуре:

internal/
  core/order/{aggregate,port,usecase}/
  adapter/in/http/
  adapter/out/persistence/
bootstrap/

— это пять пакетов, четыре mapper-структуры, CI-тест на импорты — ради чего? Решение о структуре принимается на каждый сервис отдельно: сервис-справочник — Уровень 1, сервис заказов с несколькими внешними системами и богатым доменом — Уровень 3. Одна команда, разные уровни.

Частичный Hexagonal. core/order/ есть, но handler в adapter/in/http/ инжектит persistence.OrderRepository напрямую:

// Частичный hex — хуже, чем честный плоский internal/
type OrderHandler struct {
    repo *persistence.OrderRepository  // in-adapter видит out-adapter напрямую
    sber *sber.PaymentAdapter
}

Почему это хуже, чем монолит:

  • Читая сервис, разработчик каждый раз думает: «здесь граница соблюдается или нет». Это сложнее, чем честный плоский internal/.
  • «Доделаем hex когда-нибудь» превращается в многонедельный рефакторинг без видимого результата для бизнеса, который никогда не помещается в план.

Правило: либо полный Hexagonal — пакеты core/, adapter/in/*, adapter/out/*, bootstrap/ и архитектурный тест в CI, — либо плоский internal/<bc>/. Промежуточное состояние допустимо только как короткий переходный период с явным сроком.

Коротко

  • Hexagonal в Go — третий уровень зрелости. Уровни 1–2 обходятся проще и дешевле.
  • Переходить стоит, когда совпадают хотя бы три из пяти признаков: 2+ внешних системы, богатый агрегат с бизнес-правилами, несколько типов входа, тест ядра требует Postgres, команда 3+ человек.
  • Не переходить: один Postgres, 1–2 разработчика, логика ещё меняется, нет реальных бизнес-правил в агрегате.
  • Hexagonal везде по умолчанию — антипаттерн: один сервис — одно осознанное решение.
  • Частичный Hexagonal (core есть, но адаптеры видят друг друга) хуже, чем честный плоский internal/. Либо полная структура, либо плоская.

Что почитать дальше

  • Структура пакетов — что именно строим после того, как решили переходить.
  • Core-слой — какие пакеты stdlib допустимы в core/, как выглядит богатый агрегат.
  • Ports — interface в core/<bc>/port/out/, ошибки как значения.
  • Adapters in — chi-handler маппит request-DTO в команду и передаёт в UseCase.
  • Adapters out — compile-time assertion, mapper domain ↔ внешние типы.
  • Bootstrap / Composition root — единственное место сборки зависимостей.
  • Архитектурные тесты — packages.Load + проверка запрещённых импортов.