Files
go-patterns/smells/common-mistakes.md
T

559 lines
20 KiB
Markdown

# Common Go Mistakes and Code Smells
Patterns that Go programmers get wrong, extracted from what the Go stdlib and Kubernetes intentionally **avoid** or handle carefully.
---
## 1. Interface Pollution
### Pattern/Smell Name: Premature/Over-broad Interfaces
**Source citation:** `/tmp/go-src/src/io/io.go` lines 86-307 (22 interfaces, each 1-2 methods), `/tmp/go-src/src/net/http/server.go` lines 89-95 (Handler: 1 method)
**What they do:** Every stdlib interface has 1-2 methods. `io.Reader` has one method. `http.Handler` has one method. Interfaces are defined at the **consumer** site (where they're used), not the producer site (where they're implemented).
**What they avoid:** Large interfaces, interfaces defined by the implementer, interfaces defined "just in case."
**Why:** Small interfaces are easy to implement and compose. Large interfaces force all implementations to satisfy every method — most of which the caller doesn't need. The Go proverb: "The bigger the interface, the weaker the abstraction."
**Anti-pattern (the smell):**
```go
// BAD: "Java-style" interface defined by the implementor
type UserService interface {
GetUser(id string) (*User, error)
CreateUser(u *User) error
UpdateUser(u *User) error
DeleteUser(id string) error
ListUsers(filter Filter) ([]*User, error)
GetUserByEmail(email string) (*User, error)
ValidateUser(u *User) error
// 15 more methods...
}
```
**Correct Go pattern:**
```go
// GOOD: Interface defined at the consumer with only what it needs
type UserGetter interface {
GetUser(id string) (*User, error)
}
// The handler only declares what it actually calls
func NewHandler(users UserGetter) *Handler { ... }
```
**Key rules from stdlib:**
- Accept interfaces, return structs
- Define interfaces where they're *consumed*, not where they're *produced*
- 1-2 methods per interface is ideal; 3 is suspicious; 5+ is almost certainly wrong
- Use `var _ Interface = (*Struct)(nil)` to verify implementations at compile time (server.go line 1802)
---
## 2. Error Swallowing
### Pattern/Smell Name: Ignoring Returned Errors
**Source citation:** `/tmp/go-src/src/net/http/server.go` (18 `if err != nil` blocks, every one handles the error), `/tmp/go-src/src/net/http/transport.go` (25 `if err != nil` blocks, all handled)
**What they do:** Every error returned from a function call is either:
1. Returned to the caller (propagated)
2. Logged and the operation retried/aborted
3. Wrapped with context via `fmt.Errorf("...: %w", err)`
The stdlib **never** does `_ = someFunc()` on functions that return errors (with rare, documented exceptions).
**Why:** Swallowed errors create silent failures. A database write that silently fails. A file close that leaks a descriptor. A network send that drops data. These manifest as data corruption or resource exhaustion hours later.
**Anti-pattern (the smell):**
```go
// BAD: Error thrown away
f.Close() // Close can fail (unflushed writes)
json.Unmarshal(data, &result) // silently leaves result zero-valued
resp, _ := http.Get(url) // panic on nil resp
// BAD: "log and forget" where the caller can't tell it failed
func SaveUser(u *User) {
if err := db.Save(u); err != nil {
log.Printf("failed to save user: %v", err)
// caller thinks it succeeded!
}
}
```
**Correct Go pattern:**
```go
// GOOD: Error propagated with context
func SaveUser(u *User) error {
if err := db.Save(u); err != nil {
return fmt.Errorf("save user %s: %w", u.ID, err)
}
return nil
}
// GOOD: Close errors handled for writes
defer func() {
if cerr := f.Close(); cerr != nil && err == nil {
err = cerr
}
}()
```
---
## 3. Naked Goroutines Without Shutdown
### Pattern/Smell Name: Goroutine Leaks from Unmanaged Spawning
**Source citation:** `/tmp/go-src/src/net/http/transport.go` lines 2111-2112 (readLoop/writeLoop with closech), `/tmp/go-src/src/net/http/server.go` lines 3553 (serve with context cancellation), `/tmp/go-src/src/net/http/main_test.go` (goroutine leak detector)
**What they do:** Every goroutine spawned in the stdlib has a clear shutdown path:
- A channel that signals termination (`closech`, `done`)
- A context that gets cancelled
- A `sync.WaitGroup` that tracks completion
- Tests that verify no goroutines leak
The stdlib HTTP package spawns goroutines (readLoop, writeLoop, serve) but each one:
1. Has a `closech` channel or context for signaling
2. Has a `writeLoopDone` channel to confirm exit
3. Is tracked by `sync.WaitGroup` (in httptest.Server)
4. Is verified by `afterTest` goroutine leak detection
**Why:** A goroutine without a shutdown path runs forever. In a server handling 10K connections, that's 10K leaked goroutines per restart cycle. Go's garbage collector cannot collect goroutines — they must exit.
**Anti-pattern (the smell):**
```go
// BAD: Goroutine with no way to stop it
func StartWorker(ch <-chan Task) {
go func() {
for task := range ch {
process(task)
}
}()
// Who closes ch? What if nobody does? Goroutine leaks forever.
}
// BAD: Fire-and-forget goroutine
func HandleRequest(r *Request) {
go sendAnalytics(r) // What if this blocks? No timeout, no tracking.
}
```
**Correct Go pattern (from stdlib transport.go):**
```go
// GOOD: Goroutine with explicit lifecycle
type persistConn struct {
reqch chan requestAndChan // communication
closech chan struct{} // signal shutdown
writeLoopDone chan struct{} // confirm exit
}
go pconn.readLoop() // reads from closech to know when to stop
go pconn.writeLoop() // closes writeLoopDone on exit
// Shutdown:
func (pc *persistConn) close(err error) {
close(pc.closech) // signal both loops
<-pc.writeLoopDone // wait for confirmation
}
```
---
## 4. sync.Mutex Where atomic Suffices
### Pattern/Smell Name: Over-synchronization with Mutexes
**Source citation:** `/tmp/go-src/src/net/http/server.go` lines 298-300 (atomic for simple state), `/tmp/go-src/src/net/http/transport.go` line 786-787 (comment explaining why NOT atomic)
**What they do:** Use `atomic.Bool`, `atomic.Pointer`, `atomic.Uint64` for simple flags and state that is read/written independently. Reserve `sync.Mutex` for guarding multi-field invariants.
The stdlib explicitly documents the decision: `didRead bool // not atomic.Bool because only one goroutine (the user's) should be accessing` (transport.go line 786).
**Why:** Mutexes serialize all access. For a single boolean flag read by many goroutines (like `inShutdown`), atomic operations are lock-free and orders of magnitude faster under contention.
**Anti-pattern (the smell):**
```go
// BAD: Mutex for a single boolean
type Server struct {
mu sync.Mutex
shutdown bool
}
func (s *Server) IsShutdown() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.shutdown
}
```
**Correct Go pattern (from stdlib):**
```go
// GOOD: Atomic for independent flag (server.go line 3144)
type Server struct {
inShutdown atomic.Bool
}
func (s *Server) shuttingDown() bool {
return s.inShutdown.Load()
}
// GOOD: Packed state in atomic (server.go line 300)
curState atomic.Uint64 // packed (unixtime<<8|uint8(ConnState))
// GOOD: Mutex only when multiple fields must be consistent together
mu sync.Mutex // guards hijackedv
hijackedv bool
```
**Rule of thumb:**
- Single value read/written independently → `atomic`
- Multiple values that must be consistent together → `sync.Mutex`
- Read-heavy, rare writes → `sync.RWMutex`
---
## 5. Channel Misuse
### Pattern/Smell Name: Channels for Simple Synchronization
**Source citation:** `/tmp/go-src/src/net/http/server.go` (uses channels for signaling/coordination, never for simple shared state), `/tmp/go-src/src/net/http/transport.go` lines 2242-2259 (channels with clear ownership documentation)
**What they do:**
- Channels for **signaling** events (closech, done)
- Channels for **passing ownership** of data between goroutines (reqch, writech)
- Comments documenting which goroutine reads/writes each channel
- Buffered channels with explicit reasoning about buffer size
**What they avoid:**
- Channels for sharing mutable state
- Unbuffered channels where buffered would prevent deadlock
- Channels where a mutex would be simpler
**Why:** Rob Pike's "Don't communicate by sharing memory; share memory by communicating" is often misunderstood as "always use channels." The stdlib uses mutexes freely when appropriate. Channels are for goroutine coordination, not data sharing.
**Anti-pattern (the smell):**
```go
// BAD: Channel as a glorified mutex
type Counter struct {
ch chan int
}
func NewCounter() *Counter {
c := &Counter{ch: make(chan int, 1)}
c.ch <- 0
return c
}
func (c *Counter) Increment() {
val := <-c.ch
c.ch <- val + 1
}
// BAD: Unbuffered channel causing goroutine leak
func doWork() <-chan Result {
ch := make(chan Result) // unbuffered!
go func() {
result := expensiveWork()
ch <- result // blocks forever if nobody reads
}()
return ch
}
```
**Correct Go pattern (from stdlib transport.go):**
```go
// GOOD: Channels with documented ownership and correct buffering
type persistConn struct {
reqch chan requestAndChan // written by roundTrip; read by readLoop
writech chan writeRequest // written by roundTrip; read by writeLoop
closech chan struct{} // closed when conn closed
writeErrCh chan error // buffer 1: passes write error from writeLoop to readLoop
}
// GOOD: Error channel buffered to 1 — writer never blocks
writeErrCh: make(chan error, 1)
```
---
## 6. init() Abuse
### Pattern/Smell Name: Overusing init() for Side Effects
**Source citation:** `/tmp/go-src/src/net/http/http2.go` lines 37-47 (one of the few init() uses — for package-level wiring that cannot be done any other way)
**What they do:** The stdlib uses `init()` sparingly and only for:
1. Registering protocol handlers with other packages (http2.go)
2. Setting up test hooks (export_test.go — only compiled during tests)
3. Package-level wiring that has no other option
The entire `net/http` package has only 3 `init()` functions in production code, and each has a comment explaining why it's necessary.
**Why:**
- `init()` runs implicitly — no one calls it, you can't control when, you can't skip it
- Makes testing harder (can't test without side effects)
- Creates import order dependencies
- Makes programs slow to start (all inits run at startup)
- Impossible to pass configuration to
**Anti-pattern (the smell):**
```go
// BAD: Database connection in init
func init() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err) // crashes the program at import time!
}
globalDB = db
}
// BAD: Configuration in init
func init() {
cfg = loadConfig("/etc/myapp/config.yaml") // hard-coded path, untestable
}
// BAD: Registering handlers in init
func init() {
http.HandleFunc("/api/users", handleUsers) // global state mutation
}
```
**Correct Go pattern:**
```go
// GOOD: Explicit initialization with error handling
func NewApp(cfg Config) (*App, error) {
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
return nil, fmt.Errorf("open database: %w", err)
}
return &App{db: db}, nil
}
// GOOD: Only use init() for unavoidable package wiring (like stdlib does)
func init() {
// Must set these at init time because the types are in different packages
// and there's no other way to wire them without import cycles.
http2.NoBody = NoBody
}
```
---
## 7. Premature Concurrency
### Pattern/Smell Name: Goroutines Where Sequential Code Suffices
**Source citation:** `/tmp/go-src/src/encoding/json/` (zero goroutines in production code — purely sequential), `/tmp/go-src/src/net/http/server.go` (goroutines only at the connection accept level, not in handlers)
**What they do:** The stdlib only introduces concurrency where it's structurally necessary (handling multiple connections). JSON encoding, HTTP header parsing, cookie handling — all purely sequential despite being hot paths.
**Why:** Goroutines have overhead (stack allocation, scheduler pressure, synchronization). Sequential code is easier to reason about, debug, and profile. Concurrency should solve a structural problem (waiting for I/O, handling multiple clients) — not speed up CPU-bound work (that's `GOMAXPROCS`'s job).
**Anti-pattern (the smell):**
```go
// BAD: Goroutines for CPU-bound work that shares nothing
func ProcessItems(items []Item) []Result {
results := make([]Result, len(items))
var wg sync.WaitGroup
for i, item := range items {
wg.Add(1)
go func(i int, item Item) {
defer wg.Done()
results[i] = transform(item) // transform is a pure function!
}(i, item)
}
wg.Wait()
return results
}
// This spawns 10000 goroutines for 10000 items. A simple loop is faster.
```
**Correct Go pattern:**
```go
// GOOD: Sequential unless concurrency is structurally needed
func ProcessItems(items []Item) []Result {
results := make([]Result, len(items))
for i, item := range items {
results[i] = transform(item)
}
return results
}
// GOOD: Concurrency only when I/O-bound (as in the HTTP server)
for {
rw, err := l.Accept() // blocks waiting for connection (I/O)
go c.serve(connCtx) // each connection needs its own goroutine because it blocks on I/O
}
```
---
## 8. Interface Satisfaction Checks (What They DO)
### Pattern/Smell Name: Compile-Time Interface Verification
**Source citation:** `/tmp/go-src/src/net/http/server.go` line 1802, `/tmp/go-src/src/net/http/transport.go` line 2141
**What they do:** Use `var _ Interface = (*Struct)(nil)` to verify at compile time that a type implements an interface.
**Why:** Without this, you discover a missing method at runtime (when someone calls the interface method). The blank identifier assignment costs nothing at runtime but catches missing methods at compile time.
**Code example (stdlib):**
```go
// server.go line 1802
var _ closeWriter = (*net.TCPConn)(nil)
// transport.go line 2141
var _ io.ReaderFrom = (*persistConnWriter)(nil)
// server.go line 4071
var _ Pusher = (*timeoutWriter)(nil)
```
**Anti-pattern:** Relying on runtime panics to discover interface mismatches.
---
## 9. Error Wrapping Without Context
### Pattern/Smell Name: Bare Error Propagation
**Source citation:** `/tmp/go-src/src/net/http/transport.go` line 2395, `/tmp/go-src/src/net/http/request.go` line 96
**What they do:** Wrap errors with `fmt.Errorf("context: %w", err)` to add information about what operation failed. Each layer adds its context.
**Why:** A bare `return err` from deep in a call stack gives you "connection refused" with no indication of what was being connected to, for what purpose, with what parameters. Wrapped errors create a trace: `"save user alice: write to database: connection refused"`.
**Anti-pattern (the smell):**
```go
// BAD: Bare propagation
func GetUser(id string) (*User, error) {
data, err := db.Query(...)
if err != nil {
return nil, err // caller sees "connection refused" — useless
}
}
// BAD: Losing the original error
func GetUser(id string) (*User, error) {
data, err := db.Query(...)
if err != nil {
return nil, fmt.Errorf("database error") // original error is gone
}
}
```
**Correct Go pattern (from stdlib):**
```go
// GOOD: Wrapping with %w preserves the chain
// transport.go line 2395
return fmt.Errorf("net/http: HTTP/1.x transport connection broken: %w", err)
// GOOD: Custom error type with full context
// request.go line 96
func badStringError(what, val string) error {
return fmt.Errorf("%s %q", what, val)
}
```
---
## 10. Kubernetes-Scale Anti-Patterns
### Pattern/Smell Name: Patterns That Work at Scale but Smell at Small Scale
**Source citation:** `/tmp/kubernetes-src/pkg/util/iptables/iptables_test.go` (fakeexec), `/tmp/kubernetes-src/staging/src/k8s.io/client-go/` (testify usage)
**What Kubernetes does that you probably shouldn't:**
1. **Assertion libraries (testify)** — Kubernetes uses `assert.Equal`, `require.NoError` extensively. The Go team explicitly avoids these. At Kubernetes scale (4M+ lines), the consistency might help. At normal scale, plain `if/t.Errorf` gives better error messages.
2. **Fake executors** — Kubernetes fakes the entire `exec.Command` interface for testing iptables rules. This is necessary because they can't run iptables in CI. For most codebases, integration tests with real dependencies are more valuable than elaborate fakes.
3. **Generated deepcopy methods** — Kubernetes generates `DeepCopy()` on every API type. At 500+ types, this is necessary. At 10 types, just write the copy manually.
4. **Interface-everything for testing** — Kubernetes wraps system calls, time, filesystem behind interfaces purely for testability. At their scale, you can't spin up real infrastructure. At small scale, use `httptest.Server` and real databases in Docker.
**The lesson:** Patterns born from scale requirements become anti-patterns when applied at the wrong scale. Every abstraction has a cost; only pay it when you have the problem it solves.
---
## 11. Context Misuse
### Pattern/Smell Name: Storing Values in context.Context
**Source citation:** `/tmp/go-src/src/net/http/server.go` lines 1060-1080 (context used for cancellation, NOT for passing data between layers)
**What they do:** Use context for:
- Cancellation signals (`WithCancel`, `WithTimeout`)
- Deadline propagation
- Request-scoped values that cross API boundaries (only via well-typed keys)
**What they avoid:** Using context as a grab-bag for function parameters, replacing explicit arguments with context values.
**Anti-pattern (the smell):**
```go
// BAD: Context as parameter smuggling
ctx = context.WithValue(ctx, "userID", userID)
ctx = context.WithValue(ctx, "db", database)
ctx = context.WithValue(ctx, "logger", logger)
func HandleRequest(ctx context.Context) {
userID := ctx.Value("userID").(string) // type assertion panic risk
db := ctx.Value("db").(*sql.DB) // invisible dependency
}
```
**Correct Go pattern (from stdlib):**
```go
// GOOD: Context for cancellation, explicit params for data
func HandleRequest(ctx context.Context, userID string, db *sql.DB) {
// ...
}
// GOOD: If you must use context values, use typed keys
type contextKey struct{}
var serverContextKey = contextKey{}
// Only for cross-cutting concerns that truly cross API boundaries
// (tracing, auth tokens, request IDs)
```
---
## 12. Using fmt.Sprintf in Hot Paths
### Pattern/Smell Name: Allocation in Performance-Critical Code
**Source citation:** `/tmp/go-src/src/net/http/header.go` (hand-rolled header writing, no fmt), `/tmp/go-src/src/encoding/json/encode.go` (manual byte buffer manipulation)
**What they do:** The stdlib avoids `fmt.Sprintf`, `string concatenation (+)`, and `[]byte(string)` conversions in hot paths. Instead, they write directly to `[]byte` buffers or use `strings.Builder`.
**Why:** Each `fmt.Sprintf` allocates. In a server handling 100K requests/second, that's 100K allocations/second for a single log line. The GC notices.
**Anti-pattern (the smell):**
```go
// BAD: Allocation per request
func (h Header) Get(key string) string {
return fmt.Sprintf("%s", h[key][0]) // unnecessary allocation
}
// BAD: String concatenation in a loop
var result string
for _, item := range items {
result += item.Name + "," // O(n²) allocations
}
```
**Correct Go pattern:**
```go
// GOOD: Direct buffer writes (like stdlib does)
var buf strings.Builder
for _, item := range items {
buf.WriteString(item.Name)
buf.WriteByte(',')
}
return buf.String() // single allocation for final string
```