
Golang
- 3 installs
- 19 repo stars
- Updated August 1, 2026
- xobotyi/cc-foundry
Helps with ai & agent building tasks.
About
golang is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- golang
- AI & Agent Building
- AI-coding skill
Golang by the numbers
- 3 all-time installs (skills.sh)
- Ranked #13,677 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 2, 2026 (Skillselion catalog sync)
npx skills add https://github.com/xobotyi/cc-foundry --skill golangAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3 |
|---|---|
| repo stars | ★ 19 |
| Last updated | August 1, 2026 |
| Repository | xobotyi/cc-foundry ↗ |
What it does
Helps with ai & agent building tasks.
Files
Go
Simplicity is the highest Go virtue. Resist abstraction until the cost of not abstracting is proven.
References
Extended examples, code patterns, and detailed rationale for the rules below live in ${CLAUDE_SKILL_DIR}/references/.
| Topic | Reference | Contents |
|---|---|---|
| Naming, declarations, interfaces, receivers, configuration, embedding | [${CLAUDE_SKILL_DIR}/references/idioms.md] | Extended code examples for each idiom, Go/bad vs good comparisons, decision criteria tables |
| Variable shadowing, defer traps, slice mutation, strings, copy safety | [${CLAUDE_SKILL_DIR}/references/gotchas.md] | Annotated code showing each pitfall with fix patterns, global state examples |
| Error creation, wrapping, Is/As, structured errors (golib/e) | [${CLAUDE_SKILL_DIR}/references/errors.md] | Error type decision tree, golib/e API (sentinels, fields, logging), wrapping context examples |
| Goroutines, channels, context, sync, errgroup, data races | [${CLAUDE_SKILL_DIR}/references/concurrency.md] | Worker lifecycle patterns, pipeline/fan-out/fan-in code, data race scenarios with fixes |
| Table tests, subtests, assertions, test doubles, benchmarks | [${CLAUDE_SKILL_DIR}/references/testing.md] | Full table-test template, testify usage, parallel subtests, httptest/iotest utilities |
| Project layout, packages, imports, file organization | [${CLAUDE_SKILL_DIR}/references/structure.md] | Package naming examples, import grouping, backward-incompatible change staged workflow |
Naming
Variables — The Distance Rule
Name length scales with scope distance.
| Scope | Style | Examples |
|---|---|---|
| Loop index | Single letter | i, j, k |
| Short function local | 1-3 chars | r (reader), b (buffer), ctx |
| Function parameter | Short but clear | name, path, opts |
| Package-level | Descriptive | defaultTimeout, maxRetries |
| Exported | Self-documenting | ErrNotFound, DefaultClient |
Receivers
Use 1-2 letter type abbreviation: c for Client, s for Server. Never self, this, me. Be consistent across all methods of a type.
Initialisms
All-caps for known initialisms: URL, HTTP, ID, API, SQL, XML. In mixed identifiers: userID, httpClient, xmlHTTPRequest.
Packages
- Short, lowercase, singular:
user,http,auth - Named by what they provide, not what they contain
- Never
util,common,misc,shared,helpers,types - Callers use package name as prefix — don't stutter:
widget.New()notwidget.NewWidget()
Getters and Setters
No Get prefix on getters. Setter uses Set prefix: u.Name() not u.GetName(), u.SetName(n).
Interface Names
One-method interfaces use method name plus -er: Reader, Writer, Formatter, Stringer. Honor canonical names — if your type has String() string, call it String, not ToString.
Constants
MixedCaps only — never ALL_CAPS or K prefix. Name by role, not value. If a constant has no role beyond its value, don't define it. const MaxRetries = 12 (good) vs const Twelve = 12 (bad).
Unexported Globals
Prefix with _: _defaultPort, _maxRetries. Exception: error values use err prefix: errNotFound.
Avoid Repetition
- Package name is part of every qualified reference:
widget.New()notwidget.NewWidget() - Don't encode type in name:
var users intnotvar numUsers int - Strip context obvious from scope: method on
*ProjectusesName()notProjectName()
Interfaces
- Consumer-side. Define where used, not where implemented. Producers return concrete types.
- No premature interfaces. Wait for a concrete need. Don't define "for mocking."
- Accept interfaces, return structs.
- Never pointer-to-interface. Interfaces are already reference types.
- Small interfaces. Prefer 1-3 methods.
io.Reader(1 method) is more powerful than any
10-method interface.
- Compile-time verification.
var _ http.Handler = (*Handler)(nil).
Receivers
Pointer vs Value
Use pointer receiver when: method mutates receiver, receiver contains sync.Mutex or similar, receiver is a large struct, or in doubt (default to pointer).
Use value receiver when: receiver is a small immutable value type (like time.Time), receiver is a map/func/chan (already reference types), all fields are value types with no mutability needs.
Never mix receiver types on a single type.
Maps, Funcs, and Channels
Already reference types. Never use pointers to them: func process(m map[string]int) not func process(m *map[string]int).
Context
- First parameter.
func Foo(ctx context.Context, ...). - Never store in structs. Pass through call chains explicitly.
- `context.Background()` only at the top level — in
main()or test setup. - Never include `context.Context` in option structs — pass as separate parameter.
Declarations
Variable Style
varfor zero values:var s string,var mu sync.Mutex:=for initializations:s := "hello",n := computeSize()- Top-level: use
var, omit type if obvious:var _defaultPort = 8080 - Specify type when it differs from expression:
var _e error = myError{}
Slices
- Nil slices are valid and preferred:
var s []string - Non-nil zero-length only when JSON encoding matters:
s := []string{}(nil encodes as
null, empty slice as [])
- Check empty:
len(s) == 0, nots == nil - Pre-allocate when size known:
make([]T, 0, n)
Maps
make(map[K]V)for programmatic population;make(map[K]V, n)with capacity hint- Literal for fixed content:
map[string]int{"a": 1, "b": 2}
Structs
- Always use field names in literals
- Omit zero-value fields unless they provide meaningful context
- Zero-value struct:
var user User - Pointer:
&T{}overnew(T)
Enums
Start iota at 1 to distinguish from zero-value (unless zero-value has meaning): const ( StatusActive Status = iota + 1; ... ).
Named Result Parameters
Use when they disambiguate or document caller obligations. Don't use just to enable naked returns, or when the name repeats the type.
Functions
- Synchronous default. Let callers add concurrency.
- Return errors, never exit. Only
main()callsos.Exit/log.Fatal. - `defer` for cleanup. Always.
- Early return on error. Happy path at minimum indentation.
- Accept `io.Reader`, not filenames. Improves reusability and testability.
- Close transient resources.
defer r.Body.Close(),defer rows.Close(),
defer f.Close().
Error Handling
Core Rules
- Always check errors. Never discard with
_. - Handle once. Log OR return — never both. If logging, degrade gracefully (don't return
the error). If returning, wrap with context and let the caller decide.
- Wrap with context. Prefer structured errors when the project uses them:
ErrNotFound.Wrap(err) or e.NewFrom("context", err). Standard fallback: fmt.Errorf("context: %w", err). Avoid "failed to" prefix in both.
- Error strings: lowercase, no trailing punctuation. They compose:
"read config: open file: permission denied".
- Don't panic. Return errors. Reserve panic for truly irrecoverable states.
- Use `errors.Is`/`errors.As` — never
==or direct type assertion on wrapped errors.
Error Creation
| Caller needs to match? | Message | Use |
|---|---|---|
| No | Static | errors.New("not found") |
| No | Dynamic | fmt.Errorf("file %q missing", name) |
| Yes | Static | Exported var ErrNotFound = errors.New(...) |
| Yes | Dynamic | Custom error type with Error() method |
Sentinel Errors
Naming: exported ErrXxx, unexported errXxx. Always wrap sentinels before returning so callers use errors.Is, not ==.
Custom Error Types
Naming: exported XxxError, unexported xxxError. Implement Error() string. Callers match with errors.As.
Structured Errors (golib/e)
When a project uses a structured error package like golib/e, prefer it consistently over fmt.Errorf. See ${CLAUDE_SKILL_DIR}/references/errors.md for API details.
Wrapping: %w vs %v
%wwraps (callers can unwrap witherrors.Is/errors.As) — default choice%vcreates new error with original's text only — use when underlying error is an
implementation detail
- Wrap when: caller provided the input that caused the error, or the underlying error is
part of your API contract
- Don't wrap when: error source is an implementation detail (wrapping commits you to the
underlying dependency)
Wrapping Context
- Keep context succinct:
"get user: %w"not"failed to get user: %w" - Place
%wat end of format string so error text mirrors chain structure (newest-to-oldest) - Don't repeat information the underlying error already provides
- Don't annotate if annotation adds no new information — just return
err
In-Band Errors
Don't use sentinel return values (-1, "", nil) to signal failure. Return (T, error) or (T, bool).
Must Functions
MustXYZ panics on error. Legitimate only for package-level initialization and test helpers. Never use in request handlers or runtime code paths.
Type Assertions
Always use comma-ok form: s, ok := val.(string). Never s := val.(string) (panics on wrong type).
Defer Errors
Don't silently ignore errors from deferred calls (f.Close(), rows.Close(), resp.Body.Close()). Propagate close error if no prior error exists. When intentionally ignoring, use _ = to make it explicit.
Internal Panic/Recover
Acceptable only when panics never escape package boundaries and a top-level deferred recover translates them to errors. Rare — see ${CLAUDE_SKILL_DIR}/references/errors.md for the full pattern.
Error Flow
Indent errors, keep happy path flat. Early return on error, never nest the happy path in else blocks.
Gotchas
Variable Shadowing
:= in inner blocks (if/for) silently hides outer variables. The err variable is commonly shadowed. Use go vet -shadow or golangci-lint to detect. Always verify assignments in inner blocks use = (not :=) when targeting outer-scope variables.
Defer Argument Evaluation
defer evaluates arguments immediately, not when the deferred function runs. Use closures to capture current values: defer func() { notify(status) }().
Defer in Loops
defer runs when the surrounding function returns, not at end of loop iteration. Extract loop body to a function so defer fires per iteration.
Slice Append Mutation
append on a slice with remaining capacity mutates the underlying array. Slices derived from the same array see each other's writes. Fix: use full slice expression s[:len(s):len(s)] to cap capacity, or explicit copy.
Strings: Runes vs Bytes
len(s) returns byte count, not rune count. Use range over string to iterate runes (not s[i]). Use utf8.RuneCountInString(s) for rune count.
String Concatenation
Use strings.Builder with Grow when concatenating in a loop — += is O(n^2). For a few fixed strings, + or fmt.Sprintf is fine.
Copy Safety
- Never copy
sync.Mutexor types containing one - Don't copy structs with pointer fields unless you understand aliasing
- Copy slices/maps at API boundaries to prevent external mutation
Fixed Bit-Width Types
Prefer int unless a specific width is required by a protocol, binary format, or performance constraint. int8, uint16, etc. are prone to silent overflow.
Signal Boosting
When code does the opposite of what's common (e.g., checking err == nil instead of err != nil), add a comment to draw attention.
Typed Nil Interface Trap
A (*T)(nil) assigned to an interface is non-nil. Return explicit nil when the function returns an interface type, never a typed nil.
Concurrency
Goroutine Lifecycle
Every goroutine must have: (1) a predictable exit condition, and (2) a way for other code to wait for it to finish. No fire-and-forget goroutines — they leak memory and cause data races.
Cancellation: context.Context (Primary)
Use context.Context as the default for all goroutine lifecycle management. Caller creates context with cancel, goroutine selects on ctx.Done().
Joining: sync.WaitGroup
Use sync.WaitGroup to wait for multiple goroutines to finish. It handles joining, not cancellation. Call wg.Add(1) before launching, defer wg.Done() inside the goroutine.
Worker Lifecycle Pattern
For long-lived goroutines, wrap in a struct with context.Context for cancellation and a done channel or WaitGroup for joining. Close done channel via defer close(w.done) in the run method.
Stop + Done Channels (Alternative)
When context.Context is unavailable (infrastructure code predating context), use explicit stop/done channels. Prefer context.Context in new code.
Channels vs Mutexes
| Relationship | Mechanism | Why |
|---|---|---|
| Parallel goroutines accessing shared state | sync.Mutex | Synchronization |
| Concurrent goroutines coordinating work | Channels | Communication/orchestration |
| Transferring ownership of a resource | Channels | Signaling completion |
Mutexes protect shared state. Channels coordinate independent actors.
Channel Rules
- Size: zero or one. Larger buffers require justification — you must know what prevents
the channel from filling.
- Direction in signatures. Specify
<-chanorchan<-in function parameters/returns. - Close from sender side. Never close from receiver.
- Sends on closed channels panic. Ensure all sends finish before closing.
Pipeline Pattern
Stages connected by channels: each stage receives from upstream, processes, sends downstream. Close output channel via defer close(out) in the goroutine.
Fan-Out, Fan-In
Fan-out: multiple goroutines read from one channel. Fan-in: merge multiple channels into one using a WaitGroup to close the merged channel when all inputs are done.
Bounded Parallelism
Limit concurrent work with a fixed worker pool reading from a shared channel.
Select Behavior
When multiple cases are ready, select picks one at random — not in source order. For priority, drain the work channel after receiving the stop signal.
Nil Channels
A nil channel blocks forever on send and receive. Set a channel to nil to remove it from a select at runtime.
errgroup
Prefer errgroup.WithContext over manual sync.WaitGroup + error collection. It manages goroutine groups with error propagation and context cancellation. First non-nil error from any goroutine is returned by g.Wait().
Context Propagation
Don't pass HTTP request context to background goroutines — it cancels when the response is sent. Use context.WithoutCancel(r.Context()) (Go 1.21+) for fire-and-forget background work.
Synchronization Primitives
- Mutex: zero-value is valid. Use named field
mu sync.Mutex, never embed. Never copy.
Use defer mu.Unlock() unless nanosecond performance matters.
- Atomics: for simple flags/counters, prefer
sync/atomictypes (atomic.Bool,
atomic.Int64).
Data Race Gotchas
- Append on shared slices:
appendisn't data-race-free when slice has spare capacity.
Copy before passing to goroutines.
- Map/slice assignment doesn't copy: both variables point to same backing storage. Deep
copy inside critical section (maps.Clone).
- String formatting deadlocks:
fmt.Errorf("%v", obj)may callobj.String(), which may
lock the same mutex. Validate before locking, or format with direct field access.
Concurrency Rules
- No goroutines in
init()— spawn in constructors with lifecycle management. - Use
selectwith done/context for cancellable operations.
Testing
Table-Driven Tests
- Slice named
tests, each casett - Inputs prefixed
give, outputs prefixedwant - Always use
t.Runwith descriptive names - Use field names in struct literals
- Omit zero-value fields unless they add context
- Every row must use every field — uniform logic only
When NOT to Use Table Tests
Split into separate Test... functions when: different cases need different setup/mocking, conditional assertions inside the loop, complex mock configuration per case, or table fields used only by some cases.
Subtests
t.Run creates subtests: t.Fatal stops only the current subtest, run individually with go test -run=TestX/case, shared setup/teardown via parent function.
Parallel Tests
Call t.Parallel() in subtests. In Go 1.22+, tt is safe in the closure. For Go < 1.22, shadow: tt := tt. Group parallel subtests with teardown by nesting under an intermediate t.Run("group", ...).
Assertions (testify)
- `require` — stops test on failure. Use for error checks and nil guards.
- `assert` — reports failure, continues. Use for independent value checks.
require.Equal/assert.Equalfor struct and slice comparison — neverreflect.DeepEqual
directly.
assert.ElementsMatchfor order-independent slice comparison.
Test Error Semantics
- Prefer
errors.Is/errors.Asfor semantic matching require.ErrorContainsfor substring when no sentinel available- Never use exact string matching on error messages
t.Error vs t.Fatal
Prefer t.Error to report all failures at once. Use t.Fatal only for setup failures or when a check makes subsequent checks impossible.
t.Fatal in Goroutines
t.Fatal/t.Fatalf/t.FailNow must only be called from the goroutine running the test function. Use t.Errorf + return in spawned goroutines. Note: t.Parallel() does NOT create a new goroutine — t.Fatal is safe in parallel subtests.
Test Helpers
Mark with t.Helper() so failures report the caller's line. Don't use t.Helper() in assert-like wrappers — it hides the connection between failure and cause.
Test Double Package Naming
Name by appending test to production package: creditcardtest. Use simple names (Stub, Fake) when only one type needs doubling; prefix with type name (StubService, StubStoredValue) when multiple. Prefix test double variables: var spyCC creditcardtest.Spy.
Scoped Test Setup
Keep setup scoped to tests that need it. Don't use init() or package-level vars for test data. Use sync.Once for expensive setup shared across tests.
Test Cache Safety
Go's test cache uses file mtime and env values. Never write to source directory in tests — use t.TempDir() for temp files and t.Setenv() for environment variables.
Live Services Over Mocks
Prefer real service instances (databases, caches, brokers) over synthetic mocks. Gate slow tests behind environment variables and skip when not set.
Test Naming and Organization
Test_TypeNamewith underscore for type-level tests,t.Run()for method/scenario- Black-box preferred:
package foo_testinfoo_test.go - White-box when needed:
package fooinfoo_internal_test.go - Benchmark files:
foo_benchmark_test.goorfoo_benchmark_internal_test.go
Block Scoping
Use bare blocks {} for logical grouping when separate test reporting is unnecessary. Use t.Run() when you need parallel execution, selective running, or per-scenario reporting.
Complementary Operations
Test complementary operations together (Put + Get) when it reduces duplication. Split only when operations have independent failure modes.
Compare Stable Results
Don't assert on serialization output — parse and compare semantically. Never depend on json.Marshal field ordering.
Runnable Examples
Write func Example... for complex APIs — godoc renders them, go test verifies them. The // Output: comment makes the example a test.
Race Detection
Always run tests with -race for concurrent code. Enable in CI. Use //go:build !race to exclude specific files if needed.
Avoid Sleeping
time.Sleep in tests creates flaky tests. Use channels, WaitGroups, or polling with timeout. If synchronization is impossible, use a retry/poll loop with deadline.
Testing Utilities
httptest.NewRequest/httptest.NewRecorderfor in-process HTTP handler testinghttptest.NewServerfor testing clients against fake serverstesting/iotest.ErrReaderfor error-injecting readerstesting/iotest.OneByteReaderfor one-byte-at-a-time reads
Benchmarks
- Use
b.Loop()(Go 1.24+) orfor i := 0; i < b.N; i++ b.ResetTimer()after expensive setupb.ReportAllocs()to track allocations- Assign result to package-level var to prevent compiler elimination
- Use
-benchtime=5sorbenchstatfor stable micro-benchmarks
Project Structure
Layout Principles
- `internal/` for encapsulation. All server logic, supporting packages not part of public
API. Refactor freely without breaking external consumers.
- `cmd/` for commands. Each subdirectory declares
package main. Install with
go install .../cmd/tool@latest.
- Start flat. Add directories only when a package needs internal helpers, multiple commands
exist, or sub-packages serve distinct importable purposes.
Package Design
- Lowercase, no underscores:
userstorenotuser_store - Singular:
usernotusers - By purpose:
auth,cache,handler
Imports
Two groups separated by blank line: (1) standard library, (2) everything else. Alias only to avoid conflicts. Blank imports (import _ "pkg") only in main packages or tests. Dot imports only in test files to resolve circular dependencies.
Function Organization
Within a file, order by: (1) types, constants, variables, (2) constructor (New...), (3) exported methods grouped by receiver, (4) unexported methods grouped by receiver, (5) utility functions. Order by rough call order — callers before callees.
File Organization
- One file per major type (for large types)
- Test file adjacent:
foo.go->foo_test.go - Keep related code together — don't scatter features across files
doc.gofor package-level documentation if needed- Kebab-case for Go source files:
user-service.go,http-handler.go
Backward-Incompatible Changes
Staged workflow: (1) add new code without touching old, (2) migrate callers, (3) remove old code. Each step is a separate commit. Never combine breaking changes with new functionality.
Configuration Patterns
For constructors with 3+ optional parameters, choose between option structs and functional options.
Option Structs
Use when most callers need several options, or options are shared across functions. Benefits: self-documenting field names, zero-value omission, easy to share and extend. Never include context.Context in option structs.
Functional Options
Use when most callers need zero options, there are many options, or options require validation. Use the interface form (type Option interface{ apply(*options) }) over closures for testability. Options should accept parameters, not use presence as signal: rpc.FailFast(true) not rpc.EnableFailFast().
Decision Criteria
| Factor | Option Struct | Functional Options |
|---|---|---|
| Most callers need several options | Prefer | Either |
| Most callers need zero options | Either | Prefer |
| Options need validation | Either | Prefer |
| Options shared across functions | Prefer | Either |
| Third-party extensibility needed | Avoid | Prefer |
Zero-Value Design
Design types so the zero value is immediately useful — no constructor needed. var buf bytes.Buffer is ready to use. Only write constructors when non-zero defaults are required.
Embedding
Embedding promotes methods of the inner type to the outer type. Use embedding when promoted methods ARE your intended API. Use named fields when you don't want to expose the inner type's full method set. Never embed in public API structs unless the promoted surface is intentional — it commits your API to every exported method including future additions. Embedding in internal/ types is lower risk.
Long-Running Process Naming
- Run — blocks until process completes. Caller controls the goroutine.
- Start — returns immediately, spawns internal goroutine. Accept
context.Contextas
first parameter for cancellation.
Type Preferences
- Prefer
anyoverinterface{}(Go 1.18+). Only useanywhen truly accepting any type. - Use type aliases for semantic meaning:
type UserID stringadds type safety.
type MyString string adds nothing.
Doc Comments
Every exported symbol gets a doc comment starting with its name. Complete sentences, period-terminated. Package comment in doc.go or primary .go file. Unexported types: comment when behavior is non-obvious, skip when trivial.
Global State
Libraries must not force global state. Expose instance-based APIs. Global state is safe only when logically constant, stateless, or has no external side effects. If providing convenience, make the global API a thin proxy to an instance API, and restrict to binaries — never libraries.
Application
When writing Go code: apply all conventions silently — don't narrate each rule. If an existing codebase contradicts a convention, follow the codebase and flag the divergence.
When reviewing Go code: cite the specific violation and show the fix inline. Don't lecture — state what's wrong and how to fix it.
Bad: "According to Go conventions, error strings should be lowercase..."
Good: "errors.New("Not found.") -> errors.New("not found")"Code Navigation — LSP Required
A gopls LSP server is configured for .go files. Always use LSP tools for code navigation instead of Grep or Glob. LSP understands Go's type system, scope rules, and module boundaries — text search does not.
Tool Routing
| Task | LSP Operation | Why LSP over text search |
|---|---|---|
| Find where a function/type/method is defined | goToDefinition | Resolves imports, aliases, embedded types |
| Find all usages of a symbol | findReferences | Scope-aware, no false positives from string matches |
| Get type signature, docs, or return types | hover | Instant type info without reading source files |
| List all symbols in a file | documentSymbol | Structured output vs grepping for func/type |
| Find a symbol by name across the project | workspaceSymbol | Searches all packages |
| Find concrete types implementing an interface | goToImplementation | Knows the type system and implicit interfaces |
| Find what calls a function | incomingCalls | Precise call graph across module boundaries |
| Find what a function calls | outgoingCalls | Structured dependency map |
Grep/Glob remain appropriate for: text in comments, string literals, log messages, TODO markers, config values, build tags, file name patterns — anything that isn't a Go identifier.
When spawning subagents for Go codebase exploration, instruct them to use LSP tools. Subagents have access to the same LSP server.
Toolchain
- `golangci-lint`: single entry point for formatting and linting. Configure per project.
golangci-lint run— lint. Must pass before committing.golangci-lint fmt— format. Use instead of runninggofmt/goimportsseparately.
Integration
The coding skill governs workflow (discovery, planning, verification); this skill governs Go implementation choices. Both are active simultaneously.
Simplicity is the highest Go virtue. When in doubt, write boring code.
{
"sources": {
"Effective Go": "https://go.dev/doc/effective_go",
"Go Code Review Comments": "https://go.dev/wiki/CodeReviewComments",
"Go Common Mistakes": "https://go.dev/wiki/CommonMistakes",
"Uber Go Style Guide": "https://raw.githubusercontent.com/uber-go/guide/master/style.md",
"Google Go Style Decisions": "https://google.github.io/styleguide/go/decisions",
"Google Go Style Best Practices": "https://google.github.io/styleguide/go/best-practices",
"100 Go Mistakes": "https://100go.co/",
"Go Wiki Test Comments": "https://go.dev/wiki/TestComments",
"Go Error Handling": "https://go.dev/blog/error-handling-and-go",
"Go 1.13 Error Wrapping": "https://go.dev/blog/go1.13-errors",
"Go Concurrency Patterns Pipelines": "https://go.dev/blog/pipelines",
"Go Modules Layout": "https://go.dev/doc/modules/layout",
"Go Subtests and Table-Driven Tests": "https://go.dev/blog/subtests"
},
"lastFetched": "2026-02-14T20:42:06.532Z"
}
Go Concurrency
Goroutine lifecycle, channels, pipelines, synchronization primitives, and data race prevention.
Goroutine Lifecycle
Every goroutine must have: 1. A predictable exit condition (or a way to signal stop) 2. A way for other code to wait for it to finish
Goroutines that violate this leak memory, hold references, and cause data races.
Context-Based Cancellation (Primary)
context.Context is the idiomatic mechanism for cancellation propagation in Go. Use it as the default for all goroutine lifecycle management:
func (w *Worker) Run(ctx context.Context) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
flush()
case <-ctx.Done():
return ctx.Err()
}
}
}
// Caller controls lifecycle
ctx, cancel := context.WithCancel(context.Background())
go w.Run(ctx)
// Later: signal stop
cancel()WaitGroup Pattern (Joining)
Use sync.WaitGroup to wait for multiple goroutines to finish — it handles joining, not cancellation:
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
process(item)
}(item)
}
wg.Wait()Worker With Lifecycle Management
For long-lived goroutines, wrap in a struct. Use context.Context for cancellation and a done channel or WaitGroup for joining:
type Worker struct {
done chan struct{}
}
func NewWorker(ctx context.Context) *Worker {
w := &Worker{
done: make(chan struct{}),
}
go w.run(ctx)
return w
}
func (w *Worker) run(ctx context.Context) {
defer close(w.done)
for {
select {
case <-ctx.Done():
return
default:
// work
}
}
}
func (w *Worker) Wait() {
<-w.done
}Stop + Done Pattern (Alternative)
When context.Context is unavailable (e.g., infrastructure code that predates context, or standalone signal channels), use explicit stop/done channels:
stop := make(chan struct{})
done := make(chan struct{})
go func() {
defer close(done)
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
flush()
case <-stop:
return
}
}
}()
// Later: signal stop and wait
close(stop)
<-donePrefer context.Context over raw stop/done channels in new code.
## Channels vs Mutexes
Choose based on goroutine relationship:
| Relationship | Mechanism | Why |
|-------------|-----------|-----|
| Parallel goroutines accessing shared state | `sync.Mutex` | Synchronization |
| Concurrent goroutines coordinating work | Channels | Communication / orchestration |
| Transferring ownership of a resource | Channels | Signaling completion |
**Parallel** = doing the same thing simultaneously (e.g., workers processing a queue).
**Concurrent** = doing different steps of a pipeline (e.g., producer → consumer).
Mutexes protect shared state. Channels coordinate independent actors.
## Channels
### Size: Zero or One
Channels should be unbuffered (0) or size 1. Larger buffers require justification —
you must know what prevents the channel from filling and blocking writers.
c := make(chan int) // unbuffered — synchronous handoff c := make(chan int, 1) // buffered — one item of slack
### Direction in Signatures
Specify channel direction in function signatures:
func producer() <-chan int // receive-only return func consumer(ch <-chan int) // receive-only parameter func pipe(in <-chan int) <-chan int // both directions
## Pipeline Pattern
A pipeline is stages connected by channels. Each stage receives from upstream,
processes, and sends downstream.
func gen(nums ...int) <-chan int { out := make(chan int) go func() { defer close(out) for _, n := range nums { out <- n } }() return out }
func sq(in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { out <- n * n } }() return out }
### Fan-Out, Fan-In
**Fan-out**: multiple goroutines read from the same channel.
**Fan-in**: merge multiple channels into one.
func merge(cs ...<-chan int) <-chan int { var wg sync.WaitGroup out := make(chan int) wg.Add(len(cs)) for _, c := range cs { go func(c <-chan int) { defer wg.Done() for n := range c { out <- n } }(c) } go func() { wg.Wait() close(out) }() return out }
### Cancellation with Done Channel
Every pipeline should accept a `done` channel for cancellation:
func sq(done <-chan struct{}, in <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for n := range in { select { case out <- n * n: case <-done: return } } }() return out }
Close `done` to broadcast cancellation to all stages. Prefer `context.Context` over
raw done channels in production code.
### Bounded Parallelism
Limit concurrent work with a fixed worker pool:
const numWorkers = 20 paths, errc := walkFiles(done, root) c := make(chan result)
var wg sync.WaitGroup wg.Add(numWorkers) for i := 0; i < numWorkers; i++ { go func() { defer wg.Done() for path := range paths { data, err := os.ReadFile(path) select { case c <- result{path, md5.Sum(data), err}: case <-done: return } } }() } go func() { wg.Wait() close(c) }()
### Select Chooses Randomly
When multiple cases are ready, `select` picks one at random — not in source order:
// Bug — disconnect may fire before all messages are consumed for { select { case v := <-messageCh: process(v) case <-disconnectCh: return // may fire early if both channels are ready } }
For a single-producer scenario, use a single channel or unbuffered channels.
For multi-producer, drain the work channel after receiving the stop signal.
### Nil Channels
A nil channel blocks forever on send and receive. Use this to remove cases from
`select` at runtime:
func merge(ch1, ch2 <-chan int) <-chan int { out := make(chan int) go func() { defer close(out) for ch1 != nil || ch2 != nil { select { case v, ok := <-ch1: if !ok { ch1 = nil; continue } out <- v case v, ok := <-ch2: if !ok { ch2 = nil; continue } out <- v } } }() return out }
Setting a channel to `nil` removes it from the `select` — the case will never fire.
## errgroup
`golang.org/x/sync/errgroup` manages a group of goroutines with error propagation
and context cancellation:
g, ctx := errgroup.WithContext(ctx)
for _, url := range urls { g.Go(func() error { return fetch(ctx, url) }) }
if err := g.Wait(); err != nil { // first non-nil error from any goroutine }
Prefer `errgroup` over manual `sync.WaitGroup` + error collection.
## Context Propagation
### Don't Propagate Request Context to Background Work
An HTTP request context cancels when the response is sent. Passing it to a background
goroutine causes premature cancellation:
// Bug — context cancels when response is written func handler(w http.ResponseWriter, r *http.Request) { resp := doWork(r.Context()) go publish(r.Context(), resp) // may cancel immediately writeResponse(w, resp) }
// Fix — detach from request lifecycle func handler(w http.ResponseWriter, r *http.Request) { resp := doWork(r.Context()) go publish(context.WithoutCancel(r.Context()), resp) writeResponse(w, resp) }
`context.WithoutCancel` (Go 1.21+) creates a context that inherits values but not
cancellation. Use it for fire-and-forget background work.
## Synchronization
### Mutexes
- Zero-value is valid — no `new(sync.Mutex)` needed
- Never embed in structs — use a named field: `mu sync.Mutex`
- Never copy a mutex
- Use `defer mu.Unlock()` unless nanosecond performance matters
type SafeMap struct { mu sync.Mutex data map[string]string }
func (m *SafeMap) Get(key string) string { m.mu.Lock() defer m.mu.Unlock() return m.data[key] }
### Atomics
For simple flags or counters, prefer `sync/atomic` types:
type Server struct { running atomic.Bool }
func (s *Server) Start() { if s.running.Swap(true) { return // already running } // ... }
## Data Race Gotchas
### Append on Shared Slices
`append` isn't data-race-free when the slice has spare capacity:
// Race — both goroutines write to index 0 of the same backing array s := make([]int, 0, 1) go func() { s1 := append(s, 1); fmt.Println(s1) }() go func() { s2 := append(s, 2); fmt.Println(s2) }()
Fix: copy the slice before passing to goroutines.
### Map/Slice Assignment Doesn't Copy
Assigning a map or slice to a new variable copies the header, not the data.
Both variables point to the same backing storage:
// Race — balances and m share the same map data func (c Cache) AverageBalance() float64 { c.mu.RLock() m := c.balances // NOT a copy — same underlying data c.mu.RUnlock() for _, v := range m { / race with AddBalance */ } }
// Fix — deep copy inside critical section func (c *Cache) AverageBalance() float64 { c.mu.RLock() m := maps.Clone(c.balances) c.mu.RUnlock() // safe to iterate m without lock }
### String Formatting Deadlocks
`fmt.Errorf("%v", obj)` may call `obj.String()`, which may lock the same mutex:
// Deadlock — UpdateAge holds Lock, String() tries RLock (same mutex) func (c *Customer) UpdateAge(age int) error { c.mu.Lock() defer c.mu.Unlock() if age < 0 { return fmt.Errorf("invalid age for %v", c) // calls c.String() } c.age = age return nil }
func (c *Customer) String() string { c.mu.RLock() // deadlock — already write-locked defer c.mu.RUnlock() return fmt.Sprintf("id=%s age=%d", c.id, c.age) }
Fix: validate before locking, or format with direct field access (`c.id`) instead
of `%v`.
## Rules
1. **Synchronous by default.** Let callers add concurrency.
2. **No fire-and-forget goroutines.** Every goroutine must be joinable.
3. **No goroutines in `init()`.** Spawn in constructors with lifecycle management.
4. **Close channels from the sender side.** Never close from the receiver.
5. **Use `select` with done/context** for cancellable operations.
6. **Sends on closed channels panic.** Ensure all sends finish before closing.
Go Error Handling
Error creation, wrapping, matching, structured error types, and error handling patterns.
Error Creation Decision Tree
| Caller needs to match? | Message | Use |
|---|---|---|
| No | Static | errors.New("not found") |
| No | Dynamic | fmt.Errorf("file %q missing", name) |
| Yes | Static | Exported var ErrNotFound = errors.New(...) |
| Yes | Dynamic | Custom error type with Error() method |
When using a structured error package, sentinels become objects with wrapping methods — see Structured Error Types below.
Sentinel Errors
// Exported — part of your API contract
var ErrNotFound = errors.New("not found")
var ErrPermission = errors.New("permission denied")
// Unexported — internal use only
var errTimeout = errors.New("operation timed out")Naming: exported ErrXxx, unexported errXxx.
Always wrap sentinels before returning so callers use errors.Is, not ==:
func Fetch(id string) error {
if !exists(id) {
return fmt.Errorf("fetch %q: %w", id, ErrNotFound)
}
// ...
}Custom Error Types
// Exported — callers match with errors.As
type NotFoundError struct {
Resource string
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s not found", e.Resource)
}
// Unexported — internal use
type resolveError struct {
Path string
}
func (e *resolveError) Error() string {
return fmt.Sprintf("resolve %q", e.Path)
}Naming: exported XxxError, unexported xxxError.
Structured Error Types
When a project uses a structured error package (e.g., golib/e), errors become first-class objects with method-based wrapping and key-value metadata fields.
Sentinels as Objects
// Define sentinels as error objects, not bare values
var ErrNotFound = e.New("not found")
var ErrValidation = e.New("validation failed")
// Wrap via method — sentinel provides context, cause is the argument
func Fetch(id string) error {
item, err := db.Get(id)
if err != nil {
return ErrNotFound.Wrap(err)
}
// ...
}
// → "not found: record does not exist"Structured Metadata
Attach key-value fields to errors for structured logging and diagnostics:
return ErrValidation.Wrap(err,
fields.F("user_id", userID),
fields.F("retry_count", retries),
)
// → "validation failed (user_id=42, retry_count=3): field email is required"Use snake_case for field keys. Fields are metadata for machines — the reason string is for humans.
Creating and Wrapping
// New error with reason
return e.New("operation failed")
// Wrap existing error with context (new error wrapping cause)
return e.NewFrom("failed to create service", err)
// Convert any error to structured error (preserves message, not unwrappable)
return e.From(err)
// Sentinel wrapping — sentinel provides context, cause is the argument
var ErrNotFound = e.New("not found")
return ErrNotFound.Wrap(err)Adding Fields
// Via constructor
return e.NewFrom("db query failed", err,
fields.F("query", query),
fields.F("duration_ms", elapsed.Milliseconds()),
)
// Via chaining (each call returns new instance)
return e.NewFrom("request failed", err).
WithField("user_id", userID).
WithField("retry_count", retries)
// Single field shorthand
return e.New("connection failed").WithField("host", "localhost")Error Logging Integration
Structured errors integrate with loggers via e.Log():
// Log extracts reason, wrapped error, and fields automatically
e.Log(err, logger.Error)e.Log uses the error's Reason() as the log message, the wrapped error as the error field, and attached fields as structured log fields. For non-structured errors, it falls back to err.Error() as the message.
Immutability
All methods return new error instances. Sentinels are safe to reuse:
var ErrAuth = e.New("authentication failed")
// Each call returns a new error wrapping a different cause
return ErrAuth.Wrap(err) // new instance
return ErrAuth.Wrap(other, fields.F("ip", ip)) // another new instanceWhen to Use Structured Errors
| Situation | Standard fmt.Errorf | Structured error package |
|---|---|---|
| Simple CLI tools | Sufficient | Overkill |
| Libraries with public API | Preferred | Either |
| Services with structured logging | Either | Preferred |
| Codebase with field-aware observability | Avoid | Preferred |
If your project uses a structured error package, prefer it consistently over fmt.Errorf — mixing approaches fragments error handling patterns.
Error Wrapping
%w vs %v
- `%w`: wraps the error. Callers can unwrap with
errors.Is/errors.As. Default choice. - `%v`: new error with original's text only. Hides the underlying error. Use when the
underlying error is an implementation detail.
// Wrap — caller can inspect underlying error
return fmt.Errorf("query user %q: %w", id, err)
// Don't wrap — underlying error is implementation detail
return fmt.Errorf("process request: %v", err)When to Wrap
Wrap when:
- The caller provided the input that caused the error (e.g., an
io.Reader) - The underlying error is part of your documented API contract
Don't wrap when:
- The error source is an implementation detail (e.g., which database you use)
- Wrapping would commit you to an underlying dependency
Wrapping makes the error part of your API. If you wrap sql.ErrNoRows, you can never switch databases without breaking callers who check for it.
Context in Wrapping
Keep context succinct. Avoid "failed to" — it stacks up the call chain:
// Bad — produces: "failed to get user: failed to query DB: connection refused"
return fmt.Errorf("failed to get user: %w", err)
// Good — produces: "get user: query DB: connection refused"
return fmt.Errorf("get user: %w", err)%w Placement
Place %w at the end of the format string so error text mirrors chain structure (newest-to-oldest):
// Good — prints: "read config: open file: permission denied"
return fmt.Errorf("read config: %w", err)
// Bad — prints oldest-to-newest, confusing
return fmt.Errorf("%w: read config", err)Avoid Redundant Context
Don't repeat information the underlying error already provides:
// Bad — "settings.txt" appears twice in output
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("could not open settings.txt: %v", err)
}
// Good — adds meaning without duplicating path
if err := os.Open("settings.txt"); err != nil {
return fmt.Errorf("launch codes unavailable: %v", err)
}Don't annotate if the annotation adds no new information:
// Bad — just return err
return fmt.Errorf("failed: %v", err)errors.Is and errors.As
errors.Is — Match Sentinel Values
if errors.Is(err, ErrNotFound) {
// err, or any error it wraps, matches ErrNotFound
}Walks the entire error chain. Never use err == ErrNotFound.
errors.As — Match Error Types
var nfe *NotFoundError
if errors.As(err, &nfe) {
// nfe is set to the matched error
log.Printf("resource not found: %s", nfe.Resource)
}Takes pointer-to-pointer for pointer error types. Walks the entire chain.
Custom Is/As Methods
Error types can customize matching:
func (e *Error) Is(target error) bool {
t, ok := target.(*Error)
if !ok {
return false
}
return (e.Path == t.Path || t.Path == "") &&
(e.User == t.User || t.User == "")
}Handle Errors Once
The most common error handling mistake: logging AND returning.
// BAD — error handled twice
u, err := getUser(id)
if err != nil {
log.Printf("could not get user %q: %v", id, err)
return err // caller will also log/handle
}
// GOOD — wrap and return, let caller decide
u, err := getUser(id)
if err != nil {
return fmt.Errorf("get user %q: %w", id, err)
}
// GOOD — log and degrade gracefully (don't return the error)
if err := emitMetrics(); err != nil {
log.Printf("emit metrics: %v", err)
// continue — metrics are not critical
}Error Flow
Indent errors, keep happy path flat:
// Good — early return, flat happy path
data, err := fetch(url)
if err != nil {
return err
}
result := process(data)
return save(result)
// Bad — nested happy path
if data, err := fetch(url); err == nil {
if err := save(process(data)); err == nil {
return nil
} else {
return err
}
} else {
return err
}Avoid In-Band Errors
Don't use sentinel return values (-1, "", nil) to signal failure:
// Bad — caller might miss the error
func Lookup(key string) int // returns -1 if not found
// Good — error is explicit
func Lookup(key string) (int, error)
// Good — ok pattern for simple presence checks
func Lookup(key string) (value string, ok bool)Don't Panic
Reserve panic for truly irrecoverable conditions:
- Violated invariants that indicate a programming error
template.Mustand similar initialization helpers
For everything else, return errors. Even in tests, prefer t.Fatal over panic.
Must Functions
MustXYZ names indicate functions that panic on error. Legitimate only for:
- Package-level initialization:
var re = regexp.MustCompile(...) - Test helpers:
mustMarshal(t, v)(uset.Fatal, notpanic)
// Good — package-level "constant"
var defaultVersion = MustParse("1.2.3")
// Bad — runtime panic on user input
func Handle(w http.ResponseWriter, r *http.Request) {
v := MustParse(r.FormValue("version")) // will panic
}Never use Must in request handlers or runtime code paths.
Type Assertions
Always use the comma-ok form:
// Bad — panics on wrong type
s := val.(string)
// Good — graceful handling
s, ok := val.(string)
if !ok {
return fmt.Errorf("expected string, got %T", val)
}Defer Errors
Don't silently ignore errors from deferred calls. Common offenders: f.Close(), rows.Close(), resp.Body.Close(), tx.Rollback().
// Bad — close error silently dropped
defer f.Close()
// Good — propagate close error if no prior error
defer func() {
closeErr := f.Close()
if err == nil {
err = closeErr
}
}()
// Acceptable — error is non-critical, explicitly ignore
defer func() { _ = resp.Body.Close() }()When ignoring a defer error, use _ = to make it explicit. Add a comment if the rationale isn't obvious.
Internal Panic/Recover
Panics as internal control flow are acceptable only when:
- They never escape across package boundaries
- A top-level deferred
recovertranslates them to returned errors - The panic type is distinguishable from unexpected panics
type syntaxError struct{ msg string }
func parseInt(in string) int {
n, err := strconv.Atoi(in)
if err != nil {
panic(&syntaxError{"not a valid integer"})
}
return n
}
func Parse(in string) (_ *Node, err error) {
defer func() {
if p := recover(); p != nil {
sErr, ok := p.(*syntaxError)
if !ok {
panic(p) // re-panic: not ours
}
err = fmt.Errorf("syntax error: %v", sErr.msg)
}
}()
// ... calls parseInt internally
}This pattern is rare — only use for deeply nested internal parsers where plumbing error returns adds complexity without value.
Error Strings
- Lowercase (unless beginning with proper noun or acronym)
- No trailing punctuation
- They compose:
fmt.Errorf("read config: %w", err)→"read config: open file: permission denied"
// Good
errors.New("something bad")
fmt.Errorf("read %q: %w", path, err)
// Bad
errors.New("Something bad.")
fmt.Errorf("Failed to read %q: %w", path, err)Go Gotchas
Common pitfalls that compile but produce incorrect behavior at runtime.
Variable Shadowing
Redeclaring a variable in an inner block hides the outer one. This compiles but silently uses the wrong variable:
// Bad — err in outer scope never set
var client *Client
var err error
if tracing {
client, err := createTracedClient() // shadows outer err
_ = client
}
if err != nil { // always nil — wrong err// Good — explicit assignment
var client *Client
var err error
if tracing {
client, err = createTracedClient() // assigns outer err
}Be especially careful with := in if/for blocks. The err variable is commonly shadowed. Use go vet -shadow or golangci-lint to detect.
Defer
Argument Evaluation
defer evaluates arguments immediately, not when the deferred function runs:
// Bug — status is "" at defer time
var status string
defer notify(status)
status = "done"
// Fix 1 — closure captures variable
defer func() { notify(status) }()
// Fix 2 — pass pointer
defer notify(&status)Defer in Loops
defer runs when the surrounding function returns, not at end of loop iteration. Inside a loop, deferred closes accumulate until the function exits:
// Bug — file descriptors leak until function returns
for _, path := range paths {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close() // won't close until outer function returns
}
// Fix — extract to function
for _, path := range paths {
if err := processFile(path); err != nil {
return err
}
}
func processFile(path string) error {
f, err := os.Open(path)
if err != nil { return err }
defer f.Close()
// ...
}Slices: Append Mutation
append on a slice with remaining capacity mutates the underlying array. Slices derived from the same array see each other's writes:
// Dangerous — s2 and s3 share backing array
s := make([]int, 0, 5)
s = append(s, 1, 2, 3)
s2 := append(s, 4) // writes to index 3
s3 := append(s, 5) // overwrites index 3 — s2[3] is now 5Fix: use full slice expression or copy:
// Safe — cap limited, forces new allocation
s2 := append(s[:len(s):len(s)], 4)
// Safe — explicit copy
s2 := make([]int, len(s), len(s)+1)
copy(s2, s)
s2 = append(s2, 4)Strings
Runes vs Bytes
len(s) returns byte count, not rune count. A UTF-8 character can span 1–4 bytes. Use range over a string to iterate runes, not s[i]:
s := "café"
len(s) // 5 (bytes), not 4 (runes)
utf8.RuneCountInString(s) // 4
// Iterate runes — use range value
for _, r := range s {
fmt.Printf("%c", r)
}
// Access ith rune — convert first
r := []rune(s)[3] // 'é'Concatenation
Use strings.Builder when concatenating in a loop. += allocates a new string each iteration:
// Bad — O(n²) allocations
s := ""
for _, v := range items {
s += v
}
// Good — single allocation with Grow
var b strings.Builder
b.Grow(totalLen)
for _, v := range items {
b.WriteString(v)
}
result := b.String()For a few fixed strings, + or fmt.Sprintf is fine.
Copy Safety
- Don't copy structs with pointer fields unless you understand aliasing
- Never copy a
sync.Mutexor types containing one - Copy slices/maps at API boundaries to prevent external mutation:
func (d *Driver) SetTrips(trips []Trip) {
d.trips = make([]Trip, len(trips))
copy(d.trips, trips)
}Global State
Libraries must not force clients to use global state. Expose instance-based APIs and let callers manage lifecycle:
// Bad — global registry, untestable, order-dependent
package sidecar
var registry = make(map[string]*Plugin)
func Register(name string, p *Plugin) error { /* modifies global */ }// Good — instance-based, testable, composable
package sidecar
type Registry struct { plugins map[string]*Plugin }
func New() *Registry { return &Registry{plugins: make(map[string]*Plugin)} }
func (r *Registry) Register(name string, p *Plugin) error { ... }Global state is safe only when it is logically constant, stateless (e.g., caches where hits and misses are indistinguishable), or has no external side effects.
If you must provide convenience, make the global API a thin proxy to an instance API (like http.Handle proxying to http.DefaultServeMux), and restrict global API usage to binaries — never in libraries.
Fixed Bit-Width Types
Use int8, uint16, int32, etc. with caution — they are prone to overflow errors. Prefer int unless a specific width is required by a protocol, binary format, or performance constraint.
// Bad — silent overflow risk
var count int8 = 200 // overflows to -56
// Good — use int unless width matters
var count int = 200Signal Boosting
When code does the opposite of what's common, add a comment to draw attention:
// Uncommon — checking err == nil (no error), not err != nil
if err := doSomething(); err == nil { // if NO error
// ...
}Go Idioms
Naming conventions, declaration patterns, interface design, receivers, configuration, and type usage.
Naming
Variables
The distance rule: name length scales with scope distance.
| Scope | Style | Examples |
|---|---|---|
| Loop index | Single letter | i, j, k |
| Short function local | 1-3 chars | r (reader), b (buffer), ctx |
| Function parameter | Short but clear | name, path, opts |
| Package-level | Descriptive | defaultTimeout, maxRetries |
| Exported | Self-documenting | ErrNotFound, DefaultClient |
Receivers: 1-2 letter abbreviation of the type. c for Client, s for Server. Never self, this, me. Be consistent — if one method uses c, all methods use c.
Initialisms
All-caps for known initialisms: URL, HTTP, ID, API, SQL, XML. In mixed identifiers: userID, httpClient, xmlHTTPRequest.
Packages
- Short, lowercase, singular:
user,http,auth - Named by what they provide, not what they contain
- Never
util,common,misc,shared,helpers,types - Callers use package name as prefix:
chubby.Filenotchubby.ChubbyFile
Getters and Setters
No Get prefix on getters. Setter uses Set prefix:
// Bad
func (u *User) GetName() string { return u.name }
// Good
func (u *User) Name() string { return u.name }
func (u *User) SetName(n string) { u.name = n }Interface Names
One-method interfaces use method name plus -er: Reader, Writer, Formatter, Stringer. Honor canonical names — if your type has a String() string method, call it String, not ToString.
Constants
MixedCaps only — never ALL_CAPS or K prefix:
// Good
const MaxPacketSize = 512
const defaultTimeout = 30 * time.Second
// Bad
const MAX_PACKET_SIZE = 512
const kMaxBufferSize = 1024Name by role, not value. If a constant has no role beyond its value, don't define it:
// Bad — name restates value
const Twelve = 12
// Good — name explains role
const MaxRetries = 12Unexported Globals
Prefix with _: _defaultPort, _maxRetries. Exception: error values use err prefix: errNotFound.
Avoid Repetition
Package name is part of every qualified reference — don't stutter:
// Bad → Good
widget.NewWidget → widget.New
widget.NewWidgetWithName → widget.NewWithName
db.LoadFromDatabase → db.LoadDon't encode type in variable names:
// Bad → Good
var numUsers int → var users int
var nameString string → var name string
var primaryProject *Project → var primary *ProjectStrip context already obvious from scope:
// Bad — in package "sqldb"
type DBConnection struct{}
// Good
type Connection struct{}
// Bad — method on *Project
func (p *Project) ProjectName() string
// Good
func (p *Project) Name() stringZero-Value Design
Design types so the zero value is immediately useful — no constructor needed:
// Good — zero value is an empty, ready-to-use buffer
var buf bytes.Buffer
buf.WriteString("hello")
// Good — zero value is an unlocked mutex
var mu sync.MutexOnly write constructors when non-zero defaults are required:
func NewServer(addr string) *Server {
return &Server{addr: addr, timeout: 30 * time.Second}
}Declarations
Variable Style
// Zero values — use var
var s string
var mu sync.Mutex
var buf bytes.Buffer
// Initialized values — use :=
s := "hello"
n := computeSize()
// Top-level — use var, omit type if obvious
var _defaultPort = 8080
var _errNotFound = errors.New("not found")
// Type differs from expression — specify type
var _e error = myError{}Slices
// Nil slice (preferred for most cases)
var t []string
// Non-nil zero-length (only when JSON encoding matters: nil→null, []string{}→[])
t := []string{}
// Pre-allocate when size known
t := make([]string, 0, len(input))Maps
// Empty map for programmatic population
m := make(map[string]int)
// With capacity hint
m := make(map[string]int, len(input))
// Fixed content — use literal
m := map[string]int{
"a": 1,
"b": 2,
}Structs
// Always use field names
k := User{
FirstName: "John",
LastName: "Doe",
}
// Omit zero-value fields unless they provide context
user := User{
FirstName: "John",
// Admin: false, ← omit, zero value is obvious
}
// Zero-value struct — use var
var user User
// Pointer — use &T{}
sptr := &Config{Name: "prod"}Enums
Start at 1 to distinguish from zero-value (unless zero-value has meaning):
type Status int
const (
StatusActive Status = iota + 1
StatusInactive
StatusSuspended
)Named Result Parameters
Use when they disambiguate or document caller obligations:
// Good — clarifies which *Node is which
func (n *Node) Children() (left, right *Node, err error)
// Good — caller must arrange to call cancel
func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func())Don't use just to enable naked returns, or when the name repeats the type:
// Bad — adds nothing
func (n *Node) Parent() (node *Node, err error)Interfaces
Consumer-Side Design
Interfaces belong where they're used, not where they're implemented.
// GOOD — consumer defines what it needs
package consumer
type UserStore interface {
Get(ctx context.Context, id string) (*User, error)
}
func NewService(store UserStore) *Service { ... }// BAD — producer defines interface, returns it
package producer
type Store interface { Get(ctx context.Context, id string) (*User, error) }
func NewStore() Store { return &store{} }Producers return concrete types. Consumers define the interface they need. Don't define interfaces "for mocking" — design APIs testable via real implementations.
Small Interfaces
Prefer 1-3 methods. io.Reader (1 method) is more powerful than any 10-method interface.
Compile-Time Verification
var _ http.Handler = (*Handler)(nil)
var _ fmt.Stringer = LogOutput(0)Receivers
Pointer vs Value
Use a pointer receiver when:
- Method mutates the receiver
- Receiver contains
sync.Mutexor similar - Receiver is a large struct
- In doubt — default to pointer
Use a value receiver when:
- Receiver is a small, immutable value type (like
time.Time) - Receiver is a map, func, or chan (already reference types)
- All fields are value types with no mutability needs
Never mix receiver types on a single type.
Maps, Funcs, and Channels
Already reference types. Don't use pointers to them:
// Bad
func process(m *map[string]int) { ... }
// Good
func process(m map[string]int) { ... }Context
// First parameter, always
func FetchUser(ctx context.Context, id string) (*User, error) { ... }
// Never store in structs
type Server struct {
// ctx context.Context ← WRONG
db *sql.DB
}
// context.Background() only at the top level
func main() {
ctx := context.Background()
// ...
}Configuration Patterns
For constructors with 3+ optional parameters, choose between option structs and functional options based on usage patterns.
Option Structs
Use when most callers need to specify several options, or options are shared across multiple functions:
type ReplicationOptions struct {
PrimaryRegions []string
ReadonlyRegions []string
OverwritePolicies bool
Interval time.Duration
Workers int
}
func EnableReplication(ctx context.Context, opts ReplicationOptions) { ... }Benefits: self-documenting field names, zero-value omission, easy to share and extend.
Never include `context.Context` in option structs — pass it as a separate parameter.
Functional Options
Use when most callers need zero or few options, there are many options, or options require validation:
type Option interface{ apply(*options) }
type options struct {
timeout time.Duration
logger *slog.Logger
}
type timeoutOption time.Duration
func (t timeoutOption) apply(o *options) { o.timeout = time.Duration(t) }
func WithTimeout(d time.Duration) Option { return timeoutOption(d) }
func New(addr string, opts ...Option) (*Client, error) {
o := options{timeout: 30 * time.Second}
for _, opt := range opts {
opt.apply(&o)
}
// ...
}Use the interface form (not closures) for testability and debuggability.
Options should accept parameters, not use presence as signal:
// Good — composable
rpc.FailFast(true)
// Bad — can't programmatically toggle
rpc.EnableFailFast()Decision Criteria
| Factor | Option Struct | Functional Options |
|---|---|---|
| Most callers need several options | Prefer | Either |
| Most callers need zero options | Either | Prefer |
| Options need validation | Either | Prefer |
| Options shared across functions | Prefer | Either |
| Third-party extensibility needed | Avoid | Prefer |
Doc Comments
Every exported symbol gets a doc comment starting with its name:
// Client manages connections to the message broker.
type Client struct { ... }
// Send publishes a message to the given topic.
// It returns an error if the connection is closed.
func (c *Client) Send(ctx context.Context, topic string, msg []byte) error {- Complete sentences, period-terminated
- Package comment goes in
doc.goor the primary.gofile - Unexported types: comment when behavior is non-obvious, skip when trivial
Embedding
Embedding promotes methods of the inner type to the outer type.
Use embedding when the promoted methods ARE your intended API:
// Good — ReadWriter should have Read and Write methods
type ReadWriter struct {
*Reader
*Writer
}Use named fields when you don't want to expose the inner type's full method set:
// Good — Server uses logger internally but doesn't expose Log(), Printf(), etc.
type Server struct {
logger *slog.Logger
db *sql.DB
}Never embed in public API structs unless the promoted surface is intentional — it commits your API to every exported method of the embedded type, including future additions.
Embedding in internal/ types is lower risk since the API surface is private.
Long-Running Process Naming
Distinguish blocking vs non-blocking lifecycle methods:
- Run — blocks until the process completes. Caller controls the goroutine.
- Start — returns immediately, spawns an internal goroutine. Accept
context.Context as the first parameter for cancellation.
// Run blocks — caller decides concurrency
func (w *Worker) Run(ctx context.Context) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
case job := <-w.jobs:
w.process(job)
}
}
}
// Start returns immediately — manages its own goroutine
func (w *Worker) Start(ctx context.Context) {
go w.Run(ctx)
}Type Preferences
any Over interface{}
Prefer the any alias (Go 1.18+) over interface{}:
// Good
func Process(data any) error { ... }
// Avoid
func Process(data interface{}) error { ... }Only use any when truly accepting any type (marshaling, formatting). If the set of types is known, use generics or concrete types.
Type Aliases for Semantics
Use type aliases when they add type safety or semantic meaning:
// Good — adds semantic clarity
type UserID string
type Timestamp int64
func GetUser(id UserID) (*User, error) { ... }
// Bad — no added meaning
type MyString stringFile Naming
Use kebab-case for Go source files:
user-service.go
http-handler.go
config-parser.goTest files follow the same convention with suffixes:
user-service_test.go # black-box tests (package foo_test)
user-service_internal_test.go # white-box tests (package foo)Avoid
- Built-in name shadowing: don't name variables
error,string,len,copy - Naked returns: only in very short functions. Prefer explicit returns.
- `init()`: avoid unless registering plugins. No I/O, no global state mutation.
- Mutable globals: use dependency injection instead.
- *Passing `string
orio.Reader`*: pass the value directly — it's already small/ref. - Returning typed nil as interface: a
(*T)(nil)assigned to an interface is non-nil.
Return explicit nil when the function returns an interface type.
Go Project Structure
Package design, import conventions, and managing breaking changes.
Key Principles
internal/ for Encapsulation
Code in internal/ cannot be imported by external modules. Use aggressively:
- All server logic belongs in
internal/ - Supporting packages not part of your public API
- Refactor freely without breaking external consumers
cmd/ for Commands
Use cmd/ when a repo has both importable packages and commands:
- Each subdirectory under
cmd/declarespackage main - Install with
go install github.com/user/mod/cmd/tool@latest
Don't Overstructure
Start flat. Add directories only when:
- A package needs internal helpers (
internal/) - Multiple commands exist (
cmd/) - Sub-packages serve distinct, importable purposes
Package Design
Naming
- Lowercase, no underscores:
userstore, notuser_store - Singular:
user, notusers - By purpose:
auth,cache,handler - Never:
util,common,misc,helpers,types,models
Avoid Stuttering
The package name is part of every qualified reference:
// Bad — reads as "http.HTTPClient"
package http
type HTTPClient struct{}
// Good — reads as "http.Client"
package http
type Client struct{}Imports
Group Ordering
Two groups separated by blank line: 1. Standard library 2. Everything else
import (
"context"
"fmt"
"net/http"
"github.com/org/repo/internal/auth"
"go.uber.org/zap"
)Aliasing
Alias only to avoid conflicts. Prefer renaming the more local import:
import (
"runtime/trace"
nettrace "golang.net/x/trace"
)Blank Imports
import _ "pkg" only in main packages or tests:
// main.go — register database driver
import _ "github.com/lib/pq"Dot Imports
Use only in test files to resolve circular dependencies:
package foo_test
import (
"bar/testutil" // imports "foo"
. "foo" // pretend to be in package foo
)Function Organization
Within a file, order by: 1. Types, constants, variables 2. Constructor (New...) 3. Exported methods (grouped by receiver) 4. Unexported methods (grouped by receiver) 5. Utility functions
Order functions by rough call order — readers going top-to-bottom should encounter callers before callees.
File Organization
- One file per major type (for large types)
- Test file adjacent:
foo.go→foo_test.go - Keep related code together — don't scatter a feature across files
doc.gofor package-level documentation if needed
Backward-Incompatible Changes
When breaking backward compatibility, follow a staged workflow:
1. Add new code without touching the old (e.g., new method alongside existing one) 2. Migrate callers from old to new 3. Remove old code when no use cases remain
Each step should be a separate commit. Never combine breaking changes with new functionality — reviewers and git bisect need clean boundaries.
For versioned packages, use directory-based versioning:
lib/auth/v1/ # original version
lib/auth/v2/ # breaking changesBoth versions coexist until all consumers migrate to v2.
Go Testing
Table-driven tests, assertions, test organization, benchmarks, and integration testing strategies.
Table-Driven Tests
The standard pattern for testing multiple inputs with the same logic:
func TestParse(t *testing.T) {
tests := []struct {
name string
give string
want int
wantErr bool
}{
{
name: "valid integer",
give: "42",
want: 42,
},
{
name: "negative",
give: "-7",
want: -7,
},
{
name: "invalid input",
give: "abc",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Parse(tt.give)
if tt.wantErr {
if err == nil {
t.Fatal("expected error, got nil")
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("Parse(%q) = %d, want %d", tt.give, got, tt.want)
}
})
}
}Conventions
- Slice named
tests, each casett - Inputs prefixed
give, outputs prefixedwant - Always use
t.Runwith descriptive names - Use field names in struct literals (except test tables with 3 or fewer fields)
- Omit zero-value fields unless they provide meaningful context
When NOT to Use Table Tests
Split into separate Test... functions when:
- Different cases need different setup or mocking logic
- Conditional assertions (branching) inside the loop
- Complex mock configuration per case (
shouldCallX,setupMocks func()) - Table fields are only used by some cases
Table tests must have uniform logic: every row uses every field.
Subtests
t.Run creates subtests with key advantages:
t.Fatalstops only the current subtest, not the parent- Run individually:
go test -run=TestParse/valid - Shared setup/teardown via parent function
func TestDatabase(t *testing.T) {
db := setupTestDB(t) // shared setup
t.Run("Insert", func(t *testing.T) {
// t.Fatal here won't skip Read test
})
t.Run("Read", func(t *testing.T) {
// ...
})
// teardown runs after all subtests
}Parallel Subtests
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
// test body — tt is safe in Go 1.22+
// for Go < 1.22, shadow: tt := tt
})
}Grouped Parallel With Teardown
Run a group in parallel, then wait before cleanup:
func TestGrouped(t *testing.T) {
// setup
t.Run("group", func(t *testing.T) {
t.Run("A", func(t *testing.T) { t.Parallel(); /* ... */ })
t.Run("B", func(t *testing.T) { t.Parallel(); /* ... */ })
})
// teardown — runs after A and B complete
}Assertions
testify: require vs assert
Use github.com/stretchr/testify for assertions:
- `require` — stops test on failure. Use when subsequent checks depend on this one.
- `assert` — reports failure, continues. Use when all checks should run independently.
func Test_Service(t *testing.T) {
t.Run("dependent checks", func(t *testing.T) {
data, err := LoadData("test.json")
require.NoError(t, err) // stop — can't continue without data
require.NotNil(t, data) // stop — would panic below
assert.Equal(t, "expected", data.Name) // continue — report all failures
assert.Equal(t, 42, data.Count)
})
}Default to require for error checks and nil guards. Use assert for independent value checks within the same subtest.
Struct and Slice Comparison
Use testify for struct and slice comparisons — assert.Equal/require.Equal produce readable diffs on failure:
require.Equal(t, wantUser, gotUser)
assert.ElementsMatch(t, wantItems, gotItems) // order-independentNever use reflect.DeepEqual directly — testify wraps it with better output.
t.Error vs t.Fatal
- `t.Error`: reports failure, test continues. Use for non-blocking checks.
- `t.Fatal`: reports failure, test stops. Use when continuation is meaningless.
In subtests, t.Fatal stops only the current subtest.
Rule: prefer t.Error to report all failures at once. Use t.Fatal only for setup failures or when a check makes subsequent checks impossible.
Test Helpers
Mark with t.Helper() so failures report the caller's line:
func readTestFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return data
}Don't use t.Helper() in assert-like wrappers — it hides the connection between failure and cause.
Test Error Semantics
- Prefer matching on types (
errors.As) or sentinels (errors.Is) - If you don't care about error kind, just check
err != nil - When no sentinel or type exists, use
require.ErrorContainsfor substring matching —
it's more resilient than exact string comparison
// Bad — brittle exact string matching
require.Equal(t, "user not found", err.Error())
// Good — semantic matching when sentinels exist
require.ErrorIs(t, err, ErrNotFound)
// Good — substring when no sentinel available
require.ErrorContains(t, err, "connection refused")t.Fatal and Goroutines
t.Fatal, t.Fatalf, and t.FailNow must only be called from the goroutine running the Test function. Calling them from a spawned goroutine is incorrect and will panic.
// Bad — t.Fatalf from spawned goroutine
go func() {
if err := engine.Vroom(); err != nil {
t.Fatalf("No vroom: %v", err) // WRONG — panics
}
}()
// Good — use t.Errorf + return in goroutines
go func() {
defer wg.Done()
if err := engine.Vroom(); err != nil {
t.Errorf("No vroom: %v", err)
return
}
}()Note: t.Parallel() does NOT create a new goroutine for this purpose — t.Fatal is still safe in parallel subtests.
Test Double Package Naming
Name test helper packages by appending test to the production package name:
package creditcardtest // test doubles for package creditcard
// Stub stubs creditcard.Service with no behavior.
type Stub struct{}
func (Stub) Charge(*creditcard.Card, money.Money) error { return nil }When only one type needs doubling, use simple names (Stub, Fake). When multiple types need doubling, prefix with the type name (StubService, StubStoredValue).
Prefix test double variables to distinguish from production types:
var spyCC creditcardtest.Spy // clear that this is a test doubleScoped Test Setup
Keep setup scoped to tests that need it. Don't use init() or package-level vars for test data — it penalizes tests that don't need the setup:
// Bad — all tests pay the cost, even those that don't need data
var dataset []byte
func init() { dataset = mustLoadDataset() }
// Good — only tests that need it call the helper
func TestParseData(t *testing.T) {
data := mustLoadDataset(t) // scoped to this test
// ...
}For expensive setup shared across multiple tests, use sync.Once:
var datasetOnce struct {
once sync.Once
data []byte
err error
}
func mustLoadDataset(t *testing.T) []byte {
t.Helper()
datasetOnce.once.Do(func() {
datasetOnce.data, datasetOnce.err = os.ReadFile("testdata/dataset")
})
if datasetOnce.err != nil {
t.Fatalf("Could not load dataset: %v", datasetOnce.err)
}
return datasetOnce.data
}Test Cache Safety
Go's test cache uses file mtime and environment values. Writing files in-place or modifying env vars breaks caching and can cause CI failures.
Use scoped helpers:
func TestWriteConfig(t *testing.T) {
// TempDir creates and auto-cleans a temp directory scoped to this test
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
// write to path — won't affect cache or other tests
// Setenv scopes env changes to this test's execution
t.Setenv("APP_ENV", "test")
// restored automatically when test ends
}Never write to the source directory in tests — use t.TempDir() for temp files and t.Setenv() for environment variables.
Prefer Live Services Over Mocks
When testing integrations (databases, caches, message brokers), prefer spinning up real service instances over synthetic mocks — it's more reliable and catches real issues.
func TestRedisCache(t *testing.T) {
// Skip if container runtime not available
if os.Getenv("DOCKERIZED_TESTS") != "true" {
t.Skip("requires docker")
}
// Use real Redis, not a mock
client := redis.NewClient(&redis.Options{Addr: redisAddr})
defer client.Close()
// Test against real behavior
err := client.Set(ctx, "key", "value", 0).Err()
require.NoError(t, err)
}Gate slow or resource-intensive tests behind environment variables and skip when not set. This keeps go test ./... fast while CI runs the full suite.
Runnable Examples
Write func Example... functions for complex APIs. They serve as both documentation and tests — godoc renders them, and go test verifies them:
func ExampleConfig_WriteTo() {
cfg := &Config{Name: "example"}
if err := cfg.WriteTo(os.Stdout); err != nil {
log.Fatal(err)
}
// Output:
// {"name": "example"}
}The // Output: comment makes the example a test — go test fails if output doesn't match.
Race Detection
Always run tests with -race for concurrent code:
go test -race ./...The race detector instruments memory accesses at runtime. It catches data races that may not manifest during normal execution. Enable in CI — the overhead (~2-10x slower) is acceptable for tests.
Use //go:build !race to exclude specific test files from race detection if needed (e.g., performance-sensitive benchmarks).
Avoid Sleeping
time.Sleep in tests creates flaky tests. Use synchronization instead:
// Bad — arbitrary sleep, may be too short or too long
go producer(ch)
time.Sleep(100 * time.Millisecond)
assert(len(results) == 5)
// Good — synchronize on the actual event
go producer(ch)
for i := 0; i < 5; i++ {
<-ch
}
// Good — use channels, WaitGroups, or polling with timeout
select {
case <-done:
// success
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for completion")
}If synchronization is impossible, use a retry/poll loop with a deadline instead of a fixed sleep.
Testing Utilities
httptest
net/http/httptest provides in-process HTTP testing without network I/O:
// Test a handler
func TestHandler(t *testing.T) {
req := httptest.NewRequest("GET", "/users/1", nil)
w := httptest.NewRecorder()
handler(w, req)
resp := w.Result()
if resp.StatusCode != http.StatusOK {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
}
// Test a client against a fake server
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"ok": true}`))
}))
defer srv.Close()
// use srv.URL as base URL for the client under testiotest
testing/iotest provides error-injecting readers for resilience testing:
// Test that your code handles read errors
r := iotest.ErrReader(errors.New("disk failure"))
_, err := ReadAll(r)
if err == nil {
t.Fatal("expected error")
}
// Test with one-byte-at-a-time reader
r = iotest.OneByteReader(strings.NewReader("hello"))Benchmarks
func BenchmarkFoo(b *testing.B) {
for b.Loop() {
foo()
}
}Key rules:
- Use
b.Loop()(Go 1.24+) orfor i := 0; i < b.N; i++for the benchmark loop - Use
b.ResetTimer()after expensive setup - Use
b.ReportAllocs()to track allocations - Ensure the result is used (assign to package-level var) to prevent compiler
optimization from eliminating the call
- Run with
-benchtime=5sor usebenchstatfor stable micro-benchmarks
Test Naming
Function Naming
Use Test_TypeName with underscore separator for type-level tests, and t.Run() for method/scenario subtests:
func Test_Scanner(t *testing.T) {
t.Run("Scan", func(t *testing.T) {
// test Scanner.Scan
})
t.Run("ScanFile", func(t *testing.T) {
// test Scanner.ScanFile
})
}White-Box Testing
Use _internal_test.go suffix for tests that need access to unexported identifiers:
scanner_test.go # Black-box: package scanner_test (preferred)
scanner_internal_test.go # White-box: package scanner
scanner_benchmark_test.go # Black-box benchmarks
scanner_benchmark_internal_test.go # White-box benchmarksPrefer black-box testing — it validates your public API and catches design issues. Use white-box only when testing unexported logic that can't be exercised through the public API.
Block Scoping
Use bare blocks {} for logical grouping when separate test reporting is unnecessary:
func Test_StoreAndRetrieve(t *testing.T) {
store := NewStore()
{ // empty store returns nothing
got, ok := store.Get("key")
assert.False(t, ok)
assert.Empty(t, got)
}
store.Put("key", "value")
{ // after Put, Get returns value
got, ok := store.Get("key")
assert.True(t, ok)
assert.Equal(t, "value", got)
}
}Use t.Run() instead when you need parallel execution, selective running, or per-scenario reporting.
Combining Complementary Operations
Test complementary operations together when it reduces duplication:
// Good — Put and Get are complementary
func Test_CachePutAndGet(t *testing.T) {
cache := NewCache()
cache.Put("key", "value")
got, ok := cache.Get("key")
require.True(t, ok)
require.Equal(t, "value", got)
}Split into separate tests only when operations have independent failure modes or require different setup.
Compare Stable Results
Don't assert on serialization output — it's fragile. Parse and compare semantically:
// Bad — depends on json.Marshal field ordering
require.Equal(t, `{"a":1,"b":2}`, string(got))
// Good — compare parsed data
var result map[string]int
require.NoError(t, json.Unmarshal(got, &result))
assert.Equal(t, want, result)