Go
Standard library first, small interfaces declared by the consumer, coded errors the router answers — and architecture enforced by a checking script, because Go has no ArchUnit.
Verified 2026-10-03 · Against Go 1.27, golangci-lint v2, ast-grep, gopls, and a production Go monorepo of about twenty services. Every excerpt is adapted from that monorepo, names changed.
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 checking script or it does not exist.
Errors
A feature declares each of its errors once, with a code and the kind of refusal it is. The router turns the kind into a status. No handler maps an error, and the core never mentions an HTTP status.
Adapted from a production service.
package core
import "pkg/util/errors"
var (
ErrProjectNotFound = errors.NotFound("PROJECT_NOT_FOUND", "project not found")
ErrDuplicateName = errors.Conflict("PROJECT_DUPLICATE_NAME", "project name already exists")
ErrProjectArchived = errors.Conflict("PROJECT_ARCHIVED", "an archived project cannot be changed")
ErrInvalidName = errors.Functional("INVALID_NAME", "name is required and must be at most 255 characters")
ErrInvalidCountry = errors.Functional("INVALID_COUNTRY", "country must be a two-letter ISO 3166 code")
ErrInvalidStatus = errors.Functional("INVALID_STATUS", "status must be one of: active, completed, archived")
ErrInvalidDateRange = errors.Functional("INVALID_DATE_RANGE", "the end date must be on or after the start date")
)
NotFound, Conflict and Functional build one type — a functional error with a
code, a message and a status (404, 409, 400). The router is the one place it
becomes a response, and it finds it with errors.As however deeply it was wrapped:
Adapted from a production service.
func mapDomainError(ctx core.ReqCtx, err error) bool {
var funcErr *apperrors.FunctionalError
if errors.As(err, &funcErr) {
ctx.JSON(funcErr.HTTPStatusCode(), errorBody(funcErr.Kind, funcErr.Message, funcErr.Details))
return true
}
var techErr *apperrors.TechnicalError
if errors.As(err, &techErr) {
ctx.SetError(techErr) // the full error, for the logging middleware
ctx.JSON(techErr.HTTPStatusCode(), errorBody(techErr.Kind, techErr.Message, techErr.Details))
return true
}
return false // unknown: answered 500 by the error handler, and logged
}
- The client reads the code:
{"code": "INVALID_NAME", "message": "…"}. A test asserts the code, never the message. - Not found is the feature's error, never
nil, nil. The repository turns the driver's not-found intoErrProjectNotFound, so a caller cannot dereference a record that is not there, and the router answers 404 with the feature's own code without a line in the handler. - Compare with
errors.Isanderrors.As— nevererr == E, which breaks the moment anything wraps. - Wrap with
%wwhen adding context. A barefmt.Errorfloses the code. - No silent errors.
errcheckis on everywhere outside tests (see linting). A deliberate discard is written_ = f()with a comment saying why the error has nowhere to go — visible in review, which an unchecked call is not.
When not to: a failure the caller did not cause — the database is down, a provider timed out — stays a plain or technical error and answers 500. Dressing it as a functional error hides a defect behind a polite answer.
Interfaces and the dependency rule
- The consumer declares the interface, in its own vocabulary, in its own
package. The core declares
Repository; 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.
Time and ids are dependencies
A feature never calls time.Now() or mints an id itself. It takes a clock and
an id generator through its constructor, like any other port, so a test can stop
time and predict the next id — and then assert both as literals.
Adapted from a production service.
type Clock interface {
Now() time.Time
}
// System is wall-clock time, the production Clock.
type System struct{}
func (System) Now() time.Time { return time.Now().UTC() }
type Generator interface {
New(prefix string) string // prefix_<20 characters>
NewShort() string
}
// The server bootstrap is the one place either is chosen; the test app decorates both.
func provideClock() clock.Clock { return clock.System{} }
func provideIDs() ids.Generator { return ids.XID{} }
The test doubles live beside the production ones: clock.NewFake(at) is stopped
until a test moves it, and ids.NewSequence() counts per prefix, so the first
project a test creates is prj_00000000000000000001 whatever else the request
minted. A check refuses a new direct call — see ratchets.
When not to: a pure function that needs an instant takes it as an argument. Only code that reads the time or mints an id takes the port.
Types
Typed string constants for closed sets, 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. The type should also answer questions about itself:
Adapted from a production service.
// Status is where a project is in its life.
type Status string
const (
StatusActive Status = "active"
StatusCompleted Status = "completed"
StatusArchived Status = "archived"
)
// IsValid reports whether the status is one this service knows.
func (s Status) IsValid() bool {
switch s {
case StatusActive, StatusCompleted, StatusArchived:
return true
}
return false
}
// AcceptsChanges reports whether a project in this status may still be edited.
func (s Status) AcceptsChanges() bool {
return s != StatusArchived
}
When not to: a set a tenant can edit at runtime (categories, tags) is data in a table, not a type.
- Separate DB models from domain entities. The entity carries no tags — no
bun:, nodb:, and nojson:either: the inbound response type owns the wire names. The outbound package holds the row struct and thetoEntity/fromEntityconversion. A domain struct with SQL tags is an infrastructure leak that spreads silently. Example:outbound/models.go. decimal.Decimal(shopspring, v1.4.0) 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 service
go.work # use ./shared/foundation, ./shared/features, ./services/*
shared/foundation/ # module "pkg" — ports, their adapters, utilities
shared/features/ # module "shared" — cross-service features, server bootstrap, access rules, test app
services/<name>/ # module "<name>" — one deployable: internal/features/<feature>/{core,inbound,outbound}
tooling/architecture/ # — the boundary checksAdapted from a production monorepo.
go 1.27.0
use (
./shared/foundation
./shared/features
./services/catalog
)
module catalog
go 1.27.0
replace (
pkg => ../../shared/foundation
shared => ../../shared/features
)
require (
github.com/uptrace/bun v1.2.18
go.uber.org/fx v1.24.0
pkg v0.0.0
shared v0.0.0
)
- The module is named after what it is —
module catalog,module pkg,module shared. With thereplacedirectives the imports read as English:import pcore "pkg/core","catalog/internal/features/projects/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. - The workspace is for speed;
GOWORK=offis the contract. Lint and unit tests run once over every module in workspace mode — one process, one build cache (measured: 6 s of lint where a per-module loop took 98 s). The Dockerfile, the build-every-service target and each per-service test target run withGOWORK=off, so a module whose owngo.modis incomplete fails locally, not in the image build. - The dependency rule between modules is one-way: services import
pkgandshared;sharedimportspkg; nothing imports a service, and no service imports another. The checking script enforces the last two. - The service is one composable value. Each has its own
cmd/server, and aservice.gokeeps its public shape small, so composing it into another process stays mechanical:
Adapted from a production service.
package catalog
import (
"go.uber.org/fx"
"shared/server"
"shared/service"
"catalog/config"
"catalog/internal/features"
)
type Service struct{}
var _ service.Module = (*Service)(nil)
func (s *Service) Module() fx.Option {
return fx.Options(
server.CoreModule("catalog", &config.TenantMigrations),
server.ServerModule,
features.Module,
server.LifecycleModule,
)
}
func (s *Service) Name() string { return "catalog" }
When not to: code only one service uses stays in that service's internal/. A
new module is for something a second service imports.
Enforcing architecture without ArchUnit
Go's answer is a checking script, not a convention. One make arch-check runs
small checks, each its own target, all runnable in CI, each catching what the
previous one cannot express.
Why not depguard or golangci for this: they cover imports only, and only by
pattern. The interesting violations — a query builder in a handler, a role read in
product code, a direct time.Now() — are shapes and calls, and the layer a file
belongs to is a fact of its path that a lint config cannot reason about.
1 — Directory structure
A shell script asserting the required directories exist: the shared packages everything expects, the rules directory, the services root. Missing fails.
The cheapest check there is, and it catches the refactor that half-moved a package.
2 — Imports, checked by a script that knows the layers
A short Python script reads every non-test .go file, works out from its path
which service, feature and layer it belongs to, parses its imports — block,
single and aliased — and fails on every forbidden edge. The module map comes from
the go.mod files, so a new service is covered without editing a rule.
Adapted from a production monorepo.
for line, imported in imports(path):
imported_service = modules.get(module_prefix(imported))
if imported_service and imported_service != current_service:
fail(line, "service imports another service", imported)
if relative.startswith("shared/") and imported_service:
fail(line, "shared code imports a service", imported)
if layer == "core":
if "/inbound" in imported:
fail(line, "core imports inbound adapter", imported)
if "/outbound" in imported:
fail(line, "core imports outbound adapter", imported)
if imported.startswith("github.com/uptrace/bun"):
fail(line, "core imports database framework", imported)
if imported.startswith("github.com/labstack/echo"):
fail(line, "core imports HTTP framework", imported)
if layer == "inbound":
if "/outbound" in imported:
fail(line, "inbound imports outbound adapter", imported)
if imported.startswith("github.com/uptrace/bun"):
fail(line, "inbound imports database framework", imported)
if layer == "outbound" and imported.startswith("github.com/labstack/echo"):
fail(line, "outbound imports HTTP framework", imported)
other = FEATURE_IMPORT.search(imported)
if feature and other and other.group(1) != feature and "/inbound" in imported:
fail(line, "feature imports another feature's inbound adapter", imported)
# One owner per wrapped library
if imported == "log/slog" and not relative.startswith("shared/foundation/util/log/"):
fail(line, "only foundation/util/log may import log/slog", imported)The loop also refuses a feature importing another feature's inbound; in
production it gives TOTP, the cache client and every other wrapped library
exactly one home.
Three things make this worth copying:
- The rule's sentence is the error message. A violation prints
path:line imports "…" — core imports database framework, so nobody has to find the rule to understand the failure. - It parses, it does not grep. An import block, an alias or a comment cannot fool it, and the script has its own tests — a check nobody tests drifts into checking nothing.
- The ownership rules are not architecture, they are ownership. Only
util/logmay importlog/slogis what makes wrapped-not-imported a fact rather than an aspiration — and it is two lines.
When not to: a convention the compiler already holds (internal/) needs no
rule.
3 — One guard per concern, and ratchets
A concern too specific for the import loop gets its own small script with its own fix message. Who may call a route is one:
Adapted from a production monorepo.
HANDWRITTEN_SHAPES = (
# The guards a rule replaces: who may call a route is the rule's, not a middleware's.
re.compile(r"\b(?:router\.)?(?:AdminRequired|AuthRequired|TenantStaffRequired)\("),
# Reading the credentials directly.
re.compile(r"\btenant\.(?:AuthFromContext|ServiceAuthFromContext)\("),
)
FIX = (
"declare the route's rule with shared/allow (e.g. allow.Staff(allow.Admin).To(h.Create)) "
"instead of deciding access in product code"
)A ratchet is how a rule lands in a codebase that already breaks it. The check
for direct time.Now() and id calls counts the existing ones in a checked-in
baseline. A new call fails it; removing one fails it too until the lower count
is written back, so the gain cannot be spent again later. The count only goes
down.
When not to: a new codebase has nothing to ratchet — make the rule absolute from the first commit.
4 — AST rules via ast-grep
For what an import check cannot express: shapes. sgconfig.yml points at a
rules/ directory of YAML:
Adapted from a production monorepo.
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"
ignores:
- "**/*_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. Its twin,
no-db-in-services, holds the same line for core/.
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.
5 — 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 every check and collects failures rather than exiting on the
first, so one run tells you everything that is broken:
Adapted from a production monorepo.
arch-check: ## Every architectural check; collects failures, fails once at the end
@$(SELF) arch-check-imports || IMPORTS_FAILED=1; \
$(SELF) arch-check-ast || AST_FAILED=1; \
$(SELF) arch-check-tests || TESTS_FAILED=1; \
if [ "$$IMPORTS_FAILED" = "1" ] || [ "$$AST_FAILED" = "1" ] || [ "$$TESTS_FAILED" = "1" ]; then \
echo "❌ Architecture checks failed"; exit 1; \
fiA check that stops at the first violation turns a ten-minute fix into ten runs.
Linting
golangci-lint v2, the default set plus errcheck, sqlclosecheck and
rowserrcheck, one configuration at the repository root for every module:
Adapted from a production monorepo.
version: "2"
linters:
enable:
- errcheck
- sqlclosecheck
- rowserrcheck
exclusions:
rules:
# A response the router already decided has nowhere to report a failed write.
- linters: [errcheck]
text: 'Error return value of .(c\.JSON|ctx\.(JSON|NotFound|Forbidden|Unauthorized|Conflict)). is not checked'
- path: _test\.go
linters: [errcheck]
issues:
max-issues-per-linter: 0
max-same-issues: 0errcheckon. A databaseClose,Scan,Exec,CommitorRollbackwhose error vanishes is the bug that matters, and it is invisible in review. An exception names the exact call intext:; the linter is never relaxed.depguardoff — architecture lives in the checking script.max-issues-per-linter: 0,max-same-issues: 0,new: false. Show every finding. Hiding findings in existing code behind an "only new issues" flag is how a codebase carries a permanent debt it never sees.
Traps
go.workmasking a brokengo.mod→ the image build fails where local passed. Build every service and run each service's tests withGOWORK=off.- 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. - A check whose exit status is swallowed.
sg scan | sed 's/^/ /'reports the status ofsed, so the AST check looked green whatever it found. Capture the status before piping, and make every check fail once on purpose before trusting it.