·3 min read·← All posts
Go Opinion Patterns

The list

In rough order of confidence change:

1. Hand-rolled error types. Defining MyError struct{...} for every error class. Today: use errors.Is/As with sentinel errors plus fmt.Errorf("%w: ...", err). Faster to write; same expressiveness; less boilerplate.

2. Custom logging packages. Every team had one. Today: log/slog covers 95% of use cases. The remaining 5% goes through a thin slog handler. No more logger.Errorw, log.Printf, zap.S() mixed in the same repo.

3. Interfaces defined where types are implemented. “Go interfaces should be defined where they’re consumed, not where they’re implemented.” I taught this for years. It’s still the right default — but for libraries you publish, the consumer doesn’t yet exist, so defining at the implementation side is fine. The rule isn’t absolute.

4. Channel-based work queues. Every job-queue pattern used buffered channels and worker pools. Today: errgroup + a semaphore channel is the same shape with explicit error handling. More readable; same throughput.

5. sync.Pool everywhere. I added pools to every short-lived buffer. Most of them weren’t bottlenecks. Today: profile first; pool the actual hot path. The non-hot ones add complexity without benefit.

6. iota for stringer enums. I shipped many //go:generate stringer -type=Status enums. Today: just type the string type Status string with const (StatusPending Status = "pending"...). JSON marshalling is free; debugging output is readable; no codegen step.

7. init() for setup. Every package had an init() doing setup. Today: explicit New(...) functions called from main. init() ordering across packages is the source of more bugs than it’s worth.

8. Generic-free repository code. “Generics aren’t worth it for repository methods.” Today (Go 1.18+): a generic Repository[T] cuts ~60% of the boilerplate I used to ship. Worth it.

9. Defer for cleanup in long functions. Defer is great. In a 200-line function, three deferred cleanups become hard to track. Today: refactor into smaller functions; each has one defer.

10. panic/recover for control flow. Some teams use it for “this shouldn’t happen but if it does, abort gracefully.” Today: explicit error returns. panic is for genuinely unrecoverable states (corrupted invariants). Recover is for the top-level HTTP handler and nowhere else.

11. Time as int64 Unix timestamps. Easier to serialise; less typing. Today: use time.Time everywhere; serialise via MarshalJSON. The clarity at the type level is worth the marginal byte cost.

12. interface{} for “any value.” Lots of map[string]interface{} for JSON. Today: map[string]any (same thing, better name) — but use struct decoding wherever the shape is known. interface{} for things you know the shape of is laziness.

Why these changed

A few drivers:

The meta-pattern

The idioms I’m most confident about today are the ones that are simplest. Less code; fewer abstractions; closer to the stdlib.

The idioms I was most confident about five years ago were often the most elaborate — patterns that demonstrated cleverness rather than directness.

The cost of cleverness is the team that has to maintain it after you’ve moved on. The benefit of directness is the new engineer who can read the code without needing context. Increasingly, I optimise for the latter.

For Genie’s codebase, the rule I apply on every PR: would a Go engineer who’s never seen this codebase understand this change in 60 seconds? If no, the change needs revision.

The cleverness shows up in the domain logic (the deterministic KYC rules, the NPCI rail chooser); the Go itself should be unremarkable.

What hasn’t changed

A few things I’ve stayed firmly convinced about:

The patterns evolve; the principles hold.

← Back to all posts