Architecture & Design Best Practices
Evolving Go codebases without big-bang rewrites.
These habits keep services testable, modules publishable, and refactors incremental as requirements change.
How to Use This List
- Adopt during greenfield setup and before each major version or service split.
- Enforce with code review,
golangci-lintdepguard rules, and architecture office hours. - Pair with ADRs when a rule is controversial or costly short term.
- Revisit when team size, compliance scope, or traffic order of magnitude shifts.
A - Boundaries and dependencies
- Keep composition in
cmd/only.mainconstructs concrete types; libraries exposeNewfunctions. - Define interfaces at consumers. One- or two-method ports beat provider-side interface dumps.
- Use
internal/for non-public implementation. Let the compiler enforce module boundaries. - Ban import cycles in production code. Cycles signal wrong seam - extract contracts or invert deps.
- Pass
context.Contextthrough request paths. Cancellation belongs on service and repository methods.
B - Layering and packages
- Handlers adapt transport; services own rules. No SQL or vendor SDK calls in HTTP handlers.
- Organize by feature when teams align to domains.
internal/billingbeats layer-only folders that cross-import. - Map DTOs at edges. JSON, protobuf, and SQL rows stop at adapters - domain structs stay tag-free.
- Keep frameworks at the rim. Routers and ORMs must not leak into domain packages.
- Prefer errgroup for bounded concurrency. Tie goroutine lifetime to request
context.
C - Modules, APIs, and evolution
- Run
go mod tidybefore merge. Clean graphs reduce surprise upgrades in CI. - Tag library releases with semver. Major breaks get
/v2module paths and migration notes. - Write ADRs for boundary moves. Splits, DI style, and datastore choices need a paper trail.
- Refactor in vertical slices. Extract one feature end-to-end before horizontal "big bang" moves.
- Measure before microservice splits. CPU skew, release coupling, or compliance drive splits - not aesthetics.
D - Testing and quality gates
- Unit test services with fakes, not Docker. Reserve integration tests for adapter boundaries.
- Use sentinel errors and
%wwrapping consistently. Handlers map witherrors.Is, not string compare. - Run
go test ./...andgo vet ./...in CI. Addgolangci-lintwith depguard for globals and cycles. - Contract-test RPC and event schemas. Buf breaking checks or consumer-driven tests prevent drift.
- Benchmark hot paths before optimizing architecture. Profile before introducing caches, shards, or services.
E - Operations and observability
- Configure structured logging at startup.
slogor team standard with trace/request IDs at edges. - Export OpenTelemetry from day one. Traces across handlers, DB, and outbound HTTP simplify incidents.
- Set HTTP server timeouts and body limits. Protect against slowloris and oversized uploads.
- Ship static binaries from CI. Same artifact from laptop to production; no on-server
go build. - Document graceful shutdown in
main. Drain in-flight requests on SIGTERM before exit.
FAQs
Which practices matter most on day one?
Composition in cmd/, handler-service split, internal/, and context on I/O.
Add ADRs and OTel before multi-team scale.
How do I enforce depguard without blocking tests?
Allow test-only packages or build tags for integration helpers.
Block var globalDB in non-cmd paths first.
When is a wide interface acceptable?
When it mirrors a stable external API you cannot split (cloud SDK facade).
Wrap and narrow at your boundary.
Should every repo use Wire?
No.
Manual wiring until duplication hurts; ADR the switch to codegen.
How do best practices change for libraries?
Stricter exported API discipline, semver tags, and minimal dependencies.
No main - consumers wire your constructors.
What about generated protobuf in domain?
Keep generated code in api/ or gen/ packages.
Map into domain types in adapters.
How often to rerun architecture audit?
Quarterly or after a Sev1 caused by coupling/globals.
Use the refactoring checklist for scoring.
Can I skip hexagonal folders?
Yes - practices care about dependency direction, not folder names.
How do practices interact with Go 1.26 toolchain?
Use go fix modernizers and current go directive in go.mod.
ADR major toolchain bumps.
What is the biggest anti-pattern to veto in review?
New global mutable state for request-scoped dependencies.
Reject unless ADR explains unavoidable legacy.
Related
- Architecture in Go: Small Interfaces, Explicit Dependencies - Conceptual foundation
- Go Architecture Basics - Runnable starting examples
- Refactoring & Architecture Audit Checklist - Find debt to fix
- Architecture Decision Records for Go Teams - Decision templates
- Handler-Service-Repository Layering - Default layering pattern
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).