Go Idioms Across 51 Projects#
Summary#
Go idioms are surprisingly consistent at the macro level — context propagation, table-driven tests, and manual dependency injection appear in virtually every project — but diverge sharply at the meso level, where each team makes deliberate, traceable choices about concurrency coordination, error taxonomy, configuration ergonomics, and generics adoption. The most instructive patterns are not the universal ones but the boundary conditions: where a project violates a norm, it almost always has a documented reason. The corpus reveals a language with strong community consensus on a small set of primitives and wide experimentation on the patterns built from them.
Taxonomy#
1. Concurrency Coordination#
Go’s concurrency model — goroutines, channels, sync primitives — is used across five distinct coordination approaches in this corpus. Projects rarely use only one.
1a. Channel-based lifecycle (older pattern)#
- Projects using it: Kubernetes (stopCh), etcd (stopc/donec pairs), Consul (shutdownCh), Vault (ShutdownCh), Nomad (shutdownCh), Syncthing (inner services)
- How it works: A
chan struct{}is closed on shutdown, broadcasting to all receivers simultaneously.stopcis the send-side;donecis a companion channel the goroutine closes when it finishes, allowing the caller to synchronize on actual exit. - When it’s appropriate: Pre-Go 1.7 codebases; code written before
context.Contextwas universally accepted. Still correct, slightly more verbose than context, but offers simpler debuggability (channels are visible in goroutine dumps).
1b. Context-first cancellation (modern standard)#
- Projects using it: Prometheus (8k+ context.Context refs), Dapr (4702 refs), Tailscale (1301 refs), restic (769 refs), Temporal (8416 refs), Crush (584 refs), CockroachDB (26543 refs)
- How it works: A root context from
signal.NotifyContextorcontext.WithCancelflows top-down through every function call. Shutdown is triggered by calling the cancel function. All blocking operations respectctx.Done(). - When it’s appropriate: New code and services with long call chains. Context propagation is the consensus modern idiom. Enables request-scoped values, deadlines, and cancellation in a single type.
- Key outliers: Gin deliberately avoids
context.Context(only 1 occurrence — the project predates and disagrees with context as a function parameter). fzf uses zero context (cooperating viaAtomicBoolandEventBox). Fyne has 5 references (GUI frameworks use main-thread marshaling, not context trees). These are principled omissions, not oversights.
1c. Structured concurrency via group abstractions#
Five distinct “run group” patterns appear in the corpus:
| Pattern | Projects | Mechanism |
|---|---|---|
oklog/run actor group | Prometheus | N run/interrupt pairs; first exit cancels all |
golang.org/x/sync/errgroup | Moby, restic, gh, PocketBase | First error cancels siblings |
safe.Pool / goroutine tracker | Traefik, NATS | Panic recovery + WaitGroup + context |
goro.Group + RunnerCloserManager | Temporal, Dapr | Typed lifecycle, reverse-order cleanup |
suture.Supervisor | Syncthing | Erlang-OTP supervision tree |
The oklog/run actor model is the most elegant for processes where every subsystem is a peer: each Add(run, interrupt) call declares a component and its shutdown trigger. The supervisor tree (suture) is the most sophisticated: restarts transient failures, propagates fatal errors upward, and integrates naturally with Go’s context model.
1d. Worker pool patterns#
Every project with parallel processing uses some form of worker pool; the implementations reveal philosophy:
- Kubernetes:
for i := 0; i < workers; i++ { go wait.UntilWithContext(ctx, dc.worker, time.Second) }— explicit loop, externally configurable count, rate-limited work queue handles backpressure - Moby:
errgroup.SetLimit(numWorkers)withlog2(numContainers)heuristic — self-sizing pool - CockroachDB:
raftScheduler— sharded by RangeID, dedicated shard for priority work,sync.Condwakeup rather than channels - MinIO:
errgroup.WithNErrs(n)— custom errgroup with pre-allocated per-index error slices; zero locking on erasure-coding hot path - Temporal:
goro.AdaptivePool— dynamically resizes based on observed queue depth - Syncthing: explicit goroutine count from configuration, workers self-terminate when channel closes
The evolution is: manual goroutines + WaitGroup → errgroup → pool abstraction → adaptive pool → typed scheduler framework.
1e. Sync primitives at scale#
| Primitive | Pattern | Projects |
|---|---|---|
sync.Once | Lazy initialization, idempotent startup | All projects; Go compiler 268 uses |
sync.Pool | Per-request object recycling | Gin, Echo, Fiber (context recycling); NATS (31 Raft message pools) |
sync.RWMutex | Read-heavy concurrent caches | Universal; GORM schema cache, Consul connection pool |
sync.Map | Write-once concurrent lookups | GORM hot path, Tailscale, Crush csync package |
atomic types | Lock-free counters, CAS flags | NATS 950 uses; Kubernetes 2013 uses; Fiber timestamp cache |
sync.Cond | Wake on threshold / set change | etcd FIFO scheduler, Moby stats collector, NATS write loop |
sync.Cond is notably rare — only 3-4 projects use it directly, and each use is well-justified. It is harder to reason about than channels; most teams reach for channels first.
2. Error Handling Approaches#
Error handling is the most divided area in the corpus. Five distinct strategies coexist, and the choice correlates strongly with project age and API surface.
2a. Sentinel errors (universal baseline)#
Every project defines package-level var Err* = errors.New(...) sentinels. The range is 3 (Cobra) to 40+ (NATS, etcd). The Go compiler convention of prefixing the package name into the error string ("archive/tar: invalid tar header") is followed by stdlib but not widely adopted in application code.
2b. Modern wrapping (fmt.Errorf %w) — now the consensus#
Adopted universally in code written after 2020. Projects that haven’t fully migrated (Consul, Moby, restic) explicitly acknowledge it. The combination of fmt.Errorf("%w", err) for wrapping and errors.Is/errors.As for inspection is the standard idiom.
Notable deviation: Cobra does not use %w in its own errors — because errors surface at the terminal and machine-readable chains are less important than clear messages. A principled exception.
2c. Custom error taxonomies (structured error surfaces)#
The most instructive pattern for library and service authors:
- Moby
errdefs: 12 marker interfaces (ErrNotFound,ErrConflict, etc.), each with a single unexported method. Wrapper types implement bothUnwrap()(stdlib) andCause()(pkg/errors) simultaneously. HTTP handlers map viaerrdefs.IsNotFound(err). The taxonomy is stable, composable, and API-transparent. - etcd
rpctypes: Bidirectional mapping — server-sideErrGRPC*status errors have client-sideErr*mirrors.rpctypes.Error(err)converts gRPC status to typed EtcdError. Client retry logic useserrors.Is(err, rpctypes.ErrUserEmpty). The translation layer absorbs the gRPC/stdlib boundary. - CockroachDB
cockroachdb/errors: Full annotation chain —errors.AssertionFailedffor invariants (4949 uses),errors.WithHint,errors.WithDetail,errors.WithIssueLinkfor user-facing errors,pgerror.WithCandidateCodefor SQLSTATE. Every SQL error carries structured fields so the CLI can format hint/detail/code separately. - NATS
ApiError: JetStream API errors use auint16ErrCode enum backed by anApiErrorsmap.IsNatsErr(err, apiErrors["badRequest"])is the check idiom. Server and client use the same type, avoiding translation. - Terraform
tfdiags.Diagnostics: A[]Diagnosticslice rather than a single error. This enables a parallel DAG walk to accumulate 50+ resource failures simultaneously viadiags.Append(...)— impossible with the single-error convention. The trade-off: callers must remember to checkdiags.HasErrors(), not just!= nil.
Pattern for practitioners: If your library exposes errors over a protocol boundary (HTTP, gRPC, RPC), define a typed error taxonomy early. If callers need to accumulate errors across parallel operations, use a slice type rather than error. If errors carry domain context (resource name, field path, error code), define a struct.
2d. Multi-error aggregation#
- GORM:
AddError(err)joins withfmt.Errorf("%v; %w", existing, new)on the*DBhandle — the fluent API cannot return errors, so they accumulate - Prometheus:
promql.ParseErrors []ParseErr— all syntax errors from a single PromQL parse - Kubernetes:
field.ErrorListfor validation — each field error carries a JSON path - restic:
fatalErrortype — wraps an error to signal CLI non-zero exit without re-printing the chain
2e. Panic as assertion (not error propagation)#
A consistent pattern across production systems: panics are used for programmer invariants, not for expected runtime failures.
- Kubernetes:
utilruntime.Must()— panic on scheme registration failure (init-time, deterministic) - Caddy:
assert1()— panic on nil handler in route registration - Gin: panic on nil handler in route registration
- etcd:
errors.AssertionFailedf— recoverable assertion that logs stack and returns an error - CockroachDB:
errors.AssertionFailedf(4949 uses) — converted to a structured error, not a fatal panic
The refined pattern: use panic at initialization where failure is deterministic and recovery is impossible; use structured assertion errors at runtime where the system should degrade gracefully.
3. Configuration Patterns#
3a. Functional options — widely used, not universal#
The “Rob Pike / Dave Cheney” functional options pattern (func WithFoo(v T) Option { return func(*T) { t.field = v } }) is explicitly used in:
- Moby client: Options return
errorfor validation during construction — a strict variant appropriate for public libraries - etcd client: Standard
func(client *Client)pattern - Vault auth clients: Every auth method package uses options (
WithMountPath,WithWrappingToken) - Viper:
Optioninterface with unexportedapply(*Viper)method — the “named option interface” variant that documents better than bare function types - Hugo: Generic
Listeners[T]and selectiveManagerOptionin specific packages - Syncthing: Lower-level packages (fs, ignore, db)
- Temporal: Method-chaining builder for retry policies
Explicitly not using functional options:
- Caddy: JSON-driven config model, no functional options anywhere (deliberate)
- Kubernetes: Options struct → CompletedConfig two-phase pattern (too many fields for options)
- Cobra: Direct struct-field assignment on
Command(preferred for discoverability) - NATS: Flat
Optionsstruct with ~200 fields
The functional options pattern is most valuable when: (a) most fields have sensible defaults, (b) the API is public-facing and backward compatibility matters, (c) the number of optional parameters is moderate (5-20). For 50+ field configs, struct literals are more practical.
3b. Two-phase config: Options → CompletedConfig#
Kubernetes pioneered a pattern that several infrastructure projects have adopted: separate the “raw flag values” phase from the “resolved live objects” phase.
- Stage 1 (Options): Struct populated by pflag; fields are raw strings, ints, and booleans
- Stage 2 (Config/CompletedConfig): Produced by
Complete()orValidate()— resolves string IPs tonet.IP, loads TLS certificates, creates API clients
The CompletedConfig type is unexported except through the completion method, enforcing the two-phase flow at the type system level. This prevents “partially initialized config” bugs at runtime.
3c. Runtime-reconfigurable settings#
CockroachDB’s cluster settings pattern deserves special attention. 1056 typed settings are declared as package-level variables:
var enableRPCCircuitBreakers = settings.RegisterBoolSetting(
settings.SystemOnly, "rpc.circuit_breaker.enabled", "...", true,
)Operators change settings via SET CLUSTER SETTING SQL; values propagate to all nodes via gossip. Components receive *cluster.Settings and read via typed accessors. This separates declaration from propagation from reading — a zero-restart configuration mechanism that Viper’s file-reload barely approximates.
Prometheus uses a simpler variant: ApplyConfig(*config.Config) error implemented by 11 subsystems. On SIGHUP, each implementor validates its section and atomically updates state. Failure in any reloader aborts the reload atomically.
3d. Feature flags / gates#
Three distinct feature flag systems appear in the corpus:
| System | Projects | Mechanism |
|---|---|---|
| Kubernetes FeatureGate | Kubernetes, etcd | Alpha/Beta/GA lifecycle; DefaultFeatureGate.Enabled() |
| envknob | Tailscale | Env var, lazily evaluated, mockable in tests |
| build-tag pairs | restic, Tailscale, Viper, fzf | Compile-time; zero runtime cost |
Build-tag pairs (_enabled.go / _disabled.go) are the most performant but least flexible. They appear in tailscale (feature.Hook[Func]), restic (//go:build !nofuse), and viper (experimental features). The Tailscale feature.Hook[Func] pattern is distinctive: a generic set-once function slot, populated by init(), checked with hook.GetOk() — achieving dead-code elimination without scattered if runtime.GOOS chains.
4. Dependency Injection#
4a. Manual constructor injection (~75% of projects)#
The overwhelming majority of projects in this corpus wire dependencies by hand. Every team has independently arrived at the same conclusion: Go’s explicitness benefits outweigh DI framework magic for programs where the dependency graph is fixed at compile time.
The shared pattern is the “composition root” — a single function (often main(), NewServer(), or setup()) that constructs all dependencies in topological order and passes them explicitly to each subsystem constructor. Examples: Kubernetes cmd/kube-controller-manager/app/controllermanager.go, Prometheus cmd/prometheus/main.go (~1700 lines), NATS NewServer() (~300-line function with a 350+-field struct).
The cost is explicit: these composition roots are large, but they are also completely transparent. grep NewServer finds the wiring; no framework indirection obscures it.
4b. Google Wire (Grafana)#
Grafana’s wire-generated Initialize() function is 1939 lines and wires hundreds of services. Wire is used here because:
- The service graph is extremely large
- OSS vs. Enterprise composition is managed via build tags and
wire.Bind - Circular dependencies are caught at
go generatetime
Wire’s output is checked in (not generated at startup), so the wiring is readable, greppable, and compilable. The wire_gen.go file is the authoritative record of what runs.
4c. uber/fx (Temporal)#
Temporal’s use of fx is the most complete in the corpus. Each service runs in a nested fx.App, with common resources (logger, metrics, persistence) provided in the parent graph and passed to service graphs via fx.Supply. The nested graph pattern is unusual but necessary for independent service scaling. The AnnotateWorkerComponentProvider[T any] generic helper shows fx being extended with Go generics to reduce boilerplate.
4d. init()-based self-registration#
A distinct pattern for plugin-style extensibility:
- Caddy: 136
RegisterModule()calls from 112init()functions;modules/standard/imports.goblank-imports all standard modules - Kubernetes:
localSchemeBuilder.Register(addKnownTypes)in every API package’sinit() - Prometheus: Every SD provider calls
discovery.RegisterConfig(&SDConfig{})ininit() - CockroachDB CCL: Core packages declare
var MakeChangefeedMetricsHook func(...)initialized to nil; CCL packages override ininit(). The OSS binary omits the blank import; hooks remain nil; features absent.
The init-hook pattern solves OSS/enterprise splits cleanly: no conditional code in core, no build tags in business logic, complete behavioral separation through package inclusion.
5. Interface Design#
5a. The compile-time satisfaction check#
var _ SomeInterface = (*ConcreteType)(nil) appears in every project in the corpus with non-trivial interface usage. This is perhaps the most universally adopted Go idiom not discussed in the language spec. The density reveals interface sophistication:
- Caddy: 125 occurrences (every module declares which lifecycle interfaces it satisfies)
- Temporal: 126 occurrences (CHASM components double-declare)
- Kubernetes: 2498 interface definitions; satisfaction checks in every staging package
5b. Interface segregation in practice#
The clearest examples of Go’s implicit interface satisfaction being used for ISP:
- Caddy lifecycle interfaces:
Provisioner,Validator,CleanerUpper,App,AdminRouter— modules implement only what they need. No “do nothing” interface inflation. - Moby Backend-per-router: Each route handler package defines its own
Backendinterface with only the methods it needs from*daemon.Daemon. The daemon satisfies all of them. Zero coupling at the package level. - Tailscale consumer interfaces: The SSH package defines
ipnLocalBackend(~10 methods) rather than importing the full*LocalBackend. This is textbook ISP: define the interface where it’s consumed. - restic Backend stack: Each decorator wraps
backend.Backendand implementsUnwrap() Backend. The genericAsBackend[B Backend]function walks the chain to extract any layer by type — combining the decorator pattern with type-safe introspection.
5c. init()-time registry as an interface pattern#
The caddy.Module interface, satisfied by every module and registered via init(), achieves what a Java factory-pattern registry achieves — but with zero runtime reflection and zero registration boilerplate beyond the init() function.
6. Generics Adoption (Go 1.18+)#
The corpus reveals a clear maturity curve in generics adoption:
6a. Data structure generics (earliest, most principled)#
Generic data structures are the clearest win: one implementation, type safety across many callers, zero type assertions.
- Hugo (most extensive):
doctree.Tree[T],cache.Partition[K,V],rungroup.Group[T]— generics are architectural, not cosmetic - Tailscale
syncspackage:AtomicValue[T],MutexValue[T],Map[K,V],ShardedMap[K,V]with cache-line padding — a generic concurrency primitive library - Temporal
goro.KeyedSet[K]: goroutine registry keyed by any comparable type - MinIO:
internal/grid.SingleHandler[Req,Resp]— typed RPC handlers; eliminates allinterface{}casting in the cluster RPC API - Crush
csync:Map[K,V],Slice[T],VersionedMap[K,V],LazySlice[T]— a generic concurrent collections package
6b. Framework / adapter generics#
Generics used to build type-safe framework primitives:
- Dapr
UniversalHTTPHandler[T proto.Message, U proto.Message]: wraps any handler intohttp.HandlerFuncwith compile-time proto type checking - Dapr
Runner[T any]: retry/circuit-breaker/timeout policies work generically withoutinterface{}casts - gh
Option[T any]: Rust-inspired optional value type for nil-safe config lookups - Fiber
releasePooledBinder[T interface{ Reset() }]: enforces theReset()contract at the type system level - Temporal dynamic config
setting[T, P]: phantom type parameter for precedence at compile time
6c. Utility function generics (most common, least interesting)#
Nearly every post-1.18 project has generic slice/map utility functions:
Partition[T],Filter[T],Contains[T comparable]— appearing in gh, Consul, Nomad, Hugo, Temporal- These are the generics equivalent of the old
interface{}utility packages; the type safety improvement is real but architecturally invisible
6d. Projects with zero or near-zero generics#
- Caddy: Deliberately zero (module registry model doesn’t benefit; JSON config model is type-erased by design)
- Cobra, Viper: Zero (config libraries where
map[string]anyis the data model) - fzf: Zero (older design; function-type polymorphism serves the same purpose)
- Prometheus: Minimal, data-structure only (
zeropool.Pool[T], histogram iterators) - CockroachDB: Conservative (the codebase predates 1.18 extensively; generics adopted only where concrete duplication is eliminated)
Go 1.23 range iterators (iter.Seq[T]) appear in restic and Crush — the most forward-looking adoption in the corpus. Both use them to replace channel-based iteration, eliminating goroutine overhead for sequential consumers.
7. Table-Driven Tests#
Table-driven tests are the closest thing to a universal idiom in this corpus. Every project uses them; counts range from 46 (Cobra, a small library) to 7431 (Kubernetes, measured by testCases/testcases occurrences). Two stable variants exist:
Variant A (anonymous struct):
tests := []struct {
name string
input *v1.Pod
wantErr bool
expected int
}{
{"nil pod", nil, true, 0},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) { ... })
}Variant B (named struct for reuse):
type testCase struct { name, input, expected string }
var testCases = []testCase{...}Variant A dominates. The t.Run(tc.name, ...) pattern gives named subtest output, enabling go test -run TestXxx/my_case_name. Projects that add t.Parallel() inside sub-tests achieve CPU-parallel test execution — seen in Crush and newer Kubernetes tests.
The only project without detectable table-driven tests in this corpus is PocketBase, which uses a more sequential assertion style. This is a cultural artifact, not a technical limitation.
8. Code Generation#
Code generation is used for distinct purposes across the corpus:
| Purpose | Projects | Tools |
|---|---|---|
| gRPC stubs | Kubernetes, etcd, Dapr, Temporal, NATS | protoc |
| Wire-up code | Grafana | google/wire |
| Mock generation | Temporal (126 files), Consul, Dapr | gomock, mockery |
| Stringer for enums | MinIO, Delve, NATS | go generate stringer |
| DI + schema | Grafana | CUE + generators |
| Optimizer rules | CockroachDB | optgen DSL |
| Interface from struct | Fiber | ifacemaker |
| Dynamic config keys | Temporal | custom generator |
| Kubernetes informers | Kubernetes | code-generator |
The trend is clear: code generation is used to eliminate classes of human error (mock drift, enum string names, interface/implementation divergence), not to reduce verbosity. Projects that generate mocks (Temporal, Consul, Dapr) have large interface surfaces that change infrequently — the regeneration cost is acceptable.
9. Patterns Worth Calling Out for the Book#
Moby’s errdefs (marker interface error taxonomy)#
The cleanest custom error taxonomy in the corpus. Twelve single-method marker interfaces, each a zero-allocation wrapper. errors.As traversal works for all variants. HTTP handlers never inspect error strings. This is replicable in any project and significantly better than either (a) large enum switches or (b) sentinel error variables that lose context through wrapping.
etcd’s sharded wait map (pkg/wait.Wait)#
The concurrency primitive that makes distributed consensus acknowledgment work at scale. A sharded map of 64 buckets, each with its own RWMutex. Register(id) returns a buffered channel; Trigger(id, result) closes it. The goroutine proposing to Raft parks on the channel; the apply goroutine triggers it. Zero global lock contention at high QPS. Directly applicable to any system where a caller waits for an async event keyed by ID.
CockroachDB’s TestingKnobs#
55+ fields of structured test injection embedded in production code, checked with nil guards. No build tags, no reflection, no global mutation. Enables fault injection, timing hooks, and behavior overrides deep in the distributed transaction path. The HookGlobal[T] generic function makes it testable without reflect.Value. This pattern is appropriate for any system where testability requires intercepting production code paths.
Traefik’s RingChannel (lossy non-blocking producer)#
A two-select goroutine that prefers writing (avoiding unnecessary drops) over reading. Guarantees writers never block while ensuring consumers always see the most recent update. Directly motivated by the system’s invariant that “only the latest config state matters.” Pattern is applicable to any system where intermediate states are expendable.
Kubernetes’ level-triggered reconciliation#
Not a Go pattern per se but the meta-pattern that explains every Go pattern choice in the controllers: controllers read current state, compute delta to desired state, apply corrections. The work queue is deduplicating (safe to re-enqueue idempotently). Reconcile functions take only a string key (stateless input). SharedInformers use a local cache (cheap re-reads). Each design choice follows from the level-triggered invariant.
Tailscale’s feature.Hook[Func]#
Generic, set-once function slots populated by init(). Achieves dead-code elimination at link time without build-tag guards at every call site. Core code gates on hook.GetOk(). Optional subsystems register themselves; lean binaries omit their blank imports. Type parameters enforce callback signature at compile time. Replicable in any project with optional modules.
PocketBase’s generic hook system#
Hook[T Resolver] implements HTTP-style middleware chains for any event type. Each handler calls e.Next() to continue; the chain is assembled by wrapping closures in reverse order. Used for HTTP middleware, record lifecycle hooks, server lifecycle hooks, and JS extension points — one mechanism, four use cases. The T Resolver constraint ensures every event type has Next().
Trends#
Age correlates with pattern maturity. Projects started before 2015 (Kubernetes, Moby, etcd) show the evolution: chan struct{} shutdown channels coexist with context.Context, pkg/errors coexists with fmt.Errorf %w. Projects started after 2018 (Crush, Temporal CHASM subsystem) use generics, typed atomics, and sync.OnceValue from the outset.
Size correlates with abstraction investment. XL-tier projects (Kubernetes, CockroachDB, Temporal) invest in purpose-built concurrency primitives (Stopper, raftScheduler, goro.Group). M-tier projects (Cobra, Viper, Gin) use stdlib concurrency directly and correctly. The investment in custom primitives pays off at 1000+ goroutines; below that, it’s overhead.
Domain drives divergence. GUI frameworks (Fyne) use main-thread marshaling instead of context. CLI tools (fzf, Cobra) have zero goroutines in production code. Web frameworks (Gin, Echo, Fiber) center everything on sync.Pool recycling. Distributed systems (CockroachDB, etcd, NATS) build elaborate coordination layers. The Go idioms are the same; the composition is domain-specific.
Generics adoption is fastest in infrastructure packages. The most effective uses of generics are in packages that are used by many callers with different types: concurrency utilities (Tailscale syncs), event buses (Crush pubsub.Broker[T]), RPC adapters (MinIO grid.SingleHandler[Req,Resp]), configuration (Temporal setting[T,P]). Application-layer generics are rare and generally unmotivated.
Best Practices#
Concurrency:
- Pass
context.Contextas the first parameter to every function that blocks or does I/O - Use
errgroup.WithContextfor fan-out where errors from any goroutine should cancel siblings - Use
sync.Oncefor lazy initialization; prefersync.OnceValue(Go 1.21+) for computed singletons - Use
sync.Poolfor per-request object recycling in hot paths; always callReset()beforePut() - Prefer
context.WithCanceloverchan struct{}for shutdown in new code; they’re equivalent but context chains better
Error handling:
- Define a typed error taxonomy early for any public-facing API boundary
- Use
fmt.Errorf("%w", err)for wrapping;errors.Is/errors.Asfor inspection - Use multi-error types (
[]Diagnostic,ParseErrors) when parallel operations each independently succeed or fail - Use
paniconly at initialization for deterministic programmer errors; use structured assertions at runtime
Configuration:
- Use functional options when most parameters have sensible defaults and the parameter count is 5-20
- Use config structs with a
Complete()orValidate()step for complex initialization with ordering constraints - Gate experimental features via build-tag pairs for zero runtime cost; use env vars for operator-tunable debug flags
Interface design:
- Define interfaces where they’re consumed, not where they’re implemented
- Keep interfaces small: 1-4 methods is the sweet spot in this corpus
- Always add
var _ Interface = (*Impl)(nil)for public interface implementations
Generics:
- Use generics for data structures and infrastructure packages where type parameters eliminate type assertions
- Do not use generics to add abstraction to business logic that doesn’t need it
- Consider
iter.Seq[T]for sequential collection iteration in Go 1.23+ to avoid channel overhead
Anti-Patterns#
The God Struct as DI Container: Several projects (Moby’s daemon.Daemon, Nomad’s Client, Consul’s BaseDeps) use a large struct that holds every subsystem. This works at their scale but makes testing painful — mocking requires implementing hundreds of methods. The NATS Server struct (350+ fields) is the extreme case. Accept this trade-off consciously; if testing is painful, narrow interfaces (Backend-per-router) are the fix.
init() for business logic: The init() self-registration pattern is legitimate for plugins (Caddy modules, Prometheus SD providers, Kubernetes types). It is illegitimate for initializing state that tests need to control. The CockroachDB CCL hook pattern (var Hook func; init() { Hook = impl }) is the right refinement: expose the hook as an overridable variable, not a hardcoded init effect.
Promiscuous context values: Traefik notes this as a concern — context values are used for middleware names and route info. Context values are appropriate for request-scoped data that would be inconvenient to pass explicitly (request ID, trace ID). They are inappropriate for dependencies that should be explicit constructor arguments. The rule: if it changes per-request, context value; if it changes per-startup, constructor argument.
chan struct{} shutdown without WaitGroup: Closing a channel signals goroutines to stop but doesn’t wait for them to finish. Projects that close shutdownCh without pairing it with a WaitGroup drain can return from Stop() while goroutines are still running. The correct pattern: close channel, then wg.Wait().
Goroutine leaks from un-buffered error channels: errc := make(chan error) in a goroutine that might be abandoned before reading is a goroutine leak. The fix is make(chan error, 1) — the goroutine can always send without blocking. Moby, Caddy, and gh all apply this pattern correctly in their fan-in paths.
Exemplars#
etcd — Best-in-class concurrency architecture. The sharded wait map (pkg/wait), logical deadline wait, and FIFO scheduler with sync.Cond are all purpose-built, documented, and correct. The gRPC error taxonomy (rpctypes) is the clearest bidirectional error translation in the corpus. Study etcd for: distributed concurrency primitives, error taxonomy at protocol boundaries, and feature gate lifecycle.
restic — The most pedagogically clear use of the decorator pattern on an interface. The backend.Backend decorator stack (semaphore → logger → retry → cache → dryrun → limiter) with Unwrap() Backend on every layer and generic AsBackend[B] introspection is textbook. The errgroup usage throughout is also a reference for when and how to use structured parallel error collection.
Tailscale — Best use of generics in the corpus for infrastructure packages. The syncs package (AtomicValue[T], MutexValue[T], Map[K,V], ShardedMap[K,V]) demonstrates how to build generic concurrency primitives with real production value. The feature.Hook[Func] pattern for optional modules is worth emulating in any project with optional subsystems.
Note on fyne and crush#
fyne is a GUI framework — its patterns that look unusual (absence of context.Context, 5 uses total; use of fyne.Do() for thread marshaling; //go:build tags rather than runtime GOOS checks; no functional options) reflect GUI domain conventions rather than Go ecosystem trends. GUI toolkits use main-thread constraints and event loop coordination patterns that the rest of the corpus doesn’t need.
crush is a TUI app with AI-assisted development history. Its patterns are consistent with a well-architected modern Go codebase: comprehensive csync generic collections, Go 1.23 iter.Seq adoption, errgroup for parallel init, channel-per-request for permission handshake, pubsub.Broker[T] for typed events. The patterns show ahead-of-corpus generics adoption and unusually clean separation of concerns for an M-tier project. Consult analysis/results/P51-crush--ai-development-profile.md for signals distinguishing domain choices from potential AI-assisted development influences.