Go Architecture & Domain Modeling
Targets Go 1.26. See STACK.md for pinned dependency versions.
1. Constants & Enums
- Enums: Custom types with
iotaand an implementedString()method. Avoid bare primitives. Use1 << iotafor bitmasks. - Errors: Export package-level sentinel errors (
var ErrNotFound = ...) forerrors.Is(). Useerrors.Jointo aggregate multiple errors. - Grouping: When a file declares multiple
type,const, orvarat package level, consolidate each kind into a single parenthesized block (type (...),const (...),var (...)) rather than repeating the keyword. Keep unrelated groups separated by a blank line inside the block. - File-level ordering: type → const → var → func (decorder's default order). Enforced via code review; linter checking disabled to avoid friction with tooling defaults.
- Magic values: a bare numeric or string literal used more than once becomes a named constant. Enforced by
mnd(numbers) andgoconst(strings) (see §14).
2. Structures & Memory
- Design: Explicit struct tags, standard audit fields, composition via embedding. Keep tag casing (
json,yaml) consistent across the codebase —tagliatellecatches drift, opt-in and scoped since legacy tags often can't be renamed without a breaking change (see §14). - Optimization: Order fields largest → smallest to minimize padding.
- Semantics: Pointers for mutation or mutex-protected state; values for small, immutable payloads.
- Validation:
Validate() errorfor structs taking external input; tag-based validation viago-playground/validator. - Ordering: Use
cmp(cmp.Compare[T],cmp.Or,cmp.Less) as the default ordering toolkit. - Typed models over maps: Prefer a defined struct to
map[string]any/map[string]interface{}for shaped data — the compiler catches typos and missing fields a map silently swallows.forbidigoflags the anti-pattern (see §14);exhaustruct(opt-in, scoped to specific structs) catches incomplete struct literals once you've made the switch.
3. Instantiation
- Constructors: Always provide
New...to initialize maps, slices, channels safely. - Configuration: Functional Options pattern for complex setup; never large config structs passed by value.
4. Interfaces
- Design: Define interfaces where they're used, not where implemented. Keep them small (1–3 methods) — enforced by
interfacebloat(see §14). - Signatures: Accept interfaces (for easy mocking); return concrete structs — enforced by
ireturn(see §14).
5. Stdlib defaults
Prefer stdlib over hand-rolled or third-party when stdlib now covers it.
slices,maps— collection helpers (Sort,Contains,Index,Clone,Equal,Concat, etc.)cmp—Compare,Less,Or,Ordered— the default ordering toolkit.iter—iter.Seq[T],iter.Seq2[K,V]for sequence-shaped returns (see §6).log/slog— structured logging; default for new code. Reach forzap/zerologonly when slog's allocator perf is provably insufficient.errors—errors.Is,errors.As,errors.Join.net.JoinHostPort/net.SplitHostPort— idiomatic host:port assembly (enforced bynosprintfhostportlinter). Neverfmt.Sprintf("%s:%d", host, port).
6. Iterators (Go 1.23+)
- Range-over-func:
for x := range seq { ... }whereseqisiter.Seq[T]. - Return
iter.Seq[T]when: the sequence is large/unknown-size, consumer might short-circuit, or it's backed by an underlying cursor (DB pagination, HTTP pages). - Return
[]Twhen: the result is small, bounded, and the caller almost always wants the whole thing. - Anti-pattern: wrapping a
[]Tinslices.Valuesjust to look modern. Round-tripping back toslices.Collectis wasted ceremony.
7. Concurrency
- State: Embed
sync.RWMutexdirectly above the fields it protects.sync/atomicfor simple counters. - Context fields: Never embed
context.Contextin structs (containedctxlinter enforces this). Pass it as a parameter; contexts are designed as function arguments, not persistent state. - Duration: Use
time.Durationprimitives; never multiply durations (durationcheckcatchestime.Second * 60— usetime.Minuteinstead). - Context nesting: Avoid nested contexts in loops or closures (
fatcontextcatches this anti-pattern). - WaitGroup: Use
sync.WaitGroup.Go(fn)(Go 1.25+) — replacesAdd(1) / go func() { defer Done(); ... }()boilerplate. - Context: Pass
context.Contextas the first parameter for any blocking or I/O. Usesignal.NotifyContextfor shutdown; the cancellation cause now records which signal fired (Go 1.26). - Containers:
GOMAXPROCSrespects cgroup CPU limits since Go 1.25 — don't hardcode in containerized deploys.
8. Errors & Panics
- Checking:
errors.Asfor custom types;errors.Isfor sentinels. Never force type assertions on error results — useokcheck orerrors.As(forcetypeassertenforces this). - Aggregation:
errors.Join(errs...)when collecting multiple errors (validation, parallel fan-out). - Wrapping:
fmt.Errorf("op: %w", err)to add context while preserving the chain. When wrapping external package errors, usewrapcheckto verify proper context addition (prevents "swallowing" errors). - Panics: Restrict to initialization (
MustCompilestyle). Never as control flow.
9. Testing
- Patterns: Table-driven tests and black-box testing (
package mypkg_test). Parallelize tests witht.Parallel()— checked and enforced byparalleltestandtparallellinters. Avoid deprecated testing APIs (ioutil.TempDir→t.TempDir());usetestingenforces modern stdlib testing functions. - Helpers:
t.Helper()as the first line in any custom assertion — enforced bythelper. - Concurrent code: Use
testing/synctest(GA in Go 1.25) — virtualizes time, waits for goroutines to quiesce, makes async tests deterministic. - Library:
stretchr/testifyfor assertions/mocks/suites when stdlib alone is awkward. Usetestifylintto catch incorrect assertion patterns (assertvsrequire). - Integration tests against Docker dependencies:
testcontainers-go— spins up real Postgres/Redis/Kafka/etc. containers per test run instead of mocking the driver or relying on a shared dev instance. Gate behind a build tag (e.g.//go:build integration) ortesting.Short()check sogo test ./...stays fast by default.
10. Packages
- Naming: Short, lowercase, single-word. Never
util,common,helpers. - Layout: Organize by domain/feature, not by technical layer.
- Domain modeling & interface placement: See hexagonal-arch for ports/adapters and ddd-architect for aggregates/bounded contexts. For
go.workmulti-module layout, see §16.
11. Dependencies & Logging
- Injection: Pass dependencies via constructors. No global state, no package
init()setups. For larger graphs useuber-go/fxorgoogle/wire. - Config:
spf13/viperfor layered env + file + flag configuration. - Logging:
log/slog(stdlib). Structured, context-aware.zap/zerologonly when slog is provably the bottleneck — start with slog.
12. Database access — SQL files + //go:embed
Strong preference: raw SQL in .sql files, embedded at compile time, executed via jmoiron/sqlx. Avoids ORM magic, keeps queries auditable in git, gives editors full SQL syntax highlighting and linting.
//go:embed queries/get_user_by_id.sql
var getUserByIDSQL string
func (r *UserRepo) GetByID(ctx context.Context, id int64) (User, error) {
var u User
err := r.db.GetContext(ctx, &u, getUserByIDSQL, id)
return u, err
}
Layout:
internal/userrepo/
├── repo.go
└── queries/
├── get_user_by_id.sql
├── insert_user.sql
└── list_users.sql
- Migrations:
golang-migrate— versioned up/down pairs inmigrations/. - Dynamic queries: If a query needs runtime composition (optional filters), still write the static fragments in
.sqlfiles and join them in Go. Avoid building SQL by string concatenation against user input — use parameter binding.
13. Generics
- Usage: Data structures and utility functions only. If an interface would serve, prefer it.
- Self-referential constraints (Go 1.26):
type Adder[A Adder[A]] interface { Add(A) A }is valid — useful for fluent-style domain types. - Type aliases with type params (Go 1.24):
type IntMap[V any] = map[int]V.
14. Tooling
- Lint:
golangci-lintv2 — a single binary that aggregatesstaticcheck,govet,gofmt,goimports,revive, and dozens more. Pin via.golangci.yml(v2 config schema). Run on every commit and in CI. Treat warnings as errors. Drop-in template:assets/golangci.yml— copy to your project root as.golangci.ymland setgoimports.local-prefixesto your module path.- Essential linters:
govet,staticcheck,unused,gocritic,revive,errorlint,gosec,misspell,sloglint,godoclint. - Code quality:
nolintlintvalidates that//nolintdirectives are specific and actually suppress something — enforces intentional, clean ignores.varnamelenenforces minimum variable name length (catchesx,ioutside loops; configuremax-distance: 5, min-name-length: 1).errnameensures error types follow convention (ErrFoo,FooError) — complements §1's sentinel error pattern.stylecheckenforces naming conventions across the codebase (e.g., exported/unexported consistency). - Idiomatic patterns:
preallocdetects slices that could pre-allocate capacity — idiomatic Go performance practice.reassignflags package variable reassignment in favor of const/new-var idiom.makezerocatches unintended zero-length slice declarations.predeclaredprevents shadowing Go builtins.looppointerdetects address-of-loop-variable bugs (memory safety).recvcheckensures method receiver types are consistent within an interface.dogsledflags excessive blank identifiers (e.g.,_, _, _, v := ...suggests destructuring instead).mirrorcatches bytes/strings type mismatches (e.g., comparing[]bytetostringidiomatically).exptostdreplacesgolang.org/x/exppackages with stdlib equivalents — use stdlib for idiomatic Go.modernizesuggests modern Go simplifications (range-over-int, slices API, etc.). - Duplication & magic values:
dupl(structural clones),goconst(repeated string literals),mnd(magic numbers) — all push toward named constants/enums (see §1). - Complexity & size:
nestif,gocognit(cognitive complexity),maintidx(maintainability index), plus revive'sfile-length-limitandargument-limitrules — catch functions/files that should be split before they rot. - Declaration structure:
decorderenforces file-leveltype → const → var → funcordering (decorder's default). Setdisable-dec-order-check: falsein your.golangci.ymlto enable (see §1).gochecknoinitsflagsinit()outright (see §11). - Interfaces & enums:
interfacebloatcaps interface method count,ireturnenforces concrete returns (see §4);exhaustiveforces enum-typedswitchstatements to cover every value;forbidigobansmap[string]interface{}/map[string]anyin favor of typed structs (see §2) — requires careful regex tuning for your codebase (consider enforcing struct typing via code review if regex patterns get unwieldy; template includes reference patterns inassets/forbidigo-patterns.yml). - modernize (standalone linter, golangci-lint v1.57+): all checks on by default — catches
range n(Go 1.22+),slices.Sort,slices.Contains,maps.Keys,maps.Values, etc. If too noisy, constrain viamodernize.settings(e.g.,forRangeInts: trueonly). - intrange (standalone linter): explicitly enabled — flags
for i := 0; i < n; i++whererange nis cleaner. Lightweight alternative to themodernizecoverage. - copyloopvar / perfsprint: modernization nudges —
copyloopvarflags now-redundantx := xloop-variable copies (Go 1.22+ semantics),perfsprintsuggests faster alternatives tofmt.Sprintf/fmt.Errorf(e.g.strconv,err.Error()). Both low-noise, safe to enable by default. - Optional: Scoped Linters (enable when appropriate for your project):
exhaustruct: Flags incomplete struct literals; scoped to config/DTO structs to manage noise. Enable once you've migrated to fully-typed structs (see §2).tagliatelle: Enforces struct-tag casing consistency (json/yamlnaming). Scope to new code since legacy tags often can't be renamed without breaking changes; useful when refactoring APIs (see §2).depguard: Controls allowed imports (e.g., no test frameworks in prod code). Requires careful allowlist configuration per project structure — enable after establishing import boundaries.
- Testing linters:
paralleltest: Detects tests missingt.Parallel()— encourages test suite parallelization for faster feedback.tparallel: Catches inappropriatet.Parallel()usage in subtests, preventing race conditions and serialization bugs.thelper: Ensures test helper functions callt.Helper()— prevents test output pollution and clarifies stack traces.testifylint: Best practices for testify assertions — flags incorrectassertvsrequireusage and other testify pitfalls (see §9).usetesting: Replaces deprecated testing helpers (e.g.,ioutil.TempDir→t.TempDir()) with modern stdlib equivalents.
- Correctness & safety patterns:
containedctx: Detectscontext.Contextembedded in structs — pass as parameter instead (see §7).durationcheck: Prevents duration multiplication bugs (e.g.,time.Second * 60vstime.Minute).forcetypeassert: Flags forced type assertions withoutokcheck — useerrors.Asor check the boolean (see §8).fatcontext: Detects nested contexts in loops/closures — anti-pattern that escapes context cancellation.mirror: Catches bytes/strings type mismatches; encourages idiomatic type usage.exptostd: Flagsgolang.org/x/exppackages — use stdlib equivalents instead (core idiom).wrapcheck: Ensures external package errors are wrapped with context; prevents "swallowing" errors (see §8).unparam: Reports unused function parameters — aids cleanup (can be noisy during refactoring).funcorder: Enforces consistent function/method/constructor ordering within types.modernize: Suggests modern Go simplifications (range-over-int, slices API, maps functions, etc.) — complement tocopyloopvar+perfsprint.
- Template: The
assets/golangci.ymltemplate includes sane defaults for all recommended linters with tested configuration values forlinters.settings. Copy it to.golangci.ymland customize thresholds (e.g.,gocognit.maxfor cognitive complexity) to match your project's complexity profile.
- Essential linters:
- Doc-comment coverage (opt-in): the main config disables revive's
exported/package-commentsrules — doc coverage isn't part of the standard gate (see §15). Run it on demand with the secondary configassets/golangci.doccheck.yml:golangci-lint run -c golangci.doccheck.yml ./.... - Repo-level style guards (opt-in, not golangci-lint): two conventions golangci-lint has no linter for — no one-line function bodies, no decorative separator comments (
// ──,# ═══). Ship as shell scripts, not lint rules, since existing codebases likely have violations needing a separate cleanup pass first:assets/check-no-oneline-functions.sh,assets/check-no-separator-comments.sh. Wire each into its own task-runner target (seerepo-tooling-architect§4 for Task/just setup) rather than the main lint gate, so a legacy-codebase failure doesn't block unrelated CI. - Modernization: Run
go fixperiodically (revamped in Go 1.26 as push-button modernizers). It rewrites legacy patterns toward current stdlib APIs and respects//go:fix inlinedirectives for local refactors. Ongoing hygiene, not a one-off. - Format:
gofmt(built-in) plusgoimports(viagolangci-lint) — no other formatter, no debate. - Build/run:
go build,go test,go vet,go workfor multi-module repos. Stdlib only — avoid Make/Task wrappers unless multi-language CI demands it.
15. Documentation
- Format: Exported names get full-sentence comments starting with the identifier name. Include a package-level comment.
- Style: Weave parameter and return names into prose (no
@param). Document specific error conditions. - Focus: Explain why (business rules, edge cases) — code already shows what.
16. Multi-module workspaces (go.work)
For repos where go.work ties together independently-versioned modules — one per bounded context, plus shared libraries — rather than a single go.mod.
- Module boundary is the real privacy/reuse unit. Once something is its own module (own
go.mod), nesting it under a directory namedinternal/adds nothing — Go'sinternal/import restriction is a single-module-tree mechanism and does nothing extra for something already module-isolated. Treat the directory name as organizational labeling, not enforcement. If a service module needs a genuine internal-only boundary, nest a furtherinternal/inside that module (internal/order/internal/wire/). pkg/*— each subdirectory its own module, for code genuinely shared by 2+ service modules (validation, observability, an RFC 7807 problem-detail mapper). Don't default to creatingpkg/; per hexagonal-arch §7, a shared seam is only real once two consumers need it.- Root-level
domain— only for value objects/types genuinely shared across bounded contexts (e.g. aMoneytype used by both Order and Payment). Most contexts should keep their domain private inside their own service module. A shared rootdomainmodule is a deliberate Shared Kernel (see ddd-architect's context-mapping table), not a default. internal/<service>— one bounded context = one module. Internally structured per hexagonal-arch:domain/,app/,adapters/{primary,secondary}— or flatter, since hexagonal-arch mandates the dependency direction, not the folder split. "Service" here means the deployable module itself, not ddd-architect's Domain/Application Service patterns — a singleinternal/<service>module typically contains several Application Services inside itsapp/layer (e.g.PlaceOrderService).cmd/<service>vsapp/<service>— keep distinct rather than merging composition intocmd.cmd/<service>/main.gostays minimal: parse flags/env, wiresignal.NotifyContext, call intoapp, handle the exit code.app/<service>is the actual composition root — DI graph, adapter wiring, server lifecycle — so it's exercisable independently of process-level concerns.- Cross-context calls never reach into another context's
domain/oradapters/. They go through that context's public API (HTTP/gRPC, since each service is a separate deployable in a multi-module workspace). The calling context defines its own ports shaped for what it needs (OrderCreator, notorder.Repository) — the same "define interfaces where used" rule from §4, applied across a context boundary. This is the Go-flavored version of ddd-architect's Customer/Supplier or Open Host Service patterns. - Full worked example — an order-owning service plus a checkout orchestrator service, with code — lives in RECIPES.md.
Canonical libraries
See STACK.md for the full pinned list — viper, validator, cobra/pflag, gin, sqlx, grpc, protobuf-go, testify, fx, golang-migrate, buf.