Architect
Standards
ReferenceNormative

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 into ErrProjectNotFound, 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.Is and errors.As — never err == E, which breaks the moment anything wraps.
  • Wrap with %w when adding context. A bare fmt.Errorf loses the code.
  • No silent errors. errcheck is 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.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.

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:, no db:, and no json: either: the inbound response type owns the wire names. 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. Example: outbound/models.go.
  • decimal.Decimal (shopspring, v1.4.0) 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 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 checks

Adapted 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 the replace directives 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=off is 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 with GOWORK=off, so a module whose own go.mod is incomplete fails locally, not in the image build.
  • The dependency rule between modules is one-way: services import pkg and shared; shared imports pkg; 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 a service.go keeps 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/log may import log/slog is 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; \
	fi

A 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: 0
  • errcheck on. A database Close, Scan, Exec, Commit or Rollback whose error vanishes is the bug that matters, and it is invisible in review. An exception names the exact call in text:; the linter is never relaxed.
  • depguard off — 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.work masking a broken go.mod → the image build fails where local passed. Build every service and run each service's tests with GOWORK=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 adds fmt.Errorf("...: %w", err) → the branch silently stops matching and a 404 becomes a 500. Always errors.Is.
  • A check whose exit status is swallowed. sg scan | sed 's/^/ /' reports the status of sed, 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.

On this page