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. stopc is the send-side; donec is 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.Context was 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.NotifyContext or context.WithCancel flows top-down through every function call. Shutdown is triggered by calling the cancel function. All blocking operations respect ctx.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 via AtomicBool and EventBox). 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:

PatternProjectsMechanism
oklog/run actor groupPrometheusN run/interrupt pairs; first exit cancels all
golang.org/x/sync/errgroupMoby, restic, gh, PocketBaseFirst error cancels siblings
safe.Pool / goroutine trackerTraefik, NATSPanic recovery + WaitGroup + context
goro.Group + RunnerCloserManagerTemporal, DaprTyped lifecycle, reverse-order cleanup
suture.SupervisorSyncthingErlang-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) with log2(numContainers) heuristic — self-sizing pool
  • CockroachDB: raftScheduler — sharded by RangeID, dedicated shard for priority work, sync.Cond wakeup 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#

PrimitivePatternProjects
sync.OnceLazy initialization, idempotent startupAll projects; Go compiler 268 uses
sync.PoolPer-request object recyclingGin, Echo, Fiber (context recycling); NATS (31 Raft message pools)
sync.RWMutexRead-heavy concurrent cachesUniversal; GORM schema cache, Consul connection pool
sync.MapWrite-once concurrent lookupsGORM hot path, Tailscale, Crush csync package
atomic typesLock-free counters, CAS flagsNATS 950 uses; Kubernetes 2013 uses; Fiber timestamp cache
sync.CondWake on threshold / set changeetcd 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 both Unwrap() (stdlib) and Cause() (pkg/errors) simultaneously. HTTP handlers map via errdefs.IsNotFound(err). The taxonomy is stable, composable, and API-transparent.
  • etcd rpctypes: Bidirectional mapping — server-side ErrGRPC* status errors have client-side Err* mirrors. rpctypes.Error(err) converts gRPC status to typed EtcdError. Client retry logic uses errors.Is(err, rpctypes.ErrUserEmpty). The translation layer absorbs the gRPC/stdlib boundary.
  • CockroachDB cockroachdb/errors: Full annotation chain — errors.AssertionFailedf for invariants (4949 uses), errors.WithHint, errors.WithDetail, errors.WithIssueLink for user-facing errors, pgerror.WithCandidateCode for SQLSTATE. Every SQL error carries structured fields so the CLI can format hint/detail/code separately.
  • NATS ApiError: JetStream API errors use a uint16 ErrCode enum backed by an ApiErrors map. IsNatsErr(err, apiErrors["badRequest"]) is the check idiom. Server and client use the same type, avoiding translation.
  • Terraform tfdiags.Diagnostics: A []Diagnostic slice rather than a single error. This enables a parallel DAG walk to accumulate 50+ resource failures simultaneously via diags.Append(...) — impossible with the single-error convention. The trade-off: callers must remember to check diags.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 with fmt.Errorf("%v; %w", existing, new) on the *DB handle — the fluent API cannot return errors, so they accumulate
  • Prometheus: promql.ParseErrors []ParseErr — all syntax errors from a single PromQL parse
  • Kubernetes: field.ErrorList for validation — each field error carries a JSON path
  • restic: fatalError type — 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 error for 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: Option interface with unexported apply(*Viper) method — the “named option interface” variant that documents better than bare function types
  • Hugo: Generic Listeners[T] and selective ManagerOption in 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 Options struct 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() or Validate() — resolves string IPs to net.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:

SystemProjectsMechanism
Kubernetes FeatureGateKubernetes, etcdAlpha/Beta/GA lifecycle; DefaultFeatureGate.Enabled()
envknobTailscaleEnv var, lazily evaluated, mockable in tests
build-tag pairsrestic, Tailscale, Viper, fzfCompile-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 generate time

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 112 init() functions; modules/standard/imports.go blank-imports all standard modules
  • Kubernetes: localSchemeBuilder.Register(addKnownTypes) in every API package’s init()
  • Prometheus: Every SD provider calls discovery.RegisterConfig(&SDConfig{}) in init()
  • CockroachDB CCL: Core packages declare var MakeChangefeedMetricsHook func(...) initialized to nil; CCL packages override in init(). 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 Backend interface 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.Backend and implements Unwrap() Backend. The generic AsBackend[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 syncs package: 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 all interface{} 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 into http.HandlerFunc with compile-time proto type checking
  • Dapr Runner[T any]: retry/circuit-breaker/timeout policies work generically without interface{} casts
  • gh Option[T any]: Rust-inspired optional value type for nil-safe config lookups
  • Fiber releasePooledBinder[T interface{ Reset() }]: enforces the Reset() 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]any is 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:

PurposeProjectsTools
gRPC stubsKubernetes, etcd, Dapr, Temporal, NATSprotoc
Wire-up codeGrafanagoogle/wire
Mock generationTemporal (126 files), Consul, Daprgomock, mockery
Stringer for enumsMinIO, Delve, NATSgo generate stringer
DI + schemaGrafanaCUE + generators
Optimizer rulesCockroachDBoptgen DSL
Interface from structFiberifacemaker
Dynamic config keysTemporalcustom generator
Kubernetes informersKubernetescode-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().


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:

  1. Pass context.Context as the first parameter to every function that blocks or does I/O
  2. Use errgroup.WithContext for fan-out where errors from any goroutine should cancel siblings
  3. Use sync.Once for lazy initialization; prefer sync.OnceValue (Go 1.21+) for computed singletons
  4. Use sync.Pool for per-request object recycling in hot paths; always call Reset() before Put()
  5. Prefer context.WithCancel over chan struct{} for shutdown in new code; they’re equivalent but context chains better

Error handling:

  1. Define a typed error taxonomy early for any public-facing API boundary
  2. Use fmt.Errorf("%w", err) for wrapping; errors.Is/errors.As for inspection
  3. Use multi-error types ([]Diagnostic, ParseErrors) when parallel operations each independently succeed or fail
  4. Use panic only at initialization for deterministic programmer errors; use structured assertions at runtime

Configuration:

  1. Use functional options when most parameters have sensible defaults and the parameter count is 5-20
  2. Use config structs with a Complete() or Validate() step for complex initialization with ordering constraints
  3. Gate experimental features via build-tag pairs for zero runtime cost; use env vars for operator-tunable debug flags

Interface design:

  1. Define interfaces where they’re consumed, not where they’re implemented
  2. Keep interfaces small: 1-4 methods is the sweet spot in this corpus
  3. Always add var _ Interface = (*Impl)(nil) for public interface implementations

Generics:

  1. Use generics for data structures and infrastructure packages where type parameters eliminate type assertions
  2. Do not use generics to add abstraction to business logic that doesn’t need it
  3. 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.