When several people work on the same service, sooner or later someone adds pgx to core/ — just because it's more convenient. It's a small step, but it breaks the main invariant of hexagonal architecture: the core stops being independent of the infrastructure.
In Java this is solved with two tools: Gradle modules won't let you compile core/ with infrastructure dependencies, and ArchUnit adds a second layer of rules. In Go there is no such separation at the compiler level — all packages live in a single go.mod, and nothing stops you from writing import "github.com/jackc/pgx/v5" in domain code. The only protection mechanism is a test that checks the imports itself.
How the import check works
The Go standard tooling library includes the golang.org/x/tools/go/packages package. It can load the description of any package — including the full list of what that package imports. This is what the test is built on.
The idea is simple: take all packages from core/, walk through their imports and check that there is no chi, pgx, kafka-go, redis or the like. If found — the test fails with a clear message.
Similarly, you can check that the HTTP adapter doesn't know about the PostgreSQL adapter, and that one out-adapter doesn't depend on another.
Where to put the test
The test lives in bootstrap/architecture_test.go. Why exactly there: bootstrap/ depends on all of the service's internal packages, so packages.Load sees the full import tree from it.
bootstrap/
main.go
config.go
architecture_test.go ← import-checking test
Dockerfile
The //go:build arch build tag isolates it from the ordinary go test ./.... The test is heavier than unit tests — it parses the AST of the whole project — and is only needed in CI and on an explicit local run.
Three invariants worth checking
First: core/ does not import infrastructure packages.
The core is business logic. There should be no HTTP frameworks, database drivers, or message brokers there:
//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", // logging is an infrastructure detail
}
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)
}
}
}
}
}
Second: the in-adapter does not depend on the out-adapter.
The HTTP handler must not know how storage is arranged. It works through ports:
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,
)
}
}
}
}
Third: out-adapters don't know about each other.
The PostgreSQL adapter must not call the Kafka adapter and vice versa. Each adapter is isolated:
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,
)
}
}
}
}
Helper functions
All three tests use the same pair of functions:
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
}
An important detail: the scan pattern is always specified as ./internal/core/... — with the ellipsis at the end. Then new packages inside core/ automatically fall into the check without editing the test. If you write ./internal/core/order/..., the new package core/payment/ will end up outside the check.
Compile-time assertion
Besides the import test there is one more technique — the compile-time assertion. It's a one-line variable that tells the compiler: "this type must implement this interface."
// adapter/out/sber/payment_adapter.go
package sber
import "order-service/internal/core/order/port/out"
var _ out.PaymentPort = (*SberPaymentAdapter)(nil)
If SberPaymentAdapter stops implementing PaymentPort — the build fails immediately, without waiting for CI. This is handy when refactoring ports.
But it's not a replacement for the import test. The assertion catches an interface mismatch, but doesn't forbid adding pgx to core/. For full protection you need both tools.
The test in CI as a mandatory condition
The architecture test must be a required check in CI — that is, the PR isn't merged until this step passes. If the test is "optional", someone will delete it "temporarily" — and it won't come back.
# .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/...
In the branch settings the arch-test job is marked as required. A violation is caught at the PR level, not at code review and not in production.
What happens on a violation
A developer adds logging directly into core/order/usecase/:
// core/order/usecase/create_order.go — violation
package usecase
import "log/slog"
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) error {
slog.InfoContext(ctx, "creating order", "customer_id", cmd.CustomerID)
// ...
}
On running 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
The right solution: logging is not needed in core/. The handler simply returns a result, and the call is logged by a wrapper in adapter/in/http/ or 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
}
Common mistakes
Running go test ./... without the arch tag. A test with //go:build arch simply won't run under such a command — it won't fail, it will quietly be skipped. The architecture test needs a separate command with the -tags arch flag.
Using narrow scan patterns. If you write ./internal/core/order/... instead of ./internal/core/..., then the new bounded context core/payment/ stays outside the check. Always use the broadest possible root pattern.
Relying only on code review. In a large PR of forty files, one stray import "github.com/jackc/pgx/v5" in core/ is easy to miss. Review is the last line of defense, not the first.
Replacing the import test with a compile-time assertion. The assertion only checks interface implementation — it doesn't see what exactly a package imports.
In short
- Go has no Gradle modules and no ArchUnit: protecting hexagonal architecture boundaries is built on a test with
golang.org/x/tools/go/packages. - Three invariants:
core/does not import infrastructure packages; the in-adapter does not depend on the out-adapter; out-adapters don't know about each other. - The test lives in
bootstrap/architecture_test.gowith the//go:build archtag and doesn't get into the ordinarygo test ./.... - The scan pattern
./internal/core/...— with the ellipsis, covers all current and future packages automatically. - The compile-time assertion (
var _ Port = (*Adapter)(nil)) complements the test: it catches an interface mismatch, but not forbidden imports. - The test must be a required check in CI — otherwise it gets deleted "temporarily" and never returns.
What to read next
- Core layer (Go) — what exactly is forbidden in
core/and why logging doesn't go there. - Ports (Go) — why a port is an
interface, not a struct. - Adapters in (Go) — the rule "a chi handler does not depend on persistence".
- Adapters out (Go) — the compile-time assertion and the domain ↔ system-DTO mapper.
- Bootstrap / Composition Root (Go) — the single place where wiring is assembled.