Опирается на правила: R-HEX-WHEN-1R-HEX-WHEN-3 и R-HEX-WHEN-X1R-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-сервисы, первые итерации, прототипы.
  • Уровень 2internal/<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: Частичный Hexagonalcore/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.OrderRepositoryR-HEX-WHEN-X2handler → usecase.ConfirmOrderHandlerout.OrderRepository
core/order/aggregate/ импортирует github.com/jackc/pgxR-HEX-CORE-X2sqlc-generated types в adapter/out/persistence/; маппинг в order_mapper.go
PaymentPort объявлен в adapter/out/sber/, не в core/R-HEX-PORT-X1interface в core/order/port/out/payment_port.go
PaymentPort.Register(ctx, SberRegisterRequest) в сигнатуре портаR-HEX-PORT-X2RegisterPaymentCommand из 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 инжектит OdnaKassaAdapterR-HEX-AOUT-X4ConfirmOrderHandler в 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.