Опирается на правила:
R-HEX-WHEN-1…R-HEX-WHEN-3иR-HEX-WHEN-X1…R-HEX-WHEN-X2из Hexagonal Style Guide → раздел 1. Когда переходить на Hexagonal.
Важно знать
- Hexagonal — часть Уровня 3 в UCP. Уровни 1–2 обходятся плоским
internal/<bc>/без слоёв.- Изоляция в Go обеспечивается соглашением импортов + архитектурным тестом (
packages.Load), а не Gradle-модулями и ArchUnit.- Признаки «пора»: 2+ внешних системы, несколько типов входа (chi-REST + Kafka-consumer), rich aggregate с инвариантами, команда 3+ человек, unit-тест core требует поднять реальную БД.
- Признаки «рано»: один Postgres, 3–5 эндпоинтов, 1–2 разработчика, бизнес-логика ещё не устоялась.
- Cargo-cult запрещён (
R-HEX-WHEN-X1). Решение принимается на сервис, не на отдел.- Частичный hex — антипаттерн (
R-HEX-WHEN-X2):core/есть, но handler вadapter/in/http/читает из репозитория напрямую. Либо полный, либо плоскийinternal/.- Ошибки в Go — значения:
apperr.Kind+ типизированные структуры; не паника, неerrors.Newв port-сигнатуре без типа.
Hexagonal Architecture даёт сильные гарантии: core/ без chi, pgx, kafka-go; port-интерфейсы изолируют инфраструктуру; compile-time assertion var _ out.PaymentPort = (*SberAdapter)(nil) ловит несоответствие сигнатур; архитектурный CI-тест не пускает pgx-импорт в core/. Платить за это нужно пакетной конвенцией, mapper-структурами между слоями и отдельным тестом на импорты. Цена окупается только при определённых условиях — разбираем ниже.
Hexagonal — часть Уровня 3
R-HEX-WHEN-1: Hexagonal в UCP соответствует Уровню 3 (DDD + ports/adapters + архитектурный CI-тест). Уровни 1–2 обходятся проще.
Шкала по уровням зрелости для Go-сервисов:
- Уровень 1 — UseCase + Handler + chi-роутер в одном пакете
internal/<bc>/. Нет DDD-агрегатов, нет явных port-интерфейсов. CRUD-сервисы, первые итерации, прототипы. - Уровень 2 —
internal/<bc>/с отдельнымиaggregate/,usecase/иrepository/. Domain выделен, но отдельных пакетовadapter/in/иadapter/out/нет; инфраструктура рядом с доменом. - Уровень 3 — DDD + Hexagonal. Пакеты
internal/core/<bc>/,internal/adapter/in/*,internal/adapter/out/*,bootstrap/. Архитектурный тест в CI. Это тема данной статьи.
Переносить сервис Уровня 1 на Hexagonal — значит добавить 4–5 пакетов, 3–4 mapper-структуры и CI-тест ради того, что и так держится устно. Ceremony перевесит выгоду.
Признаки «пора»
R-HEX-WHEN-2: сигналы, при которых Hexagonal начинает давать измеримый выигрыш.
1. Сервис интегрируется с 2+ внешними системами. Типичный сетап Уровня 3 — Postgres (sqlc + pgx) + Sber + ОднаКасса + Kafka. У каждой системы свои DTO, свои таймауты, свои режимы падения. Без явных пакетов adapter/out/sber/, adapter/out/odna_kassa/ весь IO-код скапливается в одном OrderService, который становится трудно тестировать и читать.
2. Rich aggregate с инвариантами. Когда Order.Confirm(payment PaymentResult) error проверяет 5 условий, обновляет статус и фиксирует результат — этот код хочется тестировать без 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-роутер для Customer (admin-панель), Kafka-consumer для Product-событий. Каждый тип входа — свой пакет (adapter/in/http/user/, adapter/in/http/admin/, adapter/in/kafka/) со своим middleware-стеком. Без разделения middleware аутентификации начинают пересекаться:
// adapter/in/http/user/router.go — JWT-аутентификация клиента
// adapter/in/http/admin/router.go — basic-auth или mTLS для внутреннего API
// adapter/in/kafka/order_events.go — consumer без HTTP-аутентификации
4. Unit-тест core требует поднять реальный Postgres. Если handler в core/ напрямую импортирует adapter/out/persistence/ или sqlc-generated типы, тест core тянет за собой весь pgx-стек. Hexagonal с port-интерфейсом OrderRepository в core/ позволяет подменить адаптер на in-memory реализацию.
5. Команда 3+ разработчиков. Архитектурные границы становятся социальным контрактом. TestCoreHasNoFrameworkImports ловит «новый коллега добавил chi-зависимость в core/», когда code review пропустит. На команде из 1–2 человек договорённости работают без автоматического теста.
Если хотя бы 3 из 5 пунктов — Hexagonal оправдан.
Признаки «рано»
R-HEX-WHEN-3: когда переход даст больше ceremony, чем пользы.
Один Postgres, нет внешних систем. Если внешний мир — только pgx-пул, Hexagonal не нужен. Интерфейс OrderRepository в internal/order/ с реализацией через sqlc уже даёт нужную границу domain ↔ persistence — без отдельных adapter/ пакетов.
1–2 разработчика, < 10K LOC. Пакетная конвенция держится устно; архитектурный тест ради «не дай бог кто-то положит pgx в core» — overhead, когда core — три файла.
Бизнес-логика ещё ищет форму. Когда структура агрегата меняется каждые 2 недели (стартапная фаза), mapper-структуры между слоями замедляют итерации. Каждый «попробуем по-другому» = переписывание OrderRequestMapper, PaymentMapper и OrderRepository-интерфейса. Сначала найти устойчивую форму, потом переходить на Hexagonal.
Нет агрегатов с инвариантами. Если domain — таблица Sber-транзакций, которую просто читают и пишут, Hexagonal ничего не защищает. Anemic Order со всей логикой в OrderService в hex-обёртке — самый дорогой вариант anemic domain.
Cargo-cult и частичный hex — запрет
R-HEX-WHEN-X1: Hexagonal как cargo-cult — все Go-сервисы команды переведены на hex-раскладку независимо от сложности. Сервис из 4 эндпоинтов с одним Postgres в структуре:
internal/
core/order/{aggregate,port,usecase}/
adapter/in/http/
adapter/out/persistence/
bootstrap/
— это 5 пакетов, 4 mapper-структуры, CI-тест на импорты — ради чего? Это форма «архитектура ради архитектуры».
Решение принимается на сервис: сервис Customer (CRUD-справочник) — Уровень 1, сервис Order (Sber + ОднаКасса + Kafka + богатый домен) — Уровень 3. Одна команда, разные уровни.
R-HEX-WHEN-X2: Частичный Hexagonal — core/order/ есть, но handler в adapter/in/http/ инжектит persistence.OrderRepository напрямую:
// AVOID
type OrderHandler struct {
repo *persistence.OrderRepository // нарушение: in-adapter видит out-adapter
sber *sber.PaymentAdapter // и это тоже
}
Почему это хуже, чем монолит:
- Архитектурный тест ловит не всё.
TestCoreHasNoFrameworkImportsчист, ноadapter/in/http/видитadapter/out/persistence/— нарушениеR-HEX-AIN-X4не поймает тест, если не написать его явно. - Mental overhead. Читая сервис, разработчик каждый раз думает «здесь hex-граница соблюдается или нет». Хуже, чем честный плоский
internal/. - Рефакторинг не откладывается. «Доделаем hex когда-нибудь» превращается в 2–3-недельный рефакторинг без видимой бизнес-ценности, который никогда не помещается в спринт.
Правило: либо полный Hexagonal (пакеты core/, adapter/in/*, adapter/out/*, bootstrap/, архитектурный тест в CI), либо плоский internal/<bc>/. Промежуточное состояние допустимо только как короткий миграционный период с явным сроком.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Hexagonal для сервиса Уровня 1 (3–5 эндпоинтов, один Postgres) | R-HEX-WHEN-X1 | Плоский internal/<bc>/ с интерфейсом Repository |
core/ есть, но handler в adapter/in/http/ инжектит persistence.OrderRepository | R-HEX-WHEN-X2 | handler → usecase.ConfirmOrderHandler → out.OrderRepository |
core/order/aggregate/ импортирует github.com/jackc/pgx | R-HEX-CORE-X2 | sqlc-generated types в adapter/out/persistence/; маппинг в order_mapper.go |
PaymentPort объявлен в adapter/out/sber/, не в core/ | R-HEX-PORT-X1 | interface в core/order/port/out/payment_port.go |
PaymentPort.Register(ctx, SberRegisterRequest) в сигнатуре порта | R-HEX-PORT-X2 | RegisterPaymentCommand из domain-типов; адаптер маппит внутри |
(Order, bool) из порта, где false = «не найдено» | R-HEX-PORT-X3 | *OrderNotFoundError{OrderID} с apperr.Kind = Domain |
adapter/in/http/ импортирует adapter/out/sber/ | R-HEX-AIN-X4 | оба адаптера зависят только от core/ |
SberAdapter инжектит OdnaKassaAdapter | R-HEX-AOUT-X4 | ConfirmOrderHandler в core/ инжектит оба порта |
wiring в adapter/out/persistence/init() | §9 anti-pattern | конструктор NewOrderRepository(pool *pgxpool.Pool) в bootstrap/main.go |
Куда дальше
- Структура пакетов — что именно строим, когда решили переходить:
internal/core/,internal/adapter/in/,internal/adapter/out/,bootstrap/. - Core слой — какие пакеты stdlib допустимы в
core/, как выглядит rich aggregate, почему sqlc-generated struct не доменный тип. - Ports —
interfaceвcore/<bc>/port/out/, port-ошибки как значения,R-HEX-PORT-X3и почему(Order, bool)— антипаттерн. - Adapters in — chi-handler маппит request-DTO → command →
UseCase.Handle;OrderRequestMapperкак отдельная структура. - Adapters out —
var _ out.PaymentPort = (*SberAdapter)(nil), mapper domain ↔ Sber-DTO, per-system isolation. - Bootstrap / Composition root —
bootstrap/main.goкак единственное место wiring,signal.NotifyContextдля graceful shutdown. - Архитектурные тесты —
packages.Load+ forbidden-imports, CI required check, что проверять кроме core.