Go Service Standards
Go stays readable at scale only if the boundaries are real. The language gives you one strong tool for
that — the internal package and the import graph — and most codebases never use it, ending up with a
utils package that everything imports and nothing owns. The conventions below are the ones that
still matter at fifty thousand lines.
Layout
cmd/<binary>/main.go wiring only: flags, config, dependency construction, signal handling
internal/<feature>/ one package per feature; owns its handlers, service and storage
internal/platform/<x>/ genuinely shared infrastructure: db, logging, http middleware
- No
pkg/. It means "importable by anyone", which is the opposite of what a service wants. Everything goes ininternal/until an external consumer actually exists. - No
utils,common,helpersorshared. These are named after their lack of a boundary. A shared package is legitimate only when you can name what it is —platform/httpx,platform/clock— not what it is for. main.goconstructs, never decides. If it contains business logic it cannot be tested.- One feature package must not import another. Cross-feature work goes through an interface the consumer defines, or an event.
Errors
- Wrap with context at every layer boundary:
fmt.Errorf("load user %s: %w", id, err). The%wis mandatory —%vdestroys the chain. - Export sentinel errors at the package boundary (
var ErrNotFound = errors.New("not found")) and match witherrors.Is/errors.Asat the edges. Never match on error strings. - Never
panicin library code. A service that panics in a handler takes down the goroutine and often the process. Return the error. - Log an error once, where it is handled — not at every level it passes through.
- Do not create a custom error type until you need to carry structured data with it.
Context and concurrency
ctx context.Contextis the first parameter of every function that does I/O, and it is never a struct field. A context stored on a struct outlives the request it belongs to.- Do not put request-scoped values in a context beyond a trace or request id. It is an untyped map.
- Every goroutine has an owner and a shutdown path. A bare
go doWork()in a handler is a leak and a lost panic. Useerrgroup.WithContextso the first failure cancels the rest and the caller waits. - Anything long-running selects on
<-ctx.Done(). - Guard shared state with a mutex or a channel, and say which in a comment on the struct. Run the race detector in CI, not just locally.
Interfaces
- Define the interface where it is consumed, not where it is implemented. The consumer knows which three methods it needs; the implementation does not.
- Accept interfaces, return structs. Returning an interface hides the concrete type from callers who may need it and makes the API harder to extend.
- One or two methods is the target. A ten-method interface is a package boundary that was never drawn.
- No
//go:generate mockgenfor a two-method interface — write the fake by hand, it is shorter.
Enforce it with golangci-lint
Documented rules decay. Put them in .golangci.yml:
depguardis the architecture linter. Most Go developers use it to ban a package; it also enforces feature isolation. Add a rule per feature package denying imports of the other feature packages, withinternal/platform/...on the allow list. This is the single highest-value entry in the file.funlen(60 lines) andgocognit(15) for the budgets, with//nolintrequiring a reason.errcheckfor unchecked returns,contextcheckfor dropped contexts,bodyclosefor leaked response bodies,errorlintto catch%vwhere%wbelongs.goimportswith-localset to the module path so import blocks group consistently.exhaustiveif you use typed string constants as enums.
Tests
- Table-driven, with the case name as the subtest name so
-runcan target one. t.Parallel()in both the parent and each subtest, and capture the loop variable if the module is pre-1.22.t.Cleanupoverdeferin helpers.t.TempDirover manual temp files.- Test through the package's exported surface. A test in
package fooreaching into unexported internals will break on every refactor; preferpackage foo_test. - Integration tests behind a build tag or
testing.Short(), so the unit suite stays under a second.
Verification gate
-
go build ./...andgo vet ./...clean. -
go test -race ./...passes. The race detector is not optional for a service. -
golangci-lint runclean, and no linter was disabled to get there. -
go mod tidyproduces no diff. - No feature package imports another — confirm by reading the depguard rules, not by assuming.
- Paste the command output rather than describing it.