← назад к разделу

Когда несколько человек работают над одним сервисом, кто-нибудь рано или поздно добавит pgx в core/ — просто чтобы было удобнее. Это маленький шаг, но он разрушает главный инвариант гексагональной архитектуры: ядро перестаёт быть независимым от инфраструктуры.

В Java это решают двумя инструментами: Gradle-модули не дают скомпилировать core/ с инфраструктурными зависимостями, а ArchUnit добавляет правила второго уровня. В Go такого разграничения на уровне компилятора нет — все пакеты живут в одном go.mod, и никто не мешает написать import "github.com/jackc/pgx/v5" в доменном коде. Единственный механизм защиты — тест, который сам проверяет импорты.

Как устроена проверка импортов

В стандартной библиотеке инструментов Go есть пакет golang.org/x/tools/go/packages. Он умеет загружать описание любого пакета — включая полный список того, что этот пакет импортирует. На этом и строится тест.

Идея простая: взять все пакеты из core/, пройти по их импортам и проверить, что там нет chi, pgx, kafka-go, redis и подобного. Если нашли — тест падает с понятным сообщением.

Аналогично можно проверить, что HTTP-адаптер не знает про PostgreSQL-адаптер, и что один out-адаптер не зависит от другого.

Где размещать тест

Тест живёт в bootstrap/architecture_test.go. Почему именно там: bootstrap/ зависит от всех внутренних пакетов сервиса, поэтому packages.Load видит из него полное дерево импортов.

bootstrap/
  main.go
  config.go
  architecture_test.go     ← тест проверки импортов
  Dockerfile

Тег сборки //go:build arch изолирует его от обычного go test ./.... Тест тяжелее unit-тестов — он разбирает AST всего проекта — и нужен только в CI и при явном локальном запуске.

Три инварианта, которые стоит проверять

Первый: core/ не импортирует инфраструктурные пакеты.

Ядро — это бизнес-логика. Там не должно быть HTTP-фреймворков, драйверов баз данных, брокеров сообщений:

//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",                       // логирование — инфраструктурная деталь
    }
    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)
                }
            }
        }
    }
}

Второй: in-адаптер не зависит от out-адаптера.

HTTP-обработчик не должен знать, как устроено хранилище. Он работает через порты:

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",
                    pkg.PkgPath, imp,
                )
            }
        }
    }
}

Третий: out-адаптеры не знают друг о друге.

PostgreSQL-адаптер не должен вызывать Kafka-адаптер и наоборот. Каждый адаптер изолирован:

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",
                    pkg.PkgPath, imp,
                )
            }
        }
    }
}

Вспомогательные функции

Все три теста используют одну и ту же пару функций:

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
}

Важная деталь: паттерн скана всегда указывается как ./internal/core/... — с многоточием в конце. Тогда новые пакеты внутри core/ автоматически попадают в проверку без правки теста. Если написать ./internal/core/order/..., новый пакет core/payment/ окажется за пределами проверки.

Compile-time assertion

Кроме теста импортов есть ещё один приём — compile-time assertion. Это однострочная переменная, которая говорит компилятору: «этот тип должен реализовывать вот этот интерфейс».

// adapter/out/sber/payment_adapter.go
package sber

import "order-service/internal/core/order/port/out"

var _ out.PaymentPort = (*SberPaymentAdapter)(nil)

Если SberPaymentAdapter перестаёт реализовывать PaymentPort — сборка падает немедленно, не дожидаясь CI. Это удобно при рефакторинге портов.

Но это не замена тесту импортов. Assertion ловит несовпадение интерфейса, но не запрещает добавить pgx в core/. Для полной защиты нужны оба инструмента.

Тест в CI как обязательное условие

Архитектурный тест должен быть required check в CI — то есть PR не мерджится, пока этот шаг не пройдёт. Если тест «желательный», кто-нибудь удалит его «временно» — и он не вернётся.

# .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/...

В настройках ветки arch-test job отмечается как required. Нарушение ловится на уровне PR, а не на код-ревью и не в продакшне.

Что происходит при нарушении

Разработчик добавляет логирование напрямую в core/order/usecase/:

// core/order/usecase/create_order.go — нарушение
package usecase

import "log/slog"

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

Правильное решение: логирование в core/ не нужно. Хендлер просто возвращает результат, а логирует вызов обёртка в adapter/in/http/ или middleware:

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
}

Частые ошибки

Запускать go test ./... без тега arch. Тест с //go:build arch в такой команде просто не запустится — не упадёт, а тихо пропустится. Для архитектурного теста нужна отдельная команда с флагом -tags arch.

Использовать узкие паттерны скана. Если написать ./internal/core/order/... вместо ./internal/core/..., то новый bounded context core/payment/ останется вне проверки. Всегда используйте максимально широкий корневой паттерн.

Полагаться только на код-ревью. В большом PR из сорока файлов один лишний import "github.com/jackc/pgx/v5" в core/ легко пропустить. Ревью — это последний рубеж, а не первый.

Заменять тест импортов compile-time assertion. Assertion проверяет только реализацию интерфейса — он не видит, что именно импортирует пакет.

Коротко

  • В Go нет Gradle-модулей и ArchUnit: защита границ гексагональной архитектуры строится на тесте с golang.org/x/tools/go/packages.
  • Три инварианта: core/ не импортирует инфраструктурные пакеты; in-адаптер не зависит от out-адаптера; out-адаптеры не знают друг о друге.
  • Тест живёт в bootstrap/architecture_test.go с тегом //go:build arch и не попадает в обычный go test ./....
  • Паттерн скана ./internal/core/... — с многоточием, охватывает все текущие и будущие пакеты автоматически.
  • Compile-time assertion (var _ Port = (*Adapter)(nil)) дополняет тест: ловит несовпадение интерфейса, но не запрещённые импорты.
  • Тест должен быть required check в CI — иначе его удалят «временно» и не вернут.

Что почитать дальше

  • Core слой (Go) — что именно запрещено в core/ и почему логирование туда не идёт.
  • Ports (Go) — почему port — это interface, не struct.
  • Adapters in (Go) — правило «chi-handler не зависит от persistence».
  • Adapters out (Go) — compile-time assertion и маппер domain ↔ system-DTO.
  • Bootstrap / Composition Root (Go) — единственное место, где собирается wiring.