Every team talks about hexagonal architecture. Almost no team enforces it.
You draw the dependency diagram: core/ never imports adapters/. adapters/ never imports cmd/. Everyone nods. Then a developer in a hurry adds import "internal/adapters/output" inside a core package because they need a JSON helper. The code review approves it because the reviewer is focused on the feature logic, not the import list.
Six months later, core/ imports from 4 adapter packages and nobody remembers when it started.
I wrote one test file that makes this impossible.
The Test
func TestCoreNeverImportsAdapters(t *testing.T) {
forbidden := []string{
"github.com/myapp/internal/adapters/",
"github.com/myapp/internal/cli/",
"github.com/myapp/cmd/",
"os/exec",
}
corePackages := discoverPackages(t, "internal/core/")
for _, pkg := range corePackages {
for _, imp := range pkg.Imports {
for _, banned := range forbidden {
if strings.HasPrefix(imp, banned) {
t.Errorf("core package %s imports forbidden %s",
pkg.Name, imp)
}
}
}
}
}
It walks every Go file in internal/core/, extracts the import list, and fails if any import matches a forbidden prefix. That's it.
What It Catches
Real violation: os/exec in core
When I added an E2E test runner to internal/core/enginetest/, the test immediately caught it:
core test isolation violation: internal/core/enginetest/e2e_test.go: imports os/exec
os/exec is an infrastructure dependency — it invokes external processes. It doesn't belong in core, even in test files. The E2E runner was moved to e2e/ at the repo root.
Without this test, the import would have stayed. It compiled fine. No reviewer would have caught it because os/exec in a test file looks reasonable.
Real violation: adapter type in port interface
A port interface in app/contracts/ accepted an adapter type as a parameter:
// BEFORE: Port contaminated with adapter type
type EvaluationLoader interface {
Load(ctx context.Context, path string) (*adapters.Evaluation, error)
}
The test caught the import of adapters/ from the app/ layer. The fix: the port returns a domain type, and the adapter converts at the boundary.
// AFTER: Port uses only domain types
type EvaluationLoader interface {
Load(ctx context.Context, path string) (*report.Assessment, error)
}
Why Code Reviews Aren't Enough
Code reviews catch architecture violations about 80% of the time. The other 20%:
- The reviewer is focused on the feature logic, not import lists
- The PR has 40 files and the violation is in file 37
- The import looks reasonable in context ("I just need this one helper")
- The reviewer doesn't know the architecture rules because they're in a wiki nobody reads
A test catches violations 100% of the time. It runs in CI on every push. It doesn't get tired, distracted, or rushed.
How to Build It
Step 1: Define your layers
internal/
core/ → NEVER imports adapters, app, cmd, os/exec
app/ → NEVER imports adapters, cmd
adapters/ → May import core, app
cli/ → May import anything
cmd/ → May import anything
Step 2: Discover packages programmatically
func discoverPackages(t *testing.T, root string) []PackageInfo {
var packages []PackageInfo
filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if !d.IsDir() && strings.HasSuffix(path, ".go") {
imports := extractImports(path)
packages = append(packages, PackageInfo{
Path: path,
Imports: imports,
})
}
return nil
})
return packages
}
Step 3: Check every import against the forbidden list
for _, pkg := range corePackages {
for _, imp := range pkg.Imports {
for _, banned := range forbidden {
if strings.HasPrefix(imp, banned) {
t.Errorf("%s imports forbidden %s", pkg.Path, imp)
}
}
}
}
Step 4: Run it in CI
- name: Architecture check
run: go test ./internal/app/ -run TestCoreNeverImportsAdapters
The ROI
This test is the most leveraged single commit in the codebase.
Over 4 months and 60 refactorings, every structural change had to pass this test. When we merged internal/infra/ into internal/adapters/, the test verified no core packages imported the old path. When we moved types between layers, the test verified the dependency direction.
Without it, the hexagonal boundaries would have eroded under the pressure of daily development. With it, the architecture is mechanically guaranteed.
The test took 30 minutes to write. It has prevented more regressions than any refactoring in the catalog.
What It Doesn't Catch
-
Logic violations: A function in
core/that formats CLI output text. The import is legal (fmt), but the responsibility is wrong. This still needs code review. - Interface design: A port with 10 methods (ISP violation). The import direction is correct but the interface is too fat. This needs design review.
-
Naming: A core package named
utilsinstead of a domain concept. The architecture is fine but discoverability suffers.
The fitness function enforces the structural rules. Design and naming still need human judgment. But by automating the structural check, you free up code review time to focus on the things that require judgment.
Checkout the code in my open source project Stave. The architecture fitness function has been running in CI since January 2026 and has caught 4 violations that would have been missed in code review.
Top comments (0)