Опирается на правила: R-HEX-TEST-1R-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-X1packages.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-1assertion ловит несовпадение интерфейса; тест ловит запрещённые импорты — нужны оба
var _ out.PaymentPort = (*SberAdapter)(nil) в core/R-HEX-PORT-X1assertion в пакете адаптера (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 — зачем вообще эти тесты, если сервис небольшой.