---
name: go-service-standards
description: Applies package layout, error, context and concurrency conventions for a Go service, and generates a golangci-lint config where the architecture rules are actually enforced rather than documented. Use when starting a Go service, restructuring packages, reviewing Go code, or setting up Go linting in CI.
license: MIT
compatibility: Go 1.21+. Requires permission to run go build, go test and golangci-lint.
metadata:
  category: coding
  version: "1.0.0"
---

# 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 *is* — `platform/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.
