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.
2. Structures & Memory
- Design: Explicit struct tags, standard audit fields, composition via embedding.
- 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.
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).
- Signatures: Accept interfaces (for easy mocking); return concrete structs.
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.
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. - 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. - 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. - Panics: Restrict to initialization (
MustCompilestyle). Never as control flow.
9. Testing
- Patterns: Table-driven tests and black-box testing (
package mypkg_test). - Helpers:
t.Helper()as the first line in any custom assertion. - 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.
10. Packages
- Naming: Short, lowercase, single-word. Never
util,common,helpers. - Layout: Organize by domain/feature, not by technical layer.
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. - 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.
Canonical libraries
See STACK.md for the full pinned list — viper, validator, cobra/pflag, gin, sqlx, grpc, protobuf-go, testify, fx, golang-migrate, buf.