hexcheck
hexcheck is a Go architecture linter for hexagonal and clean architecture boundaries.
It is built on golang.org/x/tools/go/analysis, runs as a standalone CLI, and can be embedded in a custom golangci-lint binary through the Module Plugin System.
What it checks
- dependency direction between configurable architecture roles
- framework or infrastructure types leaking into core/port APIs
- suspicious business logic in adapters and entrypoints
- missing generated mocks for port interfaces
- local fakes or concrete adapters in tests where generated mocks should be used
Architecture roles
hexcheck does not require folders to be named domain, usecase, or adapters. A repository maps its own paths to roles in .hexcheck.yaml.
Supported roles:
core— domain/core business logicusecase— application use cases and orchestrationports— interfaces/contractsadapter— infrastructure, persistence, external services, outbound adaptersentrypoint— CLI, HTTP handlers, UI entrypoints, app bootstrapignore— generated code, mocks, vendored paths
Quick config
version: 1
components:
core:
role: core
paths:
- internal/domain/**
- internal/core/**
usecases:
role: usecase
paths:
- internal/application/usecase/**
- internal/application/usecases/**
- internal/usecase/**
- internal/usecases/**
ports:
role: ports
paths:
- internal/application/port/**
- internal/domain/repository/**
adapters:
role: adapter
paths:
- internal/infrastructure/**
- internal/adapters/**
entrypoints:
role: entrypoint
paths:
- cmd/**
generated:
role: ignore
paths:
- '**/mocks/**'
- '**/generated/**'
- '**/*_templ.go'
- '**/*_gen.go'
rules:
no-adapter-imports-in-core: error
no-usecase-imports-in-core: error
no-infra-imports-in-usecase: error
no-infra-imports-in-ports: error
no-entrypoint-imports-in-adapter: error
no-framework-types-in-core: error
no-infra-types-in-ports: error
no-adapter-to-adapter-imports: warn
suspicious-business-logic-in-adapter: warn
no-local-fakes-for-ports: warn
missing-generated-mock-for-port: warn
prefer-generated-mocks: warn
heuristics:
businessLogicThreshold: 8
businessLogicMinStrongSignals: 2
businessLogicMinWeakSignals: 2
businessLogicMaxFunctionNodes: 2000
businessLogicMaxDiagnosticsPerPackage: 10
businessLogicMode: audit # audit|ci
businessLogicMinConfidence: medium # low|medium|high
excludeTestFiles: true
mocking:
generatedMockPaths:
- internal/mocks/**
- internal/application/mocks/**
- internal/application/port/mocks/**
generatedMockNamePatterns:
- Mock{{Interface}}
- '{{Interface}}Mock'
A fuller example lives in examples/hexcheck.yaml.
Business-logic diagnostics include a confidence level. audit mode reports findings at or above businessLogicMinConfidence; ci mode reports only high-confidence findings.
Rules
| Rule | Default | Reports |
|---|---|---|
no-adapter-imports-in-core |
error | core imports an adapter or entrypoint |
no-usecase-imports-in-core |
error | core imports a usecase |
no-infra-imports-in-usecase |
error | usecase imports an adapter or entrypoint |
no-infra-imports-in-ports |
error | ports import an adapter or entrypoint |
no-entrypoint-imports-in-adapter |
error | adapter imports an entrypoint |
no-framework-types-in-core |
error | exported core API exposes an adapter or framework type |
no-infra-types-in-ports |
error | port interface method exposes an adapter or framework type |
no-adapter-to-adapter-imports |
warn | adapter imports another adapter component |
suspicious-business-logic-in-adapter |
warn | adapter or entrypoint function looks like business logic |
business-logic-diagnostics-hidden |
warn | package exceeds the business-logic diagnostic cap; reports the hidden count |
unused-ignore-directive |
warn | inline ignore rule suppresses no diagnostics in this run |
no-local-fakes-for-ports |
warn | test defines a fake for a port that has a generated mock |
missing-generated-mock-for-port |
warn | port interface has no generated mock |
prefer-generated-mocks |
warn | usecase/core test imports a concrete adapter |
Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments.
Business-logic evidence treats domain calls and mutations as weak signals. Field-by-field construction of fresh local domain values is not mutation evidence. Domain predicates (methods or package-level boolean functions) driving decisions, numeric domain operands in ordering comparisons, guarded domain errors (other than common storage sentinels), and domain-derived set membership (including comma-ok checks) provide stronger evidence with branching. String/Error formatting and IsZero checks alone do not. Business keywords need decision context rather than merely a domain-typed parameter (except explicit computation/scoring or validation with mutation). The per-package cap applies after inline suppression; one business-logic-diagnostics-hidden warning states how many additional findings it hides.
The config is strict: unknown keys and unknown rule names are errors.
Suppressing a finding
Prefer config allow entries for whole files or directories. For one line, use an inline directive with a reason:
type Rows interface {
//hexcheck:ignore no-infra-types-in-ports row type is part of the published contract
Next() sql.Row
}
The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as invalid-ignore-directive. Each listed rule that suppresses nothing in the current run produces unused-ignore-directive (warn by default), including when its rule is disabled or excluded. Business-logic suppression usage is tracked even beyond the package reporting cap. Unclassified, ignored, and generated files are not checked for unused directives.
Standalone CLI
hexcheck init # write a starter .hexcheck.yaml from detected folders
hexcheck ./... # check; config is discovered from the root upwards
hexcheck -config .hexcheck.yaml -root . ./...
| Flag | Default | Meaning |
|---|---|---|
-config |
discovered | path to .hexcheck.yaml |
-root |
current dir | project root for config-relative paths |
-module |
from go.mod |
Go module path |
-fail-on |
error |
lowest severity that fails: error or warn |
-json |
false | JSON output on stdout |
-test |
true | also analyze test files |
-version |
print the version |
Each finding is prefixed with its severity, e.g. [warn] no-adapter-to-adapter-imports: ....
Exit codes: 0 no failing findings, 1 load/config/analysis error, 2 usage error, 3 findings at or above -fail-on.
golangci-lint module plugin
Build a custom golangci-lint binary using the module plugin system:
golangci-lint custom -c examples/custom-gcl.yml
Example builder config:
version: v2.12.2
name: hex-golangci-lint
destination: ./bin
plugins:
- module: github.com/bnema/hexcheck
import: github.com/bnema/hexcheck/golangci
version: v0.1.0
Enable it in a project:
version: "2"
linters:
enable:
- hexcheck
settings:
custom:
hexcheck:
type: module
description: Checks hexagonal architecture boundaries.
settings:
config: .hexcheck.yaml
golangci-lint reports every hexcheck finding as an issue, whatever its configured severity. To fail only on error rules, set the warn rules to off in .hexcheck.yaml, or exclude issues whose text matches ^\[warn\] with linters.exclusions.rules.
Agent configuration guide
SKILL.md explains how an agent should configure hexcheck for a new repository, including non-standard layouts such as core/boundaries.
Local development
make test # go test -race
make check # tidy, lint, test, and hexcheck on its own code
HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local