TheSkillz

Go Service Standards

Layout, errors and concurrency for a Go service that stays readable

TheSkillz Team TheSkillz Team No reviews yet0 installsv1.0.0
Scan passed · 100/100Human reviewedOfficial · TheSkillz
☆ Star 0

Applies cmd/internal layout with no pkg dumping ground and no utils package, error wrapping with %w and sentinel errors at boundaries, goroutine ownership via errgroup, and consumer-defined interfaces. Generates a golangci-lint config where depguard enforces feature isolation rather than just banning packages.

SKILL.md

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 in internal/ until an external consumer actually exists.
  • No utils, common, helpers or shared. These are named after their lack of a boundary. A shared package is legitimate only when you can name what it isplatform/httpx, platform/clock — not what it is for.
  • main.go constructs, 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 %w is mandatory — %v destroys the chain.
  • Export sentinel errors at the package boundary (var ErrNotFound = errors.New("not found")) and match with errors.Is / errors.As at the edges. Never match on error strings.
  • Never panic in 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.Context is 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. Use errgroup.WithContext so 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 mockgen for 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:

  • depguard is 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, with internal/platform/... on the allow list. This is the single highest-value entry in the file.
  • funlen (60 lines) and gocognit (15) for the budgets, with //nolint requiring a reason.
  • errcheck for unchecked returns, contextcheck for dropped contexts, bodyclose for leaked response bodies, errorlint to catch %v where %w belongs.
  • goimports with -local set to the module path so import blocks group consistently.
  • exhaustive if you use typed string constants as enums.

Tests

  • Table-driven, with the case name as the subtest name so -run can target one.
  • t.Parallel() in both the parent and each subtest, and capture the loop variable if the module is pre-1.22.
  • t.Cleanup over defer in helpers. t.TempDir over manual temp files.
  • Test through the package's exported surface. A test in package foo reaching into unexported internals will break on every refactor; prefer package foo_test.
  • Integration tests behind a build tag or testing.Short(), so the unit suite stays under a second.

Verification gate

  • go build ./... and go vet ./... clean.
  • go test -race ./... passes. The race detector is not optional for a service.
  • golangci-lint run clean, and no linter was disabled to get there.
  • go mod tidy produces no diff.
  • No feature package imports another — confirm by reading the depguard rules, not by assuming.
  • Paste the command output rather than describing it.

Reviews

Sign in to leave a review.

  • Be the first to review this skill.

More in coding