| name | go |
| description | Idiomatic Go - package and interface design, error wrapping, table-driven tests, generics, the modern standard library (slices/maps/cmp/errors.Join), current syntax, and logging discipline. Use when writing, reviewing, or refactoring any Go code, especially code drifting toward Java/Spring shapes (deep layer trees, generic repositories, heavy frameworks) - even if the user never says "idiomatic". Distilled from spf13/go-skills and adapted to this repo.
|
| license | Apache-2.0 |
Idiomatic Go
Write Go that is boring in the best way - direct, predictable, obvious on the
first read. Clear is better than clever. The common failure mode (and the LLM
default) is importing Java/Spring habits - deep layer trees, generic
repositories, heavy frameworks, manual worker pools - into a language built for
flat, simple, discoverable APIs. When in doubt, delete the abstraction.
Core principles
Clear is better than clever. If a function's control flow takes three reads to
follow, rewrite it. Keep the happy path un-indented: handle errors and edge cases
first and return.
func GetUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("finding user %s: %w", id, err)
}
return user, nil
}
Make the zero value useful. Design types so the zero value works without a
constructor - sync.Mutex and bytes.Buffer are the gold standard.
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Inc() { c.mu.Lock(); c.count++; c.mu.Unlock() }
Packages: flat, named for what they do
Start flat; add a package only when a domain earns a namespace. Name packages by
what they do (auth, billing, jobs), never by what layer they are
(service, repository, controller) - layer-named packages breed circular
imports and interface bloat and add zero clarity in Go. No utils/, helpers/,
or common/ junk drawers; they signal unclear ownership. Packages don't import
each other sideways - cycles mean wrong boundaries; the composition root wires them.
In this repo: code lives under internal/ in a deliberate domain (pure
interfaces) / infra (adapters) / services (business logic) split, wired
by a DI container (internal/container/container.go). That is this project's
structure - follow it; don't "flatten internal/." spf13's transferable rules
still hold: name by purpose, no junk-drawer packages, wire at the composition
root (here, the container).
Interfaces: discovered, not designed
Write concrete types first. Introduce an interface only when a consumer genuinely
needs to swap implementations, and define it in the consuming package, kept
small (io.Reader, not *os.File). Accept interfaces, return structs -
callers get the narrow dependency they need without type-asserting to reach real
fields.
type UserFetcher interface {
GetUser(id string) (*User, error)
}
type Processor struct{ fetcher UserFetcher }
In this repo: domain contracts are centralized in
internal/domain/interfaces.go so counterfeiter can generate fakes from one
place - that's the established seam. Still prefer small interfaces; just declare
new ones there when they need a generated mock.
Errors are values
Check them explicitly; they aren't exceptions. Wrap with %w to add context while
preserving the chain for errors.Is / errors.As. Combine siblings with
errors.Join (Go 1.20) - no multierr needed.
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("loading config %s: %w", path, err)
}
Functional options for complex construction
When a type has many optional settings, avoid telescoping constructors:
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second}
for _, opt := range opts {
opt(s)
}
return s
}
Testing is just Go
Go testing is just Go programming - reach for the stdlib testing package, not a
BDD framework.
- Table-driven tests with
t.Run subtests are the standard.
- Call
t.Helper() in assertion helpers so failures point at the case, not
the helper.
- Prefer simple fakes/stubs for stand-ins - implicit interfaces make them
cheap to hand-write.
- Put fixtures in
testdata/ (the tool ignores it); diff large output against
golden files.
- Compare structs with
github.com/google/go-cmp/cmp, not reflect.DeepEqual.
- Abstract the filesystem with
afero and inject afero.NewMemMapFs() in tests.
func TestParse(t *testing.T) {
tests := []struct {
name string
in string
wantErr bool
}{
{"valid", "port=8080", false},
{"bad", "port=abc", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := Parse(tt.in)
if (err != nil) != tt.wantErr {
t.Fatalf("Parse(%q) err = %v, wantErr %v", tt.in, err, tt.wantErr)
}
})
}
}
In this repo: fakes for domain interfaces are counterfeiter-generated
under tests/mocks/, not hand-written. Regenerate with task mocks:generate
(pre-commit runs it when internal/domain/interfaces.go changes); for a new
domain interface, add a counterfeiter line under mocks:generate in
Taskfile.yml. Use the generated fakes - don't hand-roll rivals. Table tests,
testdata, and go-cmp still apply.
Use current Go, not 2018 Go
This repo targets Go 1.26 - reach for the modern stdlib before any third-party
helper or hand-rolled loop:
| Instead of | Use |
|---|
sort.Slice(s, ...) | slices.Sort(s), slices.SortFunc, slices.Contains/Index |
| manual key/value loops | maps.Keys/Values/Clone/Copy/Equal |
if x == 0 { x = def } | cmp.Or(x, def); min/max built-ins |
| ad-hoc multi-error joins | errors.Join(err1, err2) |
atomic.AddInt64(&n, 1) | typed atomic.Int64/Bool/Pointer[T] (n.Add(1)) |
| gorilla/mux for basic routing | net/http: mux.HandleFunc("GET /u/{id}", ...) + r.PathValue("id") |
for i := 0; i < n; i++ | for i := range n |
interface{} | any |
// +build tags | //go:build |
To keep a request's values but drop its cancellation for work that outlives it,
use context.WithoutCancel(ctx) (Go 1.21).
Generics eliminate duplication, not model hierarchies
Use a generic when the same algorithm runs over many concrete types.
func Map[S, T any](s []S, f func(S) T) []T {
out := make([]T, len(s))
for i, v := range s {
out[i] = f(v)
}
return out
}
Do not write generic "repositories", services, or base types - that's Java in
Go syntax. Use comparable for map keys/equality and cmp.Ordered for </>.
Start concrete; generify only once the same logic repeats across 3+ types.
Logging discipline
logger.Debug("cache miss", "key", key)
logger.Info("server started", "addr", addr)
logger.Warn("retrying", "attempt", n)
logger.Error("request failed", "err", err)
- Inject the logger as a dependency; no package-level logger beyond
main.
- Never log and return the same error - log once at the boundary, return it
up the stack everywhere else.
In this repo: logging is go.uber.org/zap (*zap.Logger, from the
container's Logger()), not slog. The discipline above is identical.
Concurrency, briefly
- Share memory by communicating - pass data over a channel instead of guarding
it with a mutex where you can. Channels orchestrate; mutexes serialize.
- Bound concurrency with a buffered-channel semaphore, not a static worker pool
(Go's scheduler is cheap):
func FetchAll(ctx context.Context, urls []string, max int) error {
sem := make(chan struct{}, max)
g, ctx := errgroup.WithContext(ctx)
for _, u := range urls {
sem <- struct{}{}
g.Go(func() error {
defer func() { <-sem }()
return fetch(ctx, u)
})
}
return g.Wait()
}
- Never start a goroutine without knowing how it stops - every
go func()
needs a ctx/closed-channel exit, or it leaks.
For channel patterns in depth - done-channel, fan-out/fan-in, pipeline, or-done,
context propagation, worker semaphore - see the go-concurrency skill, and
verify with go test -race.
Debugging: the Go toolchain is not the bug
go build/test/run are deterministic and the build cache is keyed by source -
don't suspect them or reach for go clean -cache. When an error survives your
edit, it's almost always one of these, in order:
- The edit didn't fix the actual logic.
- It was in the wrong file, function, or package.
- A second call site has the same bug, unfixed.
- The error comes from a different code path than you edited.
Re-read the (accurate) error, confirm the file is actually compiled
(go list -f '{{.GoFiles}}' .), and add a t.Log/print at the exact site.
Adapted from spf13/go-skills (MIT, by Steve
Francia), trimmed and reconciled with this repo's conventions. Pairs with
go-concurrency, cobra-viper, and go-spec-reviewer.