Hexagonal Architecture даёт сильные гарантии: бизнес-логика без зависимостей на chi, pgx или kafka-go; port-интерфейсы, которые изолируют инфраструктуру; тест в CI, который не пустит pgx-импорт в ядро. Но у этих гарантий есть цена — дополнительные пакеты, mapper-структуры между слоями и отдельный тест на импорты. Цена окупается только при определённых условиях.
Три уровня сложности Go-сервиса
Не каждый сервис нуждается в одной и той же структуре. В Go-проектах обычно выделяют три уровня зрелости:
Уровень 1 — UseCase, Handler и chi-роутер живут в одном пакете internal/<bc>/. Нет явных агрегатов, нет отдельных port-интерфейсов. Подходит для CRUD-сервисов, прототипов и первых итераций.
Уровень 2 — internal/<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+ проверка запрещённых импортов.