Go Development Patterns
Language-level rules for production Go and the test suite that proves it: how types are shaped, how errors travel,
how goroutines are stopped, how a module is laid out, and how it is tested. Go rewards the boring version, so a
reader should follow control flow top to bottom without unwinding a closure. The hub carries the rules that decide
most reviews, each reference file the depth for one area.
Baseline: Go 1.25, the current stable toolchain. Confirm with go version before assuming an older release: the
examples use per-iteration loop variables, any, log/slog, and testing/synctest with no compatibility note.
When to activate
- Writing, reviewing, or refactoring a Go package, command, or service.
- Designing an interface, a constructor, or a package boundary, or chasing a goroutine leak or a data race.
- Setting up
golangci-lint, go vet, and the formatting gate.
- Writing table tests, fakes, benchmarks, or fuzz targets, or raising coverage on a package.
When not to activate
- Applying the cross-language design floor of SOLID, DRY, and naming. Use
coding-standards.
- Applying the language-neutral red-green-refactor loop and the test pyramid. Use
tdd-workflow.
- Setting up log formats, metrics, tracing, or health endpoints. Use
observability-and-logging.
- Designing the HTTP or gRPC contract the service serves. Use
api-design.
- Profiling a slow path before changing it. Use
performance-optimization.
- Driving a browser through a user journey. Use
e2e-testing.
Make the zero value useful, and inject what it cannot hold
A type whose zero value works needs no constructor, cannot be half-initialised, and composes into other structs for
free, while a nil map or a nil channel inside a struct panics on first use. When a field genuinely cannot have a
useful zero, give the type a constructor, keep the field unexported so it cannot be skipped, and take the dependency
as an argument. A package-level *sql.DB initialised in init() cannot be swapped in a test, cannot fail loudly at
startup, and ties every consumer of the package to one instance.
// BAD
type Counter struct {
counts map[string]int
}
var db *sql.DB
func init() {
db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))
}
// GOOD
type Counter struct {
mu sync.Mutex
count int
}
func NewServer(db *sql.DB) *Server {
return &Server{db: db}
}
Accept interfaces, return structs, and declare them at the consumer
An interface parameter lets a caller pass anything that fits, while an interface return hides the concrete type for
no benefit and blocks the caller from reaching a method the interface does not declare. The consumer also knows what
it needs: an interface declared next to the implementation grows to mirror the struct, and every consumer then
depends on methods it never calls.
// BAD. Declared in package postgres, mirroring the repository struct.
type UserRepository interface {
GetUser(ctx context.Context, id string) (*User, error)
SaveUser(ctx context.Context, u *User) error
Migrate(ctx context.Context) error
}
// GOOD. Declared in package service, holding only what the service calls.
type UserStore interface {
GetUser(ctx context.Context, id string) (*User, error)
}
func ProcessData(r io.Reader) (*Result, error)
Doc comments: default to none
A doc comment is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block
into a well-named function, rename the parameters so they carry their own meaning, and tighten the types. Do that
first and most doc comments have nothing left to say, which is the outcome you want. Code that explains itself cannot
go stale, a comment can.
When one is still genuinely needed, the prose is capped at five lines and is usually one. Every note you add about a
parameter, the result, or an error is capped at one line and only appears when it genuinely adds something: if the
note does not fit on a single line, shorten it or drop it. Four rules decide what goes in. Exported identifiers are
not an automatic exception: a linter wanting a comment on every exported name is not a reason to write a sentence
that adds nothing.
- Prose. One sentence, starting with the identifier name, saying what it does, then only what a caller cannot infer
from the signature. Nothing more.
- Describe a parameter only when the name and the type do not already convey it, meaning units, nullability, a valid
range, or who owns it afterwards.
orderID is the order identifier is noise, delete it.
- Describe the result only when it is non-obvious.
- Describe the error conditions always, every one a caller can act on, and name the sentinel errors it can match
with
errors.Is. The signature says only error, so this one is genuinely contract rather than decoration.
Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for
example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you
justify in review, not a budget to spend. The one-line cap on a note line has no exception at all: shorten it or
delete it.
// GOOD. One sentence, then only what the signature cannot carry, one line per note.
// Reserve holds stock for an order until the payment window closes.
// holdFor is capped at 15 minutes.
// Returns ErrInsufficientStock when the warehouse cannot cover the order.
func (w *Warehouse) Reserve(orderID OrderID, holdFor time.Duration) (Reservation, error)
The failing shape on the same function is Reserve reserves stock. It takes an order ID and a hold duration and returns a reservation and an error., which restates the signature and says nothing about the failure mode.
Wrap every error with context
%w keeps the original error matchable by errors.Is and errors.As while adding the operation that failed. Naked
returns of a library error give a caller a message with no idea which call produced it. Write the wrap message as a
lowercase operation phrase with no trailing punctuation, so the chain reads as one sentence when it is finally
printed. _ on an error is a decision to continue with unknown state: handle it, wrap it, or, where nothing can be
done, assign it explicitly so the choice is visible in review. Sentinel errors, custom types, and matching are in
references/errors.md.
// BAD
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, err
}
// GOOD
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
Take a context first, and never start a goroutine you cannot stop
A context.Context is the first parameter, never a struct field, and every blocking call in the function passes it
down so a cancelled request stops work instead of finishing it for nobody. That context is also the usual way a
goroutine ends: every one needs a defined way out, whether a closed channel, a cancelled context, or a WaitGroup
the caller waits on. A goroutine blocked forever on an unbuffered send is a leak that only shows up as memory growth
in production. Worker pools, errgroup, and graceful shutdown are in
references/concurrency.md.
// BAD. Context parked on a struct, and a send nothing can cancel.
type Request struct {
ctx context.Context
}
go func() { ch <- data }()
// GOOD
func FetchUser(ctx context.Context, id string) (*User, error)
go func() {
select {
case ch <- data:
case <-ctx.Done():
}
}()
Test it, and watch the test fail first
Write the signature, write the test, watch it fail, then implement. A test written after the code passes on the first
run has never demonstrated it can catch anything. A table plus t.Run is the default shape, because the report then
names the input that broke rather than a line number, and the message reads got X; want Y with the input in it.
// BAD
func TestAddPositive(t *testing.T) { ... }
func TestAddNegative(t *testing.T) { ... }
// GOOD
for _, tt := range []struct {
name string
a, b int
want int
}{{"positive", 2, 3, 5}, {"mixed signs", -1, 1, 0}} {
t.Run(tt.name, func(t *testing.T) {
if got := Add(tt.a, tt.b); got != tt.want {
t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)
}
})
}
Fake a dependency with a hand-written struct satisfying the interface the consumer declared, never with a mock
framework that matches method names by string. Fake only what you cannot run: a payment API is fair, the database is
not when a container or an in-memory engine exercises the real query.
Cover around 90 percent of the real logic, 100 percent on critical business logic, and exclude only generated output
such as protobuf stubs. A failing coverage gate is a signal to add the missing test, never to lower the threshold.
Run the suite the way CI runs it, from the module root, with the race detector on:
go test -race -coverprofile=coverage.out ./...
Test shape, t.Cleanup and t.Context, testing/synctest instead of time.Sleep, parallel subtests, benchmarks,
and fuzzing are in references/testing.md.
Startup readiness log
The banner, the section order, the 2-second probe timeout, and the <url> [Connected|Warning|FAILED] result format
are one convention shared by every language, owned by observability-and-logging. What is Go-specific is where it is
emitted and that it goes through one log/slog call with a leading newline, because slog stamps a timestamp and
level per call and per-line emission would shred the banner, so the whole block is one logger.Info of the built
string. Probe with http.Client{Timeout: 2 * time.Second} so an unreachable dependency cannot stall startup, log the
detail at debug with slog.Debug, and surface only the result in the banner.
logger.Info("\n" + buildStartupLog())
Avoid the classic traps
| Trap |
Do instead |
Naked return in a long function |
Name the returned values at the return statement |
if err == nil { ... } else { ... } |
Return early on the error and leave the happy path unindented |
var results []T then append in a loop |
make([]T, 0, len(items)), and strings.Builder for strings |
panic for an expected failure |
Return an error and let the caller decide |
context.Context stored in a struct |
Pass it as the first parameter |
| Mixing value and pointer receivers on one type |
Pick one and use it for every method |
interface{} in a new signature |
any, or a concrete type or type parameter |
time.Sleep to wait for a goroutine |
A channel, a WaitGroup, or a context deadline |
time.Sleep to wait in a test |
testing/synctest, or a channel the test blocks on |
Reference files
| Open this |
For |
| references/errors.md |
Sentinels, custom error types, errors.Is/As, joining, panics |
| references/concurrency.md |
Worker pools, errgroup, channels, shutdown, sync.Pool |
| references/project-layout.md |
Module layout, package naming, options pattern, embedding |
| references/tooling.md |
Build and test commands, golangci-lint v2 configuration, CI |
| references/testing.md |
Test shape, t.Cleanup, synctest, parallel subtests, coverage |
| references/table-tests.md |
Table shapes, error cases, subtests, golden files, comparison |
| references/mocks-and-fakes.md |
Fakes, stubs, spies, interfaces at the consumer, httptest |
| references/integration.md |
Real databases, containers, build tags, HTTP handler tests |
| references/benchmarks.md |
Benchmarks, allocation counts, profiles, fuzzing, coverage |
Related skills
coding-standards for the cross-language floor this skill sits on.
tdd-workflow for the language-neutral red-green-refactor loop and the test pyramid.
observability-and-logging for slog discipline, metrics, and the startup readiness log.
api-design for the shape of the HTTP or gRPC contract a Go service serves.
performance-optimization for what to do with a benchmark once you have one.
docker-patterns for building and shipping the resulting binary.
Checklist
- Every exported type has a useful zero value, or an unexported field and a constructor.
- Functions accept interfaces and return concrete types, and interfaces are declared at the consumer.
- Doc comments are absent, or one sentence plus only the notes the signature cannot carry.
- Every error a caller can act on is documented, including the sentinels it can match.
- Every returned error is wrapped with
%w and a lowercase operation phrase, and none is discarded with _.
context.Context is the first parameter everywhere and is passed to every blocking call.
- Every goroutine has a defined way to stop, and dependencies arrive through a constructor.
- Slices are preallocated where the length is known and strings are built with a
Builder.
- Every new behaviour has a test seen to fail, repeated cases are one table, and fakes are hand-written.
gofmt, go vet, golangci-lint run, and go test -race ./... all pass, with around 90 percent coverage.
1---2name: golang-patterns3description: Idiomatic Go for production services and the test suite that proves them, covering zero values, interface design, error wrapping, context and cancellation, dependency injection, allocation discipline, golangci-lint v2, table-driven tests, hand-written fakes, benchmarks, and fuzzing. Use when writing a new Go package, reviewing Go code, fixing a goroutine leak, designing an interface at the consumer, setting up Go linting, converting copy-pasted tests into a table, testing an HTTP handler, or benchmarking a hot path. Not for the language-neutral SOLID, DRY, and naming floor, use `coding-standards`.4---56# Go Development Patterns78Language-level rules for production Go and the test suite that proves it: how types are shaped, how errors travel,9how goroutines are stopped, how a module is laid out, and how it is tested. Go rewards the boring version, so a10reader should follow control flow top to bottom without unwinding a closure. The hub carries the rules that decide11most reviews, each reference file the depth for one area.1213Baseline: Go 1.25, the current stable toolchain. Confirm with `go version` before assuming an older release: the14examples use per-iteration loop variables, `any`, `log/slog`, and `testing/synctest` with no compatibility note.1516---1718### When to activate1920- Writing, reviewing, or refactoring a Go package, command, or service.21- Designing an interface, a constructor, or a package boundary, or chasing a goroutine leak or a data race.22- Setting up `golangci-lint`, `go vet`, and the formatting gate.23- Writing table tests, fakes, benchmarks, or fuzz targets, or raising coverage on a package.2425---2627### When not to activate2829- Applying the cross-language design floor of SOLID, DRY, and naming. Use `coding-standards`.30- Applying the language-neutral red-green-refactor loop and the test pyramid. Use `tdd-workflow`.31- Setting up log formats, metrics, tracing, or health endpoints. Use `observability-and-logging`.32- Designing the HTTP or gRPC contract the service serves. Use `api-design`.33- Profiling a slow path before changing it. Use `performance-optimization`.34- Driving a browser through a user journey. Use `e2e-testing`.3536---3738### Make the zero value useful, and inject what it cannot hold3940A type whose zero value works needs no constructor, cannot be half-initialised, and composes into other structs for41free, while a nil map or a nil channel inside a struct panics on first use. When a field genuinely cannot have a42useful zero, give the type a constructor, keep the field unexported so it cannot be skipped, and take the dependency43as an argument. A package-level `*sql.DB` initialised in `init()` cannot be swapped in a test, cannot fail loudly at44startup, and ties every consumer of the package to one instance.4546```go47// BAD48type Counter struct {49 counts map[string]int50}5152var db *sql.DB5354func init() {55 db, _ = sql.Open("postgres", os.Getenv("DATABASE_URL"))56}5758// GOOD59type Counter struct {60 mu sync.Mutex61 count int62}6364func NewServer(db *sql.DB) *Server {65 return &Server{db: db}66}67```6869---7071### Accept interfaces, return structs, and declare them at the consumer7273An interface parameter lets a caller pass anything that fits, while an interface return hides the concrete type for74no benefit and blocks the caller from reaching a method the interface does not declare. The consumer also knows what75it needs: an interface declared next to the implementation grows to mirror the struct, and every consumer then76depends on methods it never calls.7778```go79// BAD. Declared in package postgres, mirroring the repository struct.80type UserRepository interface {81 GetUser(ctx context.Context, id string) (*User, error)82 SaveUser(ctx context.Context, u *User) error83 Migrate(ctx context.Context) error84}8586// GOOD. Declared in package service, holding only what the service calls.87type UserStore interface {88 GetUser(ctx context.Context, id string) (*User, error)89}9091func ProcessData(r io.Reader) (*Result, error)92```9394---9596### Doc comments: default to none9798A doc comment is usually a sign that the code failed to explain itself. Before writing one, extract the unclear block99into a well-named function, rename the parameters so they carry their own meaning, and tighten the types. Do that100first and most doc comments have nothing left to say, which is the outcome you want. Code that explains itself cannot101go stale, a comment can.102103When one is still genuinely needed, the prose is capped at five lines and is usually one. Every note you add about a104parameter, the result, or an error is capped at one line and only appears when it genuinely adds something: if the105note does not fit on a single line, shorten it or drop it. Four rules decide what goes in. Exported identifiers are106not an automatic exception: a linter wanting a comment on every exported name is not a reason to write a sentence107that adds nothing.1081091. Prose. One sentence, starting with the identifier name, saying what it does, then only what a caller cannot infer110 from the signature. Nothing more.1112. Describe a parameter only when the name and the type do not already convey it, meaning units, nullability, a valid112 range, or who owns it afterwards. `orderID is the order identifier` is noise, delete it.1133. Describe the result only when it is non-obvious.1144. Describe the error conditions always, every one a caller can act on, and name the sentinel errors it can match115 with `errors.Is`. The signature says only `error`, so this one is genuinely contract rather than decoration.116117Going past the five-line prose cap is allowed only when the contract genuinely cannot be stated in fewer lines, for118example a documented state machine, an ordering requirement, or a concurrency guarantee. It is an exception you119justify in review, not a budget to spend. The one-line cap on a note line has no exception at all: shorten it or120delete it.121122```go123// GOOD. One sentence, then only what the signature cannot carry, one line per note.124// Reserve holds stock for an order until the payment window closes.125// holdFor is capped at 15 minutes.126// Returns ErrInsufficientStock when the warehouse cannot cover the order.127func (w *Warehouse) Reserve(orderID OrderID, holdFor time.Duration) (Reservation, error)128```129130The failing shape on the same function is `Reserve reserves stock. It takes an order ID and a hold duration and131returns a reservation and an error.`, which restates the signature and says nothing about the failure mode.132133---134135### Wrap every error with context136137`%w` keeps the original error matchable by `errors.Is` and `errors.As` while adding the operation that failed. Naked138returns of a library error give a caller a message with no idea which call produced it. Write the wrap message as a139lowercase operation phrase with no trailing punctuation, so the chain reads as one sentence when it is finally140printed. `_` on an error is a decision to continue with unknown state: handle it, wrap it, or, where nothing can be141done, assign it explicitly so the choice is visible in review. Sentinel errors, custom types, and matching are in142[references/errors.md](references/errors.md).143144```go145// BAD146if err := json.Unmarshal(data, &cfg); err != nil {147 return nil, err148}149150// GOOD151if err := json.Unmarshal(data, &cfg); err != nil {152 return nil, fmt.Errorf("parse config %s: %w", path, err)153}154```155156---157158### Take a context first, and never start a goroutine you cannot stop159160A `context.Context` is the first parameter, never a struct field, and every blocking call in the function passes it161down so a cancelled request stops work instead of finishing it for nobody. That context is also the usual way a162goroutine ends: every one needs a defined way out, whether a closed channel, a cancelled context, or a `WaitGroup`163the caller waits on. A goroutine blocked forever on an unbuffered send is a leak that only shows up as memory growth164in production. Worker pools, `errgroup`, and graceful shutdown are in165[references/concurrency.md](references/concurrency.md).166167```go168// BAD. Context parked on a struct, and a send nothing can cancel.169type Request struct {170 ctx context.Context171}172173go func() { ch <- data }()174175// GOOD176func FetchUser(ctx context.Context, id string) (*User, error)177178go func() {179 select {180 case ch <- data:181 case <-ctx.Done():182 }183}()184```185186---187188### Test it, and watch the test fail first189190Write the signature, write the test, watch it fail, then implement. A test written after the code passes on the first191run has never demonstrated it can catch anything. A table plus `t.Run` is the default shape, because the report then192names the input that broke rather than a line number, and the message reads `got X; want Y` with the input in it.193194```go195// BAD196func TestAddPositive(t *testing.T) { ... }197func TestAddNegative(t *testing.T) { ... }198199// GOOD200for _, tt := range []struct {201 name string202 a, b int203 want int204}{{"positive", 2, 3, 5}, {"mixed signs", -1, 1, 0}} {205 t.Run(tt.name, func(t *testing.T) {206 if got := Add(tt.a, tt.b); got != tt.want {207 t.Errorf("Add(%d, %d) = %d; want %d", tt.a, tt.b, got, tt.want)208 }209 })210}211```212213Fake a dependency with a hand-written struct satisfying the interface the consumer declared, never with a mock214framework that matches method names by string. Fake only what you cannot run: a payment API is fair, the database is215not when a container or an in-memory engine exercises the real query.216217Cover around 90 percent of the real logic, 100 percent on critical business logic, and exclude only generated output218such as protobuf stubs. A failing coverage gate is a signal to add the missing test, never to lower the threshold.219Run the suite the way CI runs it, from the module root, with the race detector on:220221```bash222go test -race -coverprofile=coverage.out ./...223```224225Test shape, `t.Cleanup` and `t.Context`, `testing/synctest` instead of `time.Sleep`, parallel subtests, benchmarks,226and fuzzing are in [references/testing.md](references/testing.md).227228---229230### Startup readiness log231232The banner, the section order, the 2-second probe timeout, and the `<url> [Connected|Warning|FAILED]` result format233are one convention shared by every language, owned by `observability-and-logging`. What is Go-specific is where it is234emitted and that it goes through one `log/slog` call with a leading newline, because slog stamps a timestamp and235level per call and per-line emission would shred the banner, so the whole block is one `logger.Info` of the built236string. Probe with `http.Client{Timeout: 2 * time.Second}` so an unreachable dependency cannot stall startup, log the237detail at debug with `slog.Debug`, and surface only the result in the banner.238239```go240logger.Info("\n" + buildStartupLog())241```242243---244245### Avoid the classic traps246247| Trap | Do instead |248| --- | --- |249| Naked `return` in a long function | Name the returned values at the `return` statement |250| `if err == nil { ... } else { ... }` | Return early on the error and leave the happy path unindented |251| `var results []T` then `append` in a loop | `make([]T, 0, len(items))`, and `strings.Builder` for strings |252| `panic` for an expected failure | Return an error and let the caller decide |253| `context.Context` stored in a struct | Pass it as the first parameter |254| Mixing value and pointer receivers on one type | Pick one and use it for every method |255| `interface{}` in a new signature | `any`, or a concrete type or type parameter |256| `time.Sleep` to wait for a goroutine | A channel, a `WaitGroup`, or a context deadline |257| `time.Sleep` to wait in a test | `testing/synctest`, or a channel the test blocks on |258259---260261### Reference files262263| Open this | For |264| --- | --- |265| [references/errors.md](references/errors.md) | Sentinels, custom error types, `errors.Is`/`As`, joining, panics |266| [references/concurrency.md](references/concurrency.md) | Worker pools, `errgroup`, channels, shutdown, `sync.Pool` |267| [references/project-layout.md](references/project-layout.md) | Module layout, package naming, options pattern, embedding |268| [references/tooling.md](references/tooling.md) | Build and test commands, `golangci-lint` v2 configuration, CI |269| [references/testing.md](references/testing.md) | Test shape, `t.Cleanup`, `synctest`, parallel subtests, coverage |270| [references/table-tests.md](references/table-tests.md) | Table shapes, error cases, subtests, golden files, comparison |271| [references/mocks-and-fakes.md](references/mocks-and-fakes.md) | Fakes, stubs, spies, interfaces at the consumer, `httptest` |272| [references/integration.md](references/integration.md) | Real databases, containers, build tags, HTTP handler tests |273| [references/benchmarks.md](references/benchmarks.md) | Benchmarks, allocation counts, profiles, fuzzing, coverage |274275---276277### Related skills278279- `coding-standards` for the cross-language floor this skill sits on.280- `tdd-workflow` for the language-neutral red-green-refactor loop and the test pyramid.281- `observability-and-logging` for slog discipline, metrics, and the startup readiness log.282- `api-design` for the shape of the HTTP or gRPC contract a Go service serves.283- `performance-optimization` for what to do with a benchmark once you have one.284- `docker-patterns` for building and shipping the resulting binary.285286---287288### Checklist289290- Every exported type has a useful zero value, or an unexported field and a constructor.291- Functions accept interfaces and return concrete types, and interfaces are declared at the consumer.292- Doc comments are absent, or one sentence plus only the notes the signature cannot carry.293- Every error a caller can act on is documented, including the sentinels it can match.294- Every returned error is wrapped with `%w` and a lowercase operation phrase, and none is discarded with `_`.295- `context.Context` is the first parameter everywhere and is passed to every blocking call.296- Every goroutine has a defined way to stop, and dependencies arrive through a constructor.297- Slices are preallocated where the length is known and strings are built with a `Builder`.298- Every new behaviour has a test seen to fail, repeated cases are one table, and fakes are hand-written.299- `gofmt`, `go vet`, `golangci-lint run`, and `go test -race ./...` all pass, with around 90 percent coverage.