Ask Claude Code to "add a worker that drains an order queue" in your Go service and the default output looks fine: a goroutine, a for loop, a JSON handler, a _ = json.Unmarshal(...) here and there. None of it crashes. All of it leaks goroutines, swallows errors, and blows up the first time a client disconnects mid-request.
The model didn't get worse — your repo just doesn't tell it the rules. A CLAUDE.md next to go.mod is the cheapest leverage you have.
Get the full CLAUDE.md Rules Pack — oliviacraftlat.gumroad.com/l/skdgt. The 13 rules below are a free preview.
1. Errors are values — wrap with %w, never swallow
The most common AI mistake in Go is _ = doThing() to silence a linter, or return err that loses every layer of context. Wrap with fmt.Errorf("doing X: %w", err) so callers can errors.Is and errors.As. Reserve panic for truly impossible states.
func GetUser(ctx context.Context, id int) (*User, error) {
row, err := db.QueryRowContext(ctx, "SELECT ... WHERE id=$1", id)
if err != nil {
return nil, fmt.Errorf("query user %d: %w", id, err)
}
user, err := scanUser(row)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("user %d: %w", id, ErrUserNotFound)
}
return user, err
}
2. context.Context is the first parameter, always
Any function that does I/O, blocks, or starts a goroutine takes ctx context.Context first. Never store it on a struct. AI defaults to omitting it because half its training data is pre-1.7 Go — and request cancellation silently breaks.
func ProcessOrder(ctx context.Context, id string) error {
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return ctx.Err()
}
return queue.Send(ctx, id)
}
context.Background() is allowed only at entry points (main, top of an HTTP handler, cron tick).
3. Never start a goroutine you can't stop
Fire-and-forget go doWork() is the canonical Go leak. Every spawned goroutine needs a way to exit and a place that waits on it. Use errgroup.Group for lifecycle plus the first non-nil error.
g, gctx := errgroup.WithContext(ctx)
for _, id := range ids {
id := id
g.Go(func() error { return ProcessOrder(gctx, id) })
}
if err := g.Wait(); err != nil {
return fmt.Errorf("batch: %w", err)
}
No go f() in HTTP handlers — orphaned goroutines outlive the request and lose their context.
4. Accept interfaces, return structs — keep interfaces small
AI loves "design pattern" interfaces with eight methods, defined in the package that implements them. Define interfaces at the consumer, keep them 1–3 methods, and return concrete *T from constructors.
// internal/billing/charge.go (consumer)
type userLookup interface {
GetByID(ctx context.Context, id int) (*User, error)
}
func Charge(ctx context.Context, users userLookup, id int, amount Money) error {
u, err := users.GetByID(ctx, id)
// ...
}
No interface "just in case." Add one when there's a second implementation or a real test-double need.
5. defer for every resource — at acquisition
Every Open, Lock, Begin, or NewRequest is followed on the next line by defer Close/Unlock/Rollback. Placed at the bottom of the function, the defer leaks on early returns. Check the close error if it can fail meaningfully (DB tx, file writes).
f, err := os.Open(path)
if err != nil {
return fmt.Errorf("open: %w", err)
}
defer f.Close()
For DB transactions, defer tx.Rollback() right after Begin — it's a no-op once Commit runs.
6. Tests are table-driven, with t.Helper() and t.Cleanup()
The default AI test is one big function with three if got != want checks and a hand-rolled *sql.DB mock that passes for SQL that's broken against real Postgres. Use table-driven tests, t.Run(tc.name, ...), helpers calling t.Helper() on line one, teardown via t.Cleanup.
func TestAdd(t *testing.T) {
cases := []struct{ name string; a, b, want int }{
{"zero", 0, 0, 0},
{"positive", 2, 3, 5},
{"negative", -1, 1, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := Add(tc.a, tc.b); got != tc.want {
t.Fatalf("Add(%d,%d)=%d, want %d", tc.a, tc.b, got, tc.want)
}
})
}
}
Repository tests run against real Postgres via testcontainers-go. CI runs go test -race ./....
7. Avoid named return values (except for defer-modified errors)
Named returns make functions read like Pascal: variables exist before the body, naked return hides what's actually returned, and shadowing bugs disappear into the noise. Use them only when a defer mutates the result — typically wrapping an error on the way out.
// ✅ defer mutates err on the way out
func process(ctx context.Context) (err error) {
defer func() { if err != nil { metrics.IncErr() } }()
return doWork(ctx)
}
// ❌ naked return hides the real values
func parse(s string) (out *Doc, err error) {
out, err = parseInternal(s)
return
}
8. Struct embedding is composition, not inheritance
Embedding promotes methods — and bugs. Embed only when you really want all methods of the inner type to become part of the outer type's public API. Never embed a sync.Mutex: it exposes Lock() to every caller. Same rule for *sql.DB or http.Client — prefer a named field unless you mean "this type is-a DB."
// ✅ field — Lock() is private
type Cache struct {
mu sync.Mutex
data map[string]string
}
// ❌ embedded — every caller can c.Lock()
type Cache struct {
sync.Mutex
data map[string]string
}
9. Custom error types, sentinel Err* for stable conditions
Stable error conditions get a sentinel: var ErrNotFound = errors.New("not found"). Errors that carry data get a struct with Error() string. Don't return inline errors.New("...") strings in hot paths — callers end up branching with strings.Contains, which is the smell of a missing type.
type ValidationError struct{ Field, Reason string }
func (e *ValidationError) Error() string {
return fmt.Sprintf("%s: %s", e.Field, e.Reason)
}
var ErrNotFound = errors.New("not found")
Callers branch via errors.Is(err, ErrNotFound) or errors.As(err, &ve).
10. Logging is log/slog, structured, no fmt.Println
fmt.Printf("user %d failed: %v", id, err) looks fine in dev and is unparseable in prod. Standardize on log/slog with JSON output, attribute-style fields, and a request-scoped logger threaded via context. The standard library shipped a structured logger in 1.21 — it's enough.
slog.InfoContext(ctx, "order processed",
"order_id", id,
"user_id", userID,
"duration_ms", time.Since(start).Milliseconds(),
)
Levels: Debug opt-in via env, Info steady state, Warn needs human follow-up, Error pages someone.
11. Module hygiene: pinned versions, no untagged dependencies
go.mod lists tagged versions only — no replace to a fork without an upstream PR link, no commit-SHA pseudo-versions for production deps. Run go mod tidy before every commit; CI fails if go.sum would change. Run govulncheck ./... in CI. Vulnerabilities in transitive deps aren't "future work."
module github.com/oliviacraft/orders
go 1.22
require (
github.com/jackc/pgx/v5 v5.5.0
github.com/go-chi/chi/v5 v5.0.10
)
12. HTTP handlers: no global state, deps via constructor
The default AI handler reaches for a package-level var db *sql.DB initialized in init(). That makes the handler untestable and shares state across importers. Build a Server struct with explicit dependencies and bind methods to it.
type Server struct {
DB *pgxpool.Pool
Logger *slog.Logger
}
func (s *Server) handleGetUser(w http.ResponseWriter, r *http.Request) {
u, err := s.users().GetByID(r.Context(), chi.URLParam(r, "id"))
if err != nil { s.writeErr(w, r, err); return }
s.writeJSON(w, r, u)
}
main wires dependencies and calls s.Routes(). No init() except for registering drivers. No global mutable state, ever.
13. Zero values must be useful
A User{} should be a valid empty user, not a panic waiting on the first method call. AI-written Go often assumes maps and slices are initialized; they're not, and m["k"] = v on a nil map panics. Use struct types whose zero value works (bytes.Buffer, sync.Mutex, sync.WaitGroup), or initialize in a constructor and document that the zero value is invalid.
type Counter struct {
mu sync.Mutex
n int
}
// no New needed — var c Counter; c.Add(1) just works
type Cache struct{ data map[string]string }
func NewCache() *Cache { return &Cache{data: map[string]string{}} }
// Cache zero value is invalid — only construct via NewCache
If a constructor is required, name it NewX and return *X. Don't ship X{} and leave callers guessing which fields are mandatory.
A starter CLAUDE.md snippet
# CLAUDE.md — Go service
## Stack
- Go 1.22+, Chi, pgx/v5, log/slog, errgroup, testcontainers-go
## Hard rules
- Every error is checked. Wrap with `fmt.Errorf("...: %w", err)`. No `_ = err`.
- `ctx context.Context` is the first parameter for every I/O or goroutine func.
- No `go f()` without an exit path. Use `errgroup.Group` and wait.
- Interfaces at the consumer, 1–3 methods. Constructors return concrete `*T`.
- `defer Close/Unlock/Rollback` on the line after acquisition.
- Tests are table-driven, use `t.Helper`, `t.Cleanup`, real Postgres for repos.
- No named returns except when `defer` mutates the result.
- Embed only when you mean "is-a." Never embed `sync.Mutex`.
- Sentinel `Err*` for stable conditions, struct types for errors with data.
- Logging via `log/slog`, JSON, attribute-style. No `fmt.Println`.
- `go mod tidy` before commit. CI runs `govulncheck` and `go test -race`.
- HTTP handlers are methods on a `Server` struct. No global state, no `init()`.
- Zero values must be usable, or constructors are mandatory and documented.
What Claude gets wrong without these rules
- Spawns goroutines with no exit — process eventually OOMs.
- Drops
context.Contextand breaks request cancellation. - Mocks
*sql.DBwith a homemade struct that passes for broken SQL. - Embeds
sync.Mutexand exposesLock()to every caller. - Initializes globals in
init()— handlers stop being testable.
Drop the 13 rules above into CLAUDE.md and the next AI PR looks like the codebase, not a tutorial. Diff stays small. go test -race stays green.
Want this for 20+ stacks with 200+ rules ready to paste? Grab the CLAUDE.md Rules Pack at oliviacraftlat.gumroad.com/l/skdgt.
— Olivia (@OliviaCraftLat)
Top comments (0)