go-architect
Go 1.26 architectural standards — memory-aligned structs, typed enums, interface design, goroutine safety, iterators, idiomatic errors, sqlx + //go:embed SQL pattern, go.work multi-module layout. Use
SKILL.mdskills/languages/go-architect/SKILL.md
GitHubGo Architecture & Domain Modeling
Targets Go 1.27.1. 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.
- Directory = package. Splitting a package to hit a file count changes the import path, package name, and exported surface — split on a real seam, not on size. Many well-named files in one package is normal (
net/http,go/types); the folder's navigability, not the count, is the trigger. See code-design-refactor §4. - 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. - The container lives at the composition root, never in a library. A library exposes
New(deps...)and never importsfx/wire— doing so drags the container into every consumer. When a graph is large, the deployable owns the container (app/,internal/bootstrap,cmd/); libraries stay constructor-only. Optional courtesy forfxusers: ship anfx.Modulein a dedicated subpackage (<pkg>/fxmod) that groups thefx.Provides for the library's constructors — whoever usesfxincludes it, whoever doesn't callsNewby hand, and the library stays DI-agnostic. - Ports and constructors live in the packages; the container lives at the edge. The rest of the code sees only interfaces — the DI framework never leaks past the composition root (see hexagonal-arch §2).
- 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 — sqlc (default), sqlx + //go:embed (alternative)
Default: sqlc. Schema-first, code-generated queries — write named queries in .sql files against your real migrated schema, run sqlc generate, get back typed Go row structs and query methods, checked at generate-time against the actual database. No ORM magic, no hand-written scanning code, queries stay plain auditable SQL in git.
-- query.sql
-- name: GetUserByID :one
SELECT * FROM users WHERE id = $1;
# sqlc.yaml
version: "2"
sql:
- engine: "postgresql"
queries: "query.sql"
schema: "migrations"
gen:
go:
package: "db"
out: "db"
func (r *UserRepo) GetByID(ctx context.Context, id int64) (db.User, error) {
return r.queries.GetUserByID(ctx, id)
}
Layout:
internal/userrepo/
├── repo.go
├── sqlc.yaml
├── query.sql
└── db/ # generated: models.go, query.sql.go, db.go — never hand-edit
- Migrations:
gooseorgolang-migrate— versioned up/down pairs inmigrations/.sqlc'sschema:reads the same migration files, so schema drift between the two is impossible by construction. - Regeneration: run
sqlc generatein CI (or a pre-commit hook) and fail the build if the generated tree differs from what's committed — generated code is committed, not built on the fly.
Alternative: jmoiron/sqlx + //go:embed. Reach for this when a query needs genuine runtime composition sqlc's static named-query model can't express (e.g. an arbitrary number of optional filter clauses), or when a package deliberately avoids a codegen step. Write raw SQL in .sql files, embed at compile time, execute via sqlx's struct-tag scanning:
//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
- Dynamic queries: even here, write the static fragments in
.sqlfiles and join them in Go rather than building SQL by string concatenation against user input — always use parameter binding. - Don't mix the two within one repository package — pick per-package, not per-query, so a reader knows which scanning convention to expect from the file layout alone.
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. - 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.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:
Dead code (
deadcode, opt-in, not golangci-lint):unusedonly flags unused unexported symbols, so an exported function nomainreaches passes the lint gate forever. Rundeadcode -test ./...(golang.org/x/tools/cmd/deadcode) so tests count as roots — without-testevery test helper looks dead. It does not follow dynamic dispatch (cobraRunE, handler funcs stored in maps), so confirm each hit before deleting. Re-run after a deletion pass: removing one function can be the last use of another. Wire it into its own task target rather than the lint gate, since it needs amainto root from.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 9457 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. - Scaling past a handful of contexts — grouping, flat-by-default features, the rank rule and what large Go projects do: see §17.
- Full worked example — an order-owning service plus a checkout orchestrator service, with code — lives in RECIPES.md.
17. Layout at scale (large repos)
§16 is the default. For a repo that has outgrown it, there is no single correct layout — only better fits for a project's shape. LAYOUTS.md is the catalogue (library, flat by concept, feature-first, escalated, grouped at the root, multi-module, layer-first), each with its trade-off, plus sections on module count, process types, generated code, cross-context communication, tests, ownership and layout smells, and a per-project review checklist. Review it per project and re-review when contexts, binaries or teams change.
Principles that hold across them:
- Default to the lightest layout that fits. Feature/context first, flat by default; escalate to
core/+ use cases +adapters/<tech>/only when a feature is domain-heavy. - Implementations are sibling subpackages named by technology; interfaces are declared next to their consumer.
- Enforce the dependency direction with a test, not prose — rank the layers, use
**/path patterns, record exceptions. - Group contexts at the root by area (not a generic
contexts/) past ~7; extract a group as a module past ~8–10 or with its own team. - No infrastructure in the model; wire schemas in
contracts/; no stutter; name the composition root for what it is, notapp. - Layer-first (
core/<ctx>,services/<ctx>,adapters/<ctx>) is a valid but suggestive option — it trades context extractability for a trivial rank rule.
Canonical libraries
See STACK.md for the full pinned list — viper, validator, cobra/pflag, gin, sqlx, grpc, protobuf-go, testify, fx, golang-migrate, buf.