Когда несколько человек работают над одним сервисом, кто-нибудь рано или поздно добавит 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.