Опирается на правила:
R-HEX-TEST-1…R-HEX-TEST-3иR-HEX-TEST-X1из Hexagonal Style Guide → раздел 8. Архитектурные тесты.
Важно знать
- В Go нет ArchUnit и Gradle-модулей — единственный механизм enforcement правил Hexagonal в едином модуле это тест, проверяющий импорты через
golang.org/x/tools/go/packages.- Тест живёт в
bootstrap/architecture_test.goс тегом сборки//go:build archи запускается отдельным шагом CI.- Проверяет минимум три инварианта:
core/не импортируетchi/pgx/kafka-go/redis;adapter/in/http/не импортируетadapter/out/*; каждый out-adapter не знает других out-адаптеров.- Required CI check: PR не мерджится при падении
go test -tags arch ./bootstrap/....- Compile-time assertion
var _ out.PaymentPort = (*PaymentAdapter)(nil)— дополнение к тесту, не замена: ловит несоответствие реализации портам, но не запрещённые импорты вcore/.- Единственный root-паттерн
./internal/core/...и./internal/adapter/...— всегда одна точка скана для всех проверок, иначе новые пакеты выпадут из теста.- Только code-review без автомата — антипаттерн (
R-HEX-TEST-X1): в большом PR с 40 файлами один лишнийimport "github.com/jackc/pgx/v5"вcore/order/aggregate/легко пропустить.
В Java Hexagonal enforcement работает на двух уровнях: Gradle-модуль не даёт core/ скомпилироваться с spring-context в classpath, а ArchUnit добавляет правила второго уровня (port — interface, in-adapter ↛ out-adapter). В Go в рамках одного модуля компилятор не знает о пакетных границах между core/ и adapter/ — они оба в одном go.mod. Поэтому enforcement живёт только в тесте.
Где живёт тест
R-HEX-TEST-1 — тест размещается в bootstrap/:
bootstrap/
main.go
config.go
architecture_test.go # ← forbidden-imports тест
Dockerfile
bootstrap/ зависит от всех пакетов сервиса, поэтому packages.Load из него видит полное дерево импортов ./internal/.... Альтернатива — отдельный пакет archtest/ с той же зависимостью от всех внутренних пакетов, но это добавляет структуру без выгоды для небольшого сервиса.
Тег сборки //go:build arch изолирует тест от обычного go test ./... — он тяжелее unit-тестов (парсит AST всего проекта) и нужен только в CI и при явном вызове.
Что проверять
R-HEX-TEST-1 — три обязательных инварианта:
1. core/ без инфраструктурных импортов (R-HEX-CORE-X1, R-HEX-CORE-X2):
//go:build arch
package main_test
import (
"strings"
"testing"
"golang.org/x/tools/go/packages"
"github.com/stretchr/testify/require"
)
func TestCoreHasNoFrameworkImports(t *testing.T) {
forbidden := []string{
"github.com/go-chi/chi",
"github.com/jackc/pgx",
"github.com/redis/go-redis",
"github.com/segmentio/kafka-go",
"github.com/sqlc-dev/pqtype",
"log/slog", // slog — infrastructure concern
}
pkgs := loadPackages(t, "./internal/core/...")
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
for _, fb := range forbidden {
if strings.HasPrefix(imp, fb) {
t.Errorf("core package %s imports forbidden %s", pkg.PkgPath, imp)
}
}
}
}
}
2. in-adapter не зависит от out-adapter (R-HEX-AIN-X4):
func TestInAdapterDoesNotImportOutAdapter(t *testing.T) {
outAdapterPrefix := modulePath(t) + "/internal/adapter/out"
pkgs := loadPackages(t, "./internal/adapter/in/...")
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
if strings.HasPrefix(imp, outAdapterPrefix) {
t.Errorf(
"in-adapter %s imports out-adapter %s (violates R-HEX-AIN-X4)",
pkg.PkgPath, imp,
)
}
}
}
}
3. out-adapter не знает других out-адаптеров (R-HEX-AOUT-X4):
func TestOutAdaptersDoNotImportEachOther(t *testing.T) {
outAdapterPrefix := modulePath(t) + "/internal/adapter/out"
pkgs := loadPackages(t, "./internal/adapter/out/...")
for _, pkg := range pkgs {
for _, imp := range pkg.Imports {
if strings.HasPrefix(imp, outAdapterPrefix) && imp != pkg.PkgPath {
t.Errorf(
"out-adapter %s imports another out-adapter %s (violates R-HEX-AOUT-X4)",
pkg.PkgPath, imp,
)
}
}
}
}
Вспомогательные функции
R-HEX-TEST-3 — единый корень скана. loadPackages вызывается с паттерном один раз на группу тестов; если добавить новый пакет, он автоматически попадёт в скан без правки теста:
func loadPackages(t *testing.T, patterns ...string) []*packages.Package {
t.Helper()
cfg := &packages.Config{
Mode: packages.NeedImports | packages.NeedName | packages.NeedFiles,
}
pkgs, err := packages.Load(cfg, patterns...)
require.NoError(t, err)
for _, pkg := range pkgs {
require.Empty(t, pkg.Errors, "package load errors in %s", pkg.PkgPath)
}
return pkgs
}
func modulePath(t *testing.T) string {
t.Helper()
cfg := &packages.Config{Mode: packages.NeedModule}
pkgs, err := packages.Load(cfg, ".")
require.NoError(t, err)
require.NotEmpty(t, pkgs)
return pkgs[0].Module.Path
}
Compile-time assertion в адаптерах
Compile-time assertion — не замена архитектурному тесту, но дополняет его: если SberPaymentAdapter перестаёт реализовывать PaymentPort из core/, сборка падает немедленно, не дожидаясь CI-теста:
// adapter/out/sber/payment_adapter.go
package sber
import "order-service/internal/core/order/port/out"
var _ out.PaymentPort = (*SberPaymentAdapter)(nil)
type SberPaymentAdapter struct {
client *Client
mapper PaymentMapper
}
func (a *SberPaymentAdapter) Register(
ctx context.Context, cmd out.RegisterPaymentCommand,
) (out.RegisterPaymentResult, error) {
sberReq := a.mapper.ToSberRequest(cmd)
sberResp, err := a.client.Register(ctx, sberReq)
if err != nil {
return out.RegisterPaymentResult{}, &SberError{Op: "register", Err: err}
}
return a.mapper.ToDomainResult(sberResp), nil
}
Аналогично для persistence-адаптера:
// adapter/out/persistence/order_repository.go
package persistence
import "order-service/internal/core/order/port/out"
var _ out.OrderRepository = (*OrderRepository)(nil)
type OrderRepository struct{ db *pgxpool.Pool }
func (r *OrderRepository) FindByID(ctx context.Context, id out.OrderID) (*aggregate.Order, error) {
row, err := r.queries.FindOrderByID(ctx, uuid.UUID(id))
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, &out.OrderNotFoundError{OrderID: id}
}
return nil, fmt.Errorf("find order %s: %w", id, err)
}
return OrderMapper{}.ToDomain(row), nil
}
Required CI check
R-HEX-TEST-2 — arch-тест в отдельном job CI, помеченном как required:
# .github/workflows/ci.yml
jobs:
arch-test:
name: Architecture tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Run architecture tests
run: go test -tags arch -v ./bootstrap/...
В branch protection rules — arch-test job required. PR не мерджится при падении.
Почему именно required, а не просто «желательный»:
- Без required не держится. Кто-то запустит тест локально, получит ошибку, удалит проверку «временно» — и она не вернётся.
- Сигнал команде. Required check говорит: «эти границы — не рекомендации, а инварианты».
- Ранняя обратная связь. Нарушение ловится на уровне PR, не на код-ревью и не в продакшн-инциденте.
Пример срабатывания
Разработчик добавляет slog-логирование напрямую в core/order/usecase/:
// core/order/usecase/create_order.go — НАРУШЕНИЕ
package usecase
import (
"log/slog" // forbidden import
)
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
slog.InfoContext(ctx, "creating order", "customer_id", cmd.CustomerID)
// ...
}
При запуске go test -tags arch ./bootstrap/...:
--- FAIL: TestCoreHasNoFrameworkImports (0.43s)
architecture_test.go:34: core package order-service/internal/core/order/usecase
imports forbidden log/slog
FAIL
Правильно: логирование передаётся через context.Context или через port-интерфейс в bootstrap/:
// core не импортирует slog; observer передаётся через контекст или порт
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*aggregate.Order, error) {
order, err := aggregate.NewOrder(cmd.CustomerID, cmd.Items)
if err != nil {
return nil, fmt.Errorf("build order: %w", err)
}
if err := h.repo.Save(ctx, order); err != nil {
return nil, fmt.Errorf("save order: %w", err)
}
return order, nil
}
Логирование вызова — в adapter/in/http/ через middleware, или в bootstrap/ через обёртку хендлера.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
| Только code-review для enforcement Hexagonal-границ | R-HEX-TEST-X1 | packages.Load тест + required CI check |
go test ./... без тега arch включает тест в каждый прогон | R-HEX-TEST-2 | //go:build arch + отдельный job |
Разные паттерны скана в разных тест-функциях (./internal/core/order/..., ./internal/core/product/...) | R-HEX-TEST-3 | единый ./internal/core/... — новые BC попадают автоматически |
| Compile-time assertion вместо forbidden-imports теста | R-HEX-TEST-1 | assertion ловит несовпадение интерфейса; тест ловит запрещённые импорты — нужны оба |
var _ out.PaymentPort = (*SberAdapter)(nil) в core/ | R-HEX-PORT-X1 | assertion в пакете адаптера (adapter/out/sber/) |
Тест проверяет только core/order/, но не core/product/, core/customer/ | R-HEX-TEST-3 | единый паттерн ./internal/core/... без BC-префикса |
Куда дальше
- Core слой — что именно запрещено в
core/и почемуslogтуда не идёт. - Ports — почему port —
interface, не struct, и как это связано с тестируемостью. - Adapters in — правило «chi-handler не зависит от persistence».
- Adapters out — compile-time assertion и маппер domain ↔ system-DTO.
- Bootstrap / composition root — единственное место, где собирается
packages.Loadи живёт wiring. - Структура пакетов — пакетная раскладка
internal/core/,internal/adapter/in/,internal/adapter/out/. - Когда переходить на Hexagonal — зачем вообще эти тесты, если сервис небольшой.