Go
Standard library first, small interfaces declared by the consumer, errors as values — and architecture enforced by a checked file, because Go has no ArchUnit.
Verified 2026-07-31 · Against Go 1.25, golangci-lint v2, ast-grep, gopls.
Go is written plain: standard library first, small interfaces declared by the
consumer, errors as values. The two things that need a deliberate decision are
module layout in a monorepo (go.work plus per-service modules) and
architecture enforcement — because Go has no ArchUnit, so the boundary rule
has to be a checked artefact or it does not exist.
Errors
- Sentinel errors in the domain package, compared with
errors.Is()— nevererr == E, which breaks the moment anything wraps. Adapters translate them at the edge (ErrNotFound→ 404); the domain never mentions an HTTP status. - Wrap with
%wwhen adding context. A barefmt.Errorfloses the sentinel. - No silent errors.
_ = doThing()is a defect, not a style choice — make it a lint rule. The one legitimate exception is deliberate constant-time work (verifying a password against a dummy hash to defeat timing attacks); exclude that file explicitly rather than relaxing the rule. - Re-export shared sentinels (
var ErrNotFound = pkgerrors.ErrNotFound) soerrors.Isworks across module boundaries without every feature inventing its own not-found.
Interfaces and the dependency rule
- The consumer declares the interface, in its own vocabulary, in its own
package. The core declares
WalletRepository; the outbound adapter implements it. Never import the driver's types into the core to "reuse" them — that inversion is how hexagonal collapses in Go. - Interfaces stay small and behavioural. A port is the set of calls the core actually makes, not a mirror of the ORM.
context.Contextis the first parameter of every method that crosses a boundary — never a struct field. Request-scoped values (tenant, transaction, auth) travel in it.- No package-level mutable state and no
init()in the core. Both defeat testability and make ordering invisible. Wire explicitly through DI instead.
Types
- Typed string constants for closed sets (
type WalletStatus stringplus consts) with anIsValid()method. Go has no enums; this is the honest substitute, and validation then lives on the type rather than scattered at call sites. - Separate DB models from domain entities. The entity carries no
bun:ordb:tags; the outbound package holds the row struct and thetoEntity/fromEntityconversion. A domain struct with SQL tags is an infrastructure leak that spreads silently. decimal.Decimal(shopspring) for money. Neverfloat64. Never.anyoverinterface{}. Generics only where they remove real duplication — Go code is read far more than it is written.
Monorepo layout — go.work plus one module per deployable
go.work # use ./src/pkg, ./src/shared, ./src/platform/gateway, ./src/services/*
src/cmd/monolith/ # — every service composed into one process
src/pkg/ # module "pkg" — framework-agnostic shared libraries
src/shared/ # module "shared" — cross-service features + server bootstrap
src/platform/gateway/ # module "gateway" — edge
src/services/<name>/ # module "<name>" — internal/features/<feature>/{core,inbound,outbound}- The module is named after the service —
module wallet,module pkg,module shared. Combined withreplacedirectives (replace pkg => ../../pkg) the imports read as English:import pcore "pkg/core","wallet/internal/features/payments/core"— not a 60-character VCS path. - A service's features live under
internal/. Go then refuses the import from outside the service at compile time, so the service boundary needs no rule while the feature boundary inside it does. GOWORK=offwhen testing a single module. The workspace resolves everything and hides the fact that a module's owngo.modis incomplete.- The dependency rule between modules is one-way:
pkgnever imports a service; services importpkgandshared. - Deployable both ways. Each service has its own
cmd/server, and a monolith binary composes the same DI modules in one process. Keep the service's public shape a small interface —Module() fx.Option,Name(),RoutePrefix()— so composition stays mechanical.
Enforcing architecture without ArchUnit
Go's answer is a checked file, not a convention. Four cheap layers, all under
one make arch-check, all runnable in CI, each catching what the previous one
cannot express.
Why not depguard or golangci for this: they cover imports only, and the interesting violations — a repository field on a handler, a query builder in a service — are shapes, not imports.
1 — Directory structure
A shell script asserting the required directories exist. Every feature has
core/, inbound/, outbound/; the shared packages exist where everything
expects them. Missing or extra fails.
The cheapest check there is, and it catches the refactor that half-moved a package.
2 — Grep rules, in a declarative file
.arch-rules, one rule per line:
GREP_PATTERN|FILE_PATHS:EXCLUSIONS|DESCRIPTIONThe Makefile target reads the file, resolves the paths, greps, and prints
file:line for each violation. Real rules from a 21-service monorepo:
# Core cannot depend on the adapters that implement its ports
import.*"app/internal/features/.*/outbound|internal/features/*/core/**/*.go|Core layer cannot depend on outbound - use repository interfaces instead
# Core is framework-agnostic
import.*"github.com/uptrace/bun|internal/features/*/core/**/*.go|Core layer cannot directly access database - use repository interfaces
import.*"github.com/labstack/echo|internal/features/*/core/**/*.go|Core layer cannot depend on Echo - must be framework-agnostic
# Core speaks domain errors, never HTTP
http\.Status|internal/features/*/core/**/*.go|Core layer should not reference HTTP status codes - use domain errors
# Inbound goes through the core, never round the side
import.*"app/internal/features/.*/outbound|internal/features/*/inbound/**/*.go|Inbound cannot depend on outbound - use core services instead
# One owner per wrapped library
"log/slog"|**/*.go:!src/pkg/util/log/**:!**/*_test.go|Only pkg/util/log can import log/slog
"github.com/pquerna/otp/totp"|**/*.go:!src/pkg/util/crypto/**:!**/*_test.go|Only pkg/util/crypto can import the TOTP library
# No silent errors, with the one legitimate exception excluded by name
_\s*=\s*\w+\.|internal/features/**/*.go:!**/*_test.go:!**/service_password.go|No silent errors - handle or return all errors explicitlyThree things make this format worth copying:
- The description is the error message. A violation prints the sentence that says why, so nobody has to find the rule to understand the failure.
- Exclusions are
!-prefixed path segments, so the password-timing exception is a named file rather than a relaxed rule. See no silent errors. - The last three rules are not architecture, they are ownership. Only
pkg/util/logmay importlog/slogis what makes wrapped-not-imported a fact rather than an aspiration — and it is one line.
Crude, instant, and it holds.
3 — AST rules via ast-grep
For what grep cannot express: shapes. sgconfig.yml points at a rules/
directory of YAML:
id: no-db-in-handlers
language: go
severity: error
message: "No database queries in handlers - use repositories through services"
note: "Handlers should call services, which call repositories. Direct DB access violates layering."
rule:
any:
- pattern: $DB.NewSelect()
- pattern: $DB.NewSelect($$$)
- pattern: $DB.NewInsert($$$)
- pattern: $DB.NewUpdate($$$)
- pattern: $DB.NewDelete($$$)
files:
- "**/internal/features/*/inbound/**/*.go"
- "!**/*_test.go"$DB is a metavariable and $$$ is any argument list, so the rule matches a
query builder on any receiver — which no import check would catch, because the
handler never imported Bun; it was handed a connection.
The companion rule, no-raw-sql-in-repositories, is severity: warning rather
than error: raw SQL is legitimate for an aggregate over tables the feature owns,
and illegitimate the moment it names another feature's table. A rule that cannot
tell those apart should warn, not fail — an error people learn to bypass is
worse than a warning they read.
4 — API naming
JSON tags and swagger parameters must be camelCase in inbound DTOs. API casing drift is invisible in review and expensive once clients exist — and once an SDK is generated from the annotations, it is a breaking change to fix.
Reporting
arch-check runs all four and collects failures rather than exiting on the
first, so one run tells you everything that is broken. A check that stops at the
first violation turns a ten-minute fix into ten runs.
Linting
golangci-lint v2 with the default set, minus:
errcheckoff — it flagsdefer f.Close(), which is idiomatic, and the noise buries real findings. The "no silent errors" grep rule covers the case that actually matters.depguardoff — architecture lives in the rules file above.gosecandduplexcluded on_test.go;lllexcluded on generated docs.
new: false. Do not hide findings in existing code behind an "only new
issues" flag; that is how a codebase carries a permanent debt it never sees.
Traps
go.workmasking a brokengo.mod→ CI builds a single module and fails where local passed. Run at least oneGOWORK=offbuild and test per module.- A domain struct reused as the DB row → the day the column names diverge, the fix touches every layer. Split them on day one; the duplication is 20 lines and buys the boundary.
- Sentinel comparison with
==after someone addsfmt.Errorf("...: %w", err)→ the branch silently stops matching and a 404 becomes a 500. Alwayserrors.Is.