← Back to the section

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.go with the //go:build arch tag and doesn't get into the ordinary go 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.