Architect
Standards
ReferenceNormative

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() — never err == E, which breaks the moment anything wraps. Adapters translate them at the edge (ErrNotFound → 404); the domain never mentions an HTTP status.
  • Wrap with %w when adding context. A bare fmt.Errorf loses 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) so errors.Is works 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.Context is 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 string plus consts) with an IsValid() 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: or db: tags; the outbound package holds the row struct and the toEntity/fromEntity conversion. A domain struct with SQL tags is an infrastructure leak that spreads silently.
  • decimal.Decimal (shopspring) for money. Never float64. Never.
  • any over interface{}. 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 servicemodule wallet, module pkg, module shared. Combined with replace directives (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=off when testing a single module. The workspace resolves everything and hides the fact that a module's own go.mod is incomplete.
  • The dependency rule between modules is one-way: pkg never imports a service; services import pkg and shared.
  • 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|DESCRIPTION

The 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 explicitly

Three 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/log may import log/slog is 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:

  • errcheck off — it flags defer f.Close(), which is idiomatic, and the noise buries real findings. The "no silent errors" grep rule covers the case that actually matters.
  • depguard off — architecture lives in the rules file above.
  • gosec and dupl excluded on _test.go; lll excluded 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.work masking a broken go.mod → CI builds a single module and fails where local passed. Run at least one GOWORK=off build 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 adds fmt.Errorf("...: %w", err) → the branch silently stops matching and a 404 becomes a 500. Always errors.Is.

On this page