DEV Community

Cover image for 🐹 Golang for AI Developers πŸ€– β€” From 0 to Pro ⚑
Truong Phung (Ethan)
Truong Phung (Ethan)

Posted on

🐹 Golang for AI Developers πŸ€– β€” From 0 to Pro ⚑

One file, one path: from package main to shipping a concurrent, observable Go service that fronts your models and never falls over.

Every example is drawn from what AI engineers actually build in Go β€” streaming proxies, tool dispatchers, rate limiters, worker pools, context-cancelled model calls. No foo/bar filler.

Companion reads: 🐍 Python for AI Developers (the sibling to this guide), πŸ“˜ The Complete Guide to LLMs and AI Agents πŸ€–
to understand modern AI deeply, ⚠️ Common Issues πŸͺ² with LLMs & AI Agents β€” and How to Fix Them πŸ› οΈ, πŸ—οΈ Building High-Quality AI Agents πŸ€–
for the agent architecture on top of this foundation, πŸ”„ The Agentic Loop Guide for the control loop itself, 🏒 Enterprise-Ready AI Agents, and πŸ› οΈ The Senior Software Engineer Playbook πŸ“–.


πŸ“– How to read this guide

You are… Start at Skip
New to Go Part 1 β†’ read straight through Parts 12–13 on first pass
Coming from Python Part 1 (the phrasebook), then Part 5 and Part 6 β€”
Coming from Java/C# Part 4, Part 5 β€” inheritance and exceptions are gone Part 2 (skim)
Building AI services Part 6, Part 7, Part 9 β€”
Reviewing code Part 14, Part 15 everything else

Convention: // βœ… = do this, // ❌ = don't. Snippets target Go 1.22+, with newer-version wins called out inline.


πŸ“‹ Table of Contents


1. 🧠 The Go Mental Model

1.1 What Go optimizes for

Go was designed for large teams maintaining network services over years. Every trade-off follows from that:

Go chose Instead of Consequence for you
A tiny spec (25 keywords) Rich features You can read any Go file after a week
Compile to one static binary Runtime + deps FROM scratch images, 10 ms cold start
Explicit errors as values Exceptions Failure paths are visible in the code
Composition + interfaces Inheritance No class hierarchies to reverse-engineer
Goroutines + channels Callbacks / async colouring Blocking code that scales to 100k connections
One formatter, one toolchain Ecosystem choice Zero config debates; go test, go fmt, pprof are built in

Go is boring on purpose. The payoff is that a service written by someone who left two years ago still compiles, still reads clearly, and still runs.

1.2 Compiled and statically typed β€” what that buys you

[your .go files] β†’ [compiler: types, escape analysis, inlining] β†’ [one native binary]
                                                                   ↑ includes the runtime
                                                                     (scheduler + GC)
Enter fullscreen mode Exit fullscreen mode
  • Errors caught at compile time: type mismatches, unused variables, unused imports, missing returns. A whole class of Python 3 a.m. incidents simply cannot happen.
  • No interpreter, no venv, no site-packages at runtime. Deploy is COPY binary /.
  • Predictable performance: no JIT warmup, no GIL, real parallelism across cores.

The cost: more ceremony up front, no REPL, and a smaller ML ecosystem.

1.3 Go vs Python β€” pick per service, not per company

Dimension Go Python
Execution Native binary + embedded runtime Bytecode on the CPython VM
Typing Static, enforced by the compiler Dynamic; static only via mypy in CI
Parallelism Real: goroutines across all cores GIL-limited; processes or C extensions
Concurrency cost ~2 KB per goroutine ~KB per coroutine, ~MB per thread
p99 latency Stable (GC pauses < 1 ms) Noisier
Deploy artifact 15–40 MB static binary Interpreter + wheels + lockfile
Startup ~5 ms 100–500 ms (imports)
ML/AI libraries Thin (inference clients, ONNX, tokenizers) Everything
Best at API gateways, streaming proxies, orchestrators, high-fan-out workers Model training, data science, ML inference glue

The production shape that wins β€” and the one in this repo's CLAUDE.md β€” is both: Go as the BFF that owns HTTP, auth, tenancy, streaming and fan-out; Python as the ML service it calls for heavy computation. Use Go where request volume and connection count live; use Python where the models live.

1.4 A Python β†’ Go phrasebook

Python Go Note
x = 5 x := 5 := declares + infers, inside functions only
list[int] []int Slice β€” dynamic array
dict[str, int] map[string]int Iteration order is randomized
tuple struct, or multiple return values No tuple type
None nil (pointers, slices, maps, interfaces, funcs, chans) Value types have zero values instead
Optional[T] *T, or (T, bool), or (T, error) Pointers are the "maybe" of Go
raise ValueError(...) return fmt.Errorf("...: %w", err) Errors are returned, not thrown
try/except if err != nil { … } Explicit at every call
with open(...) as f: f, err := os.Open(...); defer f.Close() defer is the context manager
@decorator Higher-order function / middleware Wrap the function or the handler
class A: def m(self) type A struct{} + func (a A) M() Methods live outside the type
Protocol (structural) interface Go interfaces are structural too β€” no implements
async def / await just call it, in a go routine No function colouring
asyncio.gather errgroup.Group Bounded with SetLimit
asyncio.Semaphore(8) buffered channel or SetLimit(8)
f"{x:.2f}" fmt.Sprintf("%.2f", x)
pytest go test ./... Testing is in the stdlib
venv + pyproject.toml go.mod Modules, no activation

1.5 Hello, service

package main

import (
    "fmt"
    "log/slog"
    "net/http"
    "os"
)

func main() {
    logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
    mux := http.NewServeMux()
    mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "ok")
    })
    logger.Info("listening", "addr", ":8080")
    if err := http.ListenAndServe(":8080", mux); err != nil {
        logger.Error("server failed", "err", err)
        os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Three things a Python developer should notice: no framework, no decorators, and errors returned rather than raised. ("GET /healthz" method-and-pattern routing is Go 1.22+.)

🎯 Actionable rules

  1. Choose Go for the request path and the fan-out; keep Python where the models are.
  2. Let the compiler carry the weight you spend mypy effort on in Python.
  3. Learn error, interface, defer, and context β€” everything else is syntax.

2. 🧱 Core Types & Syntax

2.1 Declarations and zero values

var name string          // "" β€” declared variables are ALWAYS initialized
var count int            // 0
var ratio float64        // 0
var ok bool              // false
var tools []string       // nil (usable: len 0, append works)
var index map[string]int // nil (readable, but WRITING panics)
var client *http.Client  // nil

model := "claude-opus-5"           // := infers the type; functions only
timeout, retries := 30, 3          // multiple assignment
_, err := doThing()                // _ discards a value you must accept
Enter fullscreen mode Exit fullscreen mode

Zero values are Go's answer to None. There is no uninitialized memory, so a struct is useful the moment it exists. Design your types so the zero value works (sync.Mutex, bytes.Buffer, and http.Client all do).

⚠️ var m map[string]int is nil: reads return the zero value, writes panic. Always m := make(map[string]int) or m := map[string]int{}.

2.2 The type set

int, int8/16/32/64, uint…    // int is 64-bit on modern platforms; use it by default
float32, float64             // float64 unless you're storing millions of embeddings
string                       // immutable, UTF-8 bytes
byte  = uint8                // a raw byte
rune  = int32                // one Unicode code point
bool
[]T, map[K]V, chan T, *T, func(...) ..., interface{ … }, struct{ … }
Enter fullscreen mode Exit fullscreen mode

Go has no implicit conversion, not even int β†’ int64:

var i int = 42
var f float64 = float64(i)          // explicit, always
var u uint8 = uint8(300)            // ⚠️ silently wraps to 44 β€” check ranges yourself
n, err := strconv.Atoi("42")        // string β†’ int (returns an error!)
s := strconv.Itoa(42)               // int β†’ string
f, err := strconv.ParseFloat("0.7", 64)
b, err := strconv.ParseBool("true")
Enter fullscreen mode Exit fullscreen mode

⚠️ string(65) gives "A", not "65" β€” it converts a code point. Use strconv. (go vet flags this.)

2.3 Constants and iota

const MaxHistoryTurns = 20                    // untyped: adapts to context
const ToolTimeout = 30 * time.Second          // typed by inference

type Role string
const (
    RoleUser      Role = "user"
    RoleAssistant Role = "assistant"
    RoleSystem    Role = "system"
)

type Status int
const (
    StatusOK Status = iota   // 0 β€” iota counts from 0 within a const block
    StatusRetry              // 1
    StatusFailed             // 2
)

func (s Status) String() string {              // makes it print nicely everywhere
    switch s {
    case StatusOK:     return "ok"
    case StatusRetry:  return "retry"
    case StatusFailed: return "failed"
    default:           return fmt.Sprintf("Status(%d)", int(s))
    }
}
Enter fullscreen mode Exit fullscreen mode

A named string type (type Role string) is Go's enum: the compiler rejects a raw "usr" typo where a Role is expected, while JSON marshalling still just works.

2.4 Strings, bytes, runes

Strings are immutable byte slices holding UTF-8. Indexing gives bytes; ranging gives runes.

s := "cafΓ©"
len(s)                       // 5 β€” BYTES, not characters
s[0]                         // 99 (byte 'c')
for i, r := range s {        // i = byte offset, r = rune
    fmt.Printf("%d:%c ", i, r)   // 0:c 1:a 2:f 3:Γ©
}
utf8.RuneCountInString(s)    // 4 β€” actual character count
[]rune(s)[3]                 // 'Γ©' β€” index by character (allocates)
[]byte(s)                    // copy to a mutable byte slice
Enter fullscreen mode Exit fullscreen mode

The strings package covers what Python puts on str:

strings.TrimSpace("  hi \n")            // "hi"
strings.ToLower("Calculate 2+2")
strings.Split("a,b,c", ",")             // []string{"a","b","c"}
strings.SplitN("calculate 10*5", "calculate", 2)[1]   // " 10*5"  (maxsplit)
strings.Join([]string{"a", "b"}, ", ")  // "a, b"
strings.HasPrefix(name, "tool:")        // also HasSuffix, Contains, EqualFold
strings.ReplaceAll(s, "ok", "done")
strings.Fields("  a  b ")               // ["a","b"] β€” split on any whitespace
strings.TrimPrefix(path, "docs/")       // prefix-safe (not Trim, which is a char set)
strings.Cut("key=value", "=")           // "key", "value", true β€” the modern splitter
Enter fullscreen mode Exit fullscreen mode

Building strings: += in a loop is O(nΒ²) and allocates every time. Use a builder:

var b strings.Builder
b.Grow(len(history) * 64)                  // one allocation if you can estimate
for _, m := range history {
    fmt.Fprintf(&b, "%s: %s\n", m.Role, m.Content)
}
prompt := b.String()
Enter fullscreen mode Exit fullscreen mode

2.5 fmt verbs you'll actually use

fmt.Sprintf("%s scored %.2f", name, score)   // string, 2-decimal float
fmt.Sprintf("%d/%d tokens", used, limit)     // int
fmt.Sprintf("%q", name)                      // "calculator" β€” quoted, like Python's !r
fmt.Sprintf("%v", cfg)                       // default format
fmt.Sprintf("%+v", cfg)                      // {Name:agent Model:claude-opus-5} ← field names
fmt.Sprintf("%#v", cfg)                      // Go syntax β€” best for debugging
fmt.Sprintf("%T", v)                         // the dynamic type: *main.Agent
fmt.Errorf("run tool %q: %w", name, err)     // %w WRAPS an error (see Β§5)
Enter fullscreen mode Exit fullscreen mode

%q is your !r: it makes "" and " " visible in logs. %+v on a struct is the fastest debugging tool in the language.

2.6 Slices β€” the type you must actually understand

A slice is a 3-word header: pointer to a backing array, length, capacity. That header is copied on assignment; the array is not.

xs := []string{"a", "b"}          // literal
ys := make([]string, 0, 100)      // len 0, cap 100 β€” preallocate when you know the size
ys = append(ys, "x")              // append RETURNS a new header; always reassign
len(xs); cap(xs)
xs = append(xs, ys...)            // ... spreads a slice (like Python's *)
copy(dst, src)                    // copies min(len(dst), len(src))
last10 := history[max(0, len(history)-10):]   // sliding window (min/max builtins: Go 1.21+)
Enter fullscreen mode Exit fullscreen mode

⚠️ The aliasing trap β€” slicing shares the backing array:

all := []int{1, 2, 3, 4, 5}
head := all[:3]
head = append(head, 99)      // cap allows it β†’ OVERWRITES all[3]
fmt.Println(all)             // [1 2 3 99 5]
Enter fullscreen mode Exit fullscreen mode

Fixes: three-index slicing to cap it (all[:3:3] forces append to copy), or slices.Clone(head).

⚠️ Never keep a small slice of a huge one β€” the whole backing array stays alive:

snippet := slices.Clone(bigDoc[:100])   // βœ… 100 bytes retained, not 50 MB
Enter fullscreen mode Exit fullscreen mode

The slices package (Go 1.21+) replaces most hand-written loops:

slices.Contains(tools, "bash")
slices.Sort(scores)
slices.SortFunc(docs, func(a, b Doc) int { return cmp.Compare(b.Score, a.Score) })  // desc
slices.Index(names, "calculator")
slices.Clone(xs); slices.Reverse(xs); slices.Max(scores)
Enter fullscreen mode Exit fullscreen mode

2.7 Maps

scores := map[string]float64{"calculator": 0.94}
v := scores["missing"]                 // 0 β€” no error, zero value
v, ok := scores["missing"]             // βœ… the comma-ok idiom: v=0, ok=false
delete(scores, "calculator")
len(scores)
clear(scores)                          // Go 1.21+

for k, v := range scores { … }         // ⚠️ ORDER IS RANDOMIZED, deliberately
keys := slices.Sorted(maps.Keys(scores))   // Go 1.23+ β€” deterministic iteration
Enter fullscreen mode Exit fullscreen mode
  • The comma-ok form is how you distinguish "absent" from "present and zero" β€” Go's answer to dict.get vs [].
  • Maps are not safe for concurrent use. Concurrent read+write panics with a fatal error the race detector can't recover from. Guard with sync.RWMutex or use sync.Map (only for its two specific patterns β€” see Β§6.6).
  • Preallocate when you know the size: make(map[string]int, 1000).

2.8 Structs and pointers

type AgentConfig struct {
    Name        string   `json:"name"`
    Model       string   `json:"model"`
    Temperature float64  `json:"temperature,omitempty"`
    Tools       []string `json:"tools,omitempty"`
    apiKey      string   `json:"-"`     // lowercase = unexported; "-" = never marshalled
}

cfg := AgentConfig{Name: "researcher", Model: "claude-opus-5"}   // βœ… field names, always
p := &cfg                       // pointer
p.Temperature = 0.2             // auto-dereference β€” no -> in Go
fmt.Printf("%+v\n", cfg)
Enter fullscreen mode Exit fullscreen mode

Exported = capitalized. Name is visible outside the package; apiKey is not. That single rule replaces public/private.

Struct tags are metadata read by reflection β€” the JSON, DB, and validation layers all use them.

Value or pointer?

Use a value Use a pointer
Small, immutable-ish (time.Time, Point) The method mutates the receiver
You want a copy (concurrency safety) The struct is large (copying costs)
Zero value is meaningful Nil must be distinguishable from empty

Go is always pass-by-value β€” passing a struct copies it; passing a pointer copies the pointer. Slices, maps, and channels contain internal pointers, so copying the header still shares the data.

2.9 Control flow

if err := run(ctx); err != nil {          // βœ… init statement scopes err to the if
    return fmt.Errorf("run: %w", err)
}

switch {                                   // no condition = cleaner if/else-if chain
case score > 0.9:  label = "high"
case score > 0.5:  label = "medium"
default:           label = "low"
}

switch status {                            // no fallthrough by default (unlike C)
case StatusOK, StatusRetry:                // multiple values per case
    continue
}

for i := 0; i < n; i++ { }                 // classic
for i, msg := range history { }            // range: index+value
for _, msg := range history { }            // value only
for k := range scores { }                  // map: keys only
for range 5 { }                            // Go 1.22+: repeat N times
for { break }                              // infinite loop β€” the only `while`

for msg := range ch { }                    // range over a channel until it's closed
for tok := range stream.Tokens() { }       // Go 1.23+: range over an iterator function
Enter fullscreen mode Exit fullscreen mode

There is no while, no ternary, and no do/while. That's not an oversight β€” it's the "one obvious way" principle.

⚠️ range copies each element: for _, d := range docs { d.Score = 0 } mutates a copy. Use for i := range docs { docs[i].Score = 0 }.

βœ… Since Go 1.22, loop variables are per-iteration, so the classic "all goroutines see the last value" bug is gone. On older versions you needed i := i inside the loop.

2.10 Labels, goto, and other things you won't need

goto exists; you will not use it. Labeled break/continue are occasionally right for breaking out of nested loops:

outer:
for _, doc := range docs {
    for _, chunk := range doc.Chunks {
        if chunk.Match(q) { break outer }
    }
}
Enter fullscreen mode Exit fullscreen mode

🎯 Actionable rules

  1. Design types so the zero value is useful; never return a nil map you expect callers to write to.
  2. Always reassign the result of append, and slices.Clone anything you retain from a big slice.
  3. Use comma-ok on map reads whenever "absent" and "zero" differ.
  4. %+v and %q in every debug print; %w in every wrapped error.

3. πŸ”§ Functions, Closures, defer

3.1 Signatures and multiple returns

// Summarize returns a summary of text capped at maxWords words.
//
// It collapses whitespace and never splits a word. maxWords must be > 0.
func Summarize(text string, maxWords int) (string, error) {
    if maxWords <= 0 {
        return "", fmt.Errorf("maxWords must be positive, got %d", maxWords)
    }
    words := strings.Fields(text)
    if len(words) > maxWords {
        words = words[:maxWords]
    }
    return strings.Join(words, " "), nil
}

summary, err := Summarize(doc, 50)
if err != nil { … }
Enter fullscreen mode Exit fullscreen mode

(T, error) is the signature of Go. The error is the last return value, always. There is no Optional, no exception, no hidden control flow.

Doc comments start with the identifier's name and are the package's documentation (go doc, pkg.go.dev). Exported identifiers without a comment are flagged by linters β€” and the comment is what an LLM reads when your function becomes a tool.

func splitHostPort(s string) (host string, port int, err error) {   // named returns
    // … named results are pre-declared and zero-valued; a bare `return` returns them
    return host, port, nil                     // βœ… still return explicitly for clarity
}
Enter fullscreen mode Exit fullscreen mode

Use named returns for documentation and for defer-based error wrapping (Β§3.4) β€” not as an excuse for naked returns in long functions.

3.2 Variadic functions and function values

func RunTool(name string, args ...any) (string, error) { … }
RunTool("calculator", "2+2")
RunTool("search", queryArgs...)                 // spread a slice

type ToolFunc func(ctx context.Context, args json.RawMessage) (string, error)

var registry = map[string]ToolFunc{}            // string β†’ behaviour, the Go way

func Register(name string, fn ToolFunc) { registry[name] = fn }
Enter fullscreen mode Exit fullscreen mode

Functions are values: assign them, store them in maps, pass them, return them. That covers most of what Python decorators do.

3.3 Closures

func makeRetrier(attempts int, base time.Duration) func(context.Context, func() error) error {
    return func(ctx context.Context, op func() error) error {
        var err error
        for i := range attempts {
            if err = op(); err == nil {
                return nil
            }
            select {
            case <-time.After(base << i):          // exponential backoff
            case <-ctx.Done():
                return ctx.Err()
            }
        }
        return fmt.Errorf("after %d attempts: %w", attempts, err)
    }
}

retry := makeRetrier(3, 100*time.Millisecond)
Enter fullscreen mode Exit fullscreen mode

Closures capture variables by reference, so a closure can outlive the function that made it β€” the compiler moves those variables to the heap (see escape analysis, Β§7.4).

3.4 defer in practice

defer schedules a call to run when the surrounding function returns β€” on any path, including panic. It is Go's with/finally.

func fetchDoc(ctx context.Context, url string) ([]byte, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, fmt.Errorf("fetchDoc: build request: %w", err)
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("fetchDoc: %w", err)
    }
    defer resp.Body.Close()          // βœ… immediately after the error check, every time
    …
}
Enter fullscreen mode Exit fullscreen mode

Four rules that cover every defer bug:

  1. LIFO order. Multiple defers run in reverse.
  2. Arguments are evaluated at defer time, the call happens later:
   start := time.Now()
   defer log.Printf("took %s", time.Since(start))   // ❌ Since() runs NOW β†’ always ~0
   defer func() { log.Printf("took %s", time.Since(start)) }()   // βœ… closure defers the read
Enter fullscreen mode Exit fullscreen mode
  1. It's function-scoped, not block-scoped. Deferring inside a loop accumulates until the function ends:
   for _, p := range paths {
       f, _ := os.Open(p)
       defer f.Close()        // ❌ 10 000 open files, all closed at the very end
   }
   for _, p := range paths {  // βœ… give each iteration its own function
       func() {
           f, _ := os.Open(p); defer f.Close(); process(f)
       }()
   }
Enter fullscreen mode Exit fullscreen mode
  1. A deferred closure can modify named return values β€” the idiomatic way to wrap every error exit at once:
   func (s *Store) Save(ctx context.Context, d Doc) (err error) {
       tx, err := s.db.BeginTx(ctx, nil)
       if err != nil { return err }
       defer func() {
           if err != nil { _ = tx.Rollback(); return }
           err = tx.Commit()
       }()
       …
   }
Enter fullscreen mode Exit fullscreen mode

⚠️ Deferred Close() on a writer can silently drop errors. For files you write, close explicitly and check, or capture it: defer func() { err = errors.Join(err, f.Close()) }().

3.5 init() and package-level state

func init() { … }        // runs once, after package vars, before main
Enter fullscreen mode Exit fullscreen mode

Use it almost never: it hides work, runs on import, and makes tests order-dependent. Prefer an explicit constructor called from main. The one defensible use is registering a driver or a codec.

🎯 Actionable rules

  1. Return (T, error); handle or wrap the error at the very next line.
  2. defer the cleanup on the line after the error check that acquired the resource.
  3. No defer inside loops β€” wrap the body in a function.
  4. Doc-comment every exported identifier, starting with its name.

4. 🧬 Structs, Methods, Interfaces, Generics

4.1 Methods and receivers

type Agent struct {
    cfg      AgentConfig
    llm      LLMClient
    history  []Message
    mu       sync.Mutex
}

// NewAgent constructs an Agent. Constructor functions are Go's __init__.
func NewAgent(cfg AgentConfig, llm LLMClient) (*Agent, error) {
    if cfg.Name == "" {
        return nil, errors.New("agent: name is required")
    }
    return &Agent{cfg: cfg, llm: llm}, nil
}

func (a *Agent) AddMessage(role Role, content string) {   // pointer receiver: mutates
    a.mu.Lock()
    defer a.mu.Unlock()
    a.history = append(a.history, Message{Role: role, Content: content})
}

func (a *Agent) Len() int { return len(a.history) }        // pointer for consistency

func (c AgentConfig) Describe() string {                   // value receiver: read-only, small
    return fmt.Sprintf("%s/%s@%.1f", c.Name, c.Model, c.Temperature)
}
Enter fullscreen mode Exit fullscreen mode

Receiver rules:

  • Use a pointer receiver if the method mutates, if the struct is large, or if it contains a sync.Mutex (copying a mutex is a bug go vet catches).
  • Be consistent: if any method needs a pointer receiver, give them all pointer receivers.
  • Only *T satisfies an interface when methods have pointer receivers β€” a plain T value won't compile. This is the #1 "why doesn't my type implement this interface" error.

4.2 Embedding β€” composition instead of inheritance

type BaseTool struct {
    Name        string
    Description string
}

func (b BaseTool) Schema() string { … }

type CalculatorTool struct {
    BaseTool           // embedded: no field name
    Precision int
}

calc := CalculatorTool{BaseTool: BaseTool{Name: "calculator"}, Precision: 4}
calc.Name          // promoted field
calc.Schema()      // promoted method
Enter fullscreen mode Exit fullscreen mode

Embedding promotes fields and methods β€” it looks like inheritance but it's delegation: there is no virtual dispatch and no super. Embedding an interface is the standard way to build decorators and partial fakes:

type loggingStore struct {
    Store                     // embedded interface: unimplemented methods pass through
    log *slog.Logger
}
func (s loggingStore) Get(ctx context.Context, id string) (Doc, error) {
    s.log.Info("get", "id", id)
    return s.Store.Get(ctx, id)
}
Enter fullscreen mode Exit fullscreen mode

4.3 Interfaces β€” small, implicit, defined by the consumer

There is no implements keyword. If the method set matches, the type satisfies the interface.

// Defined in the package that USES it, not the one that implements it.
type LLMClient interface {
    Complete(ctx context.Context, prompt string) (string, error)
}

type AnthropicClient struct{ … }
func (c *AnthropicClient) Complete(ctx context.Context, p string) (string, error) { … }
// *AnthropicClient now satisfies LLMClient. No import of your package required.

agent, _ := NewAgent(cfg, &AnthropicClient{})     // prod
agent, _ := NewAgent(cfg, &fakeLLM{reply: "42"})  // test β€” no mocking library needed
Enter fullscreen mode Exit fullscreen mode

The three rules that make Go interfaces work:

  1. "Accept interfaces, return structs." Take the narrowest interface you need as a parameter; return concrete types so callers keep every method.
  2. Define the interface where it's consumed. This inverts the dependency without a DI framework.
  3. Keep them tiny. io.Reader has one method. A 12-method interface is a class in disguise; nobody can fake it in a test.
var _ LLMClient = (*AnthropicClient)(nil)    // compile-time assertion that it satisfies
Enter fullscreen mode Exit fullscreen mode

4.4 any, type assertions, and type switches

var v any = payload                    // any == interface{} (Go 1.18+ alias)

s, ok := v.(string)                    // βœ… comma-ok: never panics
s := v.(string)                        // ❌ panics if v isn't a string

switch x := v.(type) {                 // type switch
case string:
    return x
case map[string]any:
    return fmt.Sprintf("%d keys", len(x))
case nil:
    return "null"
default:
    return fmt.Sprintf("unsupported %T", x)
}
Enter fullscreen mode Exit fullscreen mode

any throws away the compiler's help β€” use it only at the JSON/reflection boundary and convert into a real type immediately (the same discipline as Python's Any).

⚠️ The typed-nil trap β€” an interface holding a nil pointer is not nil:

func newClient() *AnthropicClient { return nil }
var c LLMClient = newClient()
c == nil        // false! the interface has a type (*AnthropicClient) and a nil value
Enter fullscreen mode Exit fullscreen mode

Fix: return the interface type as a literal nil, never a typed nil pointer. Most commonly this bites with error β€” never declare var err *MyError and return it as error.

4.5 Generics

Type parameters (Go 1.18+) exist to remove copy-paste, not to build hierarchies.

func Map[T, U any](xs []T, f func(T) U) []U {
    out := make([]U, 0, len(xs))
    for _, x := range xs {
        out = append(out, f(x))
    }
    return out
}
names := Map(tools, func(t Tool) string { return t.Name() })

func Keys[K comparable, V any](m map[K]V) []K { … }   // comparable = usable as a map key

type Number interface{ ~int | ~int64 | ~float64 }      // ~ = "any type whose underlying type is"
func Sum[T Number](xs []T) T { var s T; for _, x := range xs { s += x }; return s }

// A generic, type-safe cache β€” the common real-world use.
type Cache[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}
func NewCache[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{m: make(map[K]V)}
}
func (c *Cache[K, V]) Get(k K) (V, bool) {
    c.mu.RLock(); defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}
Enter fullscreen mode Exit fullscreen mode

When not to use generics: if an interface expresses it, use the interface. Generics can't have methods with their own type parameters, they inflate compile times, and Map/Filter chains read worse in Go than a plain for loop. The slices, maps, and cmp packages already cover 90% of what you'd write.

4.6 Interfaces worth knowing by heart

Interface Method Why it matters
error Error() string Every failure (Β§5)
fmt.Stringer String() string Custom formatting in every %v
io.Reader / io.Writer Read/Write Files, sockets, buffers, HTTP bodies β€” all compose
io.Closer Close() error Pairs with defer
json.Marshaler / Unmarshaler Custom JSON Enums, time formats, LLM payload quirks
context.Context Done, Err, Value, Deadline Cancellation everywhere (Β§6.5)
http.Handler ServeHTTP Every middleware in Go
sort.Interface Len/Less/Swap Mostly superseded by slices.SortFunc

io.Reader/io.Writer are the reason Go plumbing composes so well: an HTTP body, a gzip stream, a file, and a bytes.Buffer are interchangeable.

🎯 Actionable rules

  1. Constructors return (*T, error); validate there, so an existing value is always valid.
  2. Define small interfaces in the consuming package; accept interfaces, return structs.
  3. var _ Iface = (*T)(nil) to assert satisfaction at compile time.
  4. Reach for generics only after you've written the same function twice.

5. πŸ’₯ Errors Are Values

5.1 The whole mechanism

type error interface {
    Error() string
}
Enter fullscreen mode Exit fullscreen mode

That's it. An error is any value with an Error() string method. There is no stack unwinding, no exception hierarchy, no invisible control flow β€” which is why Go code has if err != nil everywhere and why you can always see the failure path.

errors.New("agent: name is required")                       // static message
fmt.Errorf("embed batch %d: %w", i, err)                    // wrap with context
fmt.Errorf("parse config: %v", err)                         // %v = context WITHOUT wrapping
errors.Join(err1, err2)                                     // multiple failures (Go 1.20+)
Enter fullscreen mode Exit fullscreen mode

%w vs %v: %w keeps the original error reachable by errors.Is/errors.As; %v flattens it to text. Wrap by default; use %v deliberately when you don't want callers coupling to an internal error type.

5.2 The wrapping convention

Follow one convention across the codebase β€” this repo's (CLAUDE.md) is fmt.Errorf("packagename.FuncName: %w", err):

func (r *Repo) GetDoc(ctx context.Context, id string) (Doc, error) {
    var d Doc
    if err := r.db.GetContext(ctx, &d, qGetDoc, id); err != nil {
        return Doc{}, fmt.Errorf("repo.GetDoc: %w", err)
    }
    return d, nil
}
Enter fullscreen mode Exit fullscreen mode

Read top-to-bottom, the final message becomes a trace:
handler.Query: service.Answer: repo.GetDoc: sql: no rows in result set

Rules: add context, not restatement (never "error: %w"); don't capitalize or end with punctuation; never log and return the same error β€” pick one, and log at the boundary that handles it.

5.3 Sentinels, custom types, Is, As

// Sentinel: a comparable, exported value callers can test for.
var (
    ErrNotFound   = errors.New("not found")
    ErrRateLimit  = errors.New("rate limited")
)

// Custom type: when the caller needs structured detail.
type ToolError struct {
    Tool string
    Code int
    Err  error
}

func (e *ToolError) Error() string { return fmt.Sprintf("tool %s: %v", e.Tool, e.Err) }
func (e *ToolError) Unwrap() error { return e.Err }        // makes errors.Is see through it

// Callers:
if errors.Is(err, ErrNotFound) {                            // βœ… works through any wrapping
    return http.StatusNotFound, nil
}

var toolErr *ToolError
if errors.As(err, &toolErr) {                               // βœ… extract the typed error
    metrics.ToolFailures.WithLabelValues(toolErr.Tool).Inc()
}

if err == ErrNotFound { }                                   // ❌ breaks the moment someone wraps
Enter fullscreen mode Exit fullscreen mode

errors.Is for identity, errors.As for structure. Never compare error strings.

5.4 Handling patterns that keep code readable

// βœ… Handle immediately; the happy path stays at the left margin.
resp, err := c.Complete(ctx, prompt)
if err != nil {
    return fmt.Errorf("agent.Run: %w", err)
}
use(resp)
Enter fullscreen mode Exit fullscreen mode
// βœ… Retry only what's retryable.
for attempt := range maxAttempts {
    out, err = call(ctx)
    if err == nil { break }
    if !errors.Is(err, ErrRateLimit) && !isTransient(err) {
        return fmt.Errorf("agent.call: %w", err)     // permanent β†’ stop immediately
    }
    select {
    case <-time.After(backoff(attempt)):
    case <-ctx.Done():
        return ctx.Err()
    }
}
Enter fullscreen mode Exit fullscreen mode
// βœ… Deliberately ignoring an error is written, not implied.
_ = resp.Body.Close()
defer func() { _ = tx.Rollback() }()   // rollback after a commit is a no-op
Enter fullscreen mode Exit fullscreen mode
// βœ… Collect failures across a batch instead of stopping at the first.
var errs []error
for _, chunk := range chunks {
    if err := index(ctx, chunk); err != nil {
        errs = append(errs, fmt.Errorf("chunk %s: %w", chunk.ID, err))
    }
}
return errors.Join(errs...)     // nil if the slice is empty
Enter fullscreen mode Exit fullscreen mode

5.5 Panic and recover β€” and when they're legitimate

panic unwinds the goroutine and crashes the process unless recovered. It is not an exception system.

Panic only when the program cannot sensibly continue: an impossible invariant, a programming bug, or failed initialization at startup (regexp.MustCompile, template.Must β€” the Must prefix is the convention).

Recover only at a process boundary β€” one bad request must not kill the server:

func Recoverer(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                slog.Error("panic in handler",
                    "err", rec, "path", r.URL.Path, "stack", string(debug.Stack()))
                http.Error(w, "internal error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r)
    })
}
Enter fullscreen mode Exit fullscreen mode

⚠️ recover only works in the same goroutine. A panic inside go func(){…}() kills the whole process no matter what your HTTP middleware does β€” every goroutine you spawn needs its own recover, or must be provably panic-free.

5.6 Python ↔ Go error mapping

Python Go
raise ValueError("bad temp") return fmt.Errorf("bad temperature %v", t)
except ValueError: if errors.Is(err, ErrBadTemp)
except SomeError as e: e.field var e *SomeError; errors.As(err, &e)
raise X from err fmt.Errorf("context: %w", err)
finally: defer
except Exception: pass _ = f() (and a comment saying why)
Traceback The wrap chain you built by hand
sys.exit(1) on fatal config log.Fatal / panic in main only

🎯 Actionable rules

  1. Wrap with %w and a pkg.Func: prefix at every layer; log once, at the top.
  2. errors.Is for sentinels, errors.As for typed detail β€” never string comparison.
  3. Panic only for programmer bugs and startup failures; recover only at boundaries.
  4. Every goroutine you start needs its own panic protection.

6. πŸŒ€ Concurrency: Goroutines, Channels, Context

Go's headline feature. It is also where every serious Go bug lives.

6.1 Goroutines

go doWork()                      // that's the entire syntax
go func(id string) { … }(docID)  // pass arguments explicitly
Enter fullscreen mode Exit fullscreen mode

A goroutine is a user-space thread multiplexed onto OS threads by the Go runtime: ~2 KB of initial stack (grown on demand), microsecond creation. A hundred thousand of them in one process is normal; a hundred thousand OS threads is not.

The rule that prevents most production incidents: never start a goroutine without knowing how it stops. Every goroutine needs an exit condition β€” a closed channel, a cancelled context, or a finite loop. A goroutine blocked forever on a channel nobody writes to is a leak: its stack, its captured variables, and everything they reference stay alive until the process dies.

// ❌ leaks one goroutine per request, forever, if nobody reads results
go func() { results <- expensive() }()

// βœ… it can always exit
go func() {
    select {
    case results <- expensive():
    case <-ctx.Done():
    }
}()
Enter fullscreen mode Exit fullscreen mode

6.2 Channels

A channel is a typed, concurrency-safe queue. Unbuffered channels are a rendezvous: the sender blocks until a receiver takes the value.

ch := make(chan Token)             // unbuffered: synchronous handoff
buf := make(chan Job, 100)         // buffered: sender proceeds until full
ch <- tok                          // send
tok := <-ch                        // receive
tok, ok := <-ch                    // ok == false when the channel is closed AND drained
close(ch)                          // only the SENDER closes, and only once
for tok := range ch { … }          // receives until closed
Enter fullscreen mode Exit fullscreen mode

Directional types document intent and are checked by the compiler:

func produce(out chan<- Token)  { … }   // send-only
func consume(in  <-chan Token)  { … }   // receive-only
Enter fullscreen mode Exit fullscreen mode
Operation On a nil channel On a closed channel
Send blocks forever panics
Receive blocks forever returns zero value immediately, ok=false
Close panics panics

Consequences: only ever close from the single owning sender; closing signals "no more values", not "stop". To stop a consumer, cancel its context.

6.3 select

select {
case tok := <-tokens:
    emit(tok)
case err := <-errs:
    return err
case <-ctx.Done():                       // cancellation, always include it
    return ctx.Err()
case <-time.After(5 * time.Second):      // per-iteration timeout
    return errors.New("stream stalled")
default:                                 // non-blocking: runs if nothing else is ready
    metrics.Idle.Inc()
}
Enter fullscreen mode Exit fullscreen mode

select blocks until one case is ready, choosing randomly among ready cases. With default it never blocks. ⚠️ time.After allocates a timer per call β€” inside a hot loop use a reusable time.NewTimer/Ticker and stop it.

6.4 The three concurrency shapes you'll actually build

1. Bounded worker pool β€” N workers over a job channel. The default for embedding, indexing, or crawling:

func EmbedAll(ctx context.Context, chunks []string, workers int) ([][]float32, error) {
    type result struct {
        i   int
        vec []float32
        err error
    }
    jobs := make(chan int)
    out := make(chan result, len(chunks))

    var wg sync.WaitGroup
    for range workers {                     // fixed number of goroutines
        wg.Add(1)
        go func() {
            defer wg.Done()
            for i := range jobs {           // exits when jobs is closed
                v, err := embed(ctx, chunks[i])
                out <- result{i, v, err}
            }
        }()
    }

    go func() {                             // feed, then close so workers exit
        defer close(jobs)
        for i := range chunks {
            select {
            case jobs <- i:
            case <-ctx.Done():
                return
            }
        }
    }()

    wg.Wait()
    close(out)

    vecs := make([][]float32, len(chunks))
    for r := range out {
        if r.err != nil {
            return nil, fmt.Errorf("embed chunk %d: %w", r.i, r.err)
        }
        vecs[r.i] = r.vec                   // index carries the order back
    }
    return vecs, nil
}
Enter fullscreen mode Exit fullscreen mode

2. errgroup β€” the concise version when you just need "run these, stop on first error":

import "golang.org/x/sync/errgroup"

g, ctx := errgroup.WithContext(ctx)         // ctx is cancelled as soon as one task fails
g.SetLimit(8)                               // ← bounded concurrency, one line

results := make([]Doc, len(ids))
for i, id := range ids {
    g.Go(func() error {                     // Go 1.22+: no `i := i` needed
        d, err := fetch(ctx, id)
        if err != nil {
            return fmt.Errorf("fetch %s: %w", id, err)
        }
        results[i] = d                      // βœ… distinct indices β€” no mutex required
        return nil
    })
}
if err := g.Wait(); err != nil {
    return nil, err
}
Enter fullscreen mode Exit fullscreen mode

This is Go's asyncio.gather + Semaphore, with cancellation included.

3. Pipeline / fan-in β€” merge several streams into one, the shape behind multi-model or multi-tool streaming:

func merge[T any](ctx context.Context, chans ...<-chan T) <-chan T {
    out := make(chan T)
    var wg sync.WaitGroup
    for _, c := range chans {
        wg.Add(1)
        go func(c <-chan T) {
            defer wg.Done()
            for v := range c {
                select {
                case out <- v:
                case <-ctx.Done():
                    return
                }
            }
        }(c)
    }
    go func() { wg.Wait(); close(out) }()    // close exactly once, after all senders finish
    return out
}
Enter fullscreen mode Exit fullscreen mode

6.5 context: cancellation that actually propagates

context.Context carries a deadline, a cancellation signal, and request-scoped values down the call tree. Every function that does I/O takes one as its first parameter.

ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()                            // βœ… ALWAYS defer cancel β€” otherwise the timer leaks

resp, err := agent.Run(ctx, prompt)
switch {
case errors.Is(err, context.DeadlineExceeded):
    http.Error(w, "upstream timeout", http.StatusGatewayTimeout)
case errors.Is(err, context.Canceled):
    return                                // client hung up; nothing to write
}
Enter fullscreen mode Exit fullscreen mode

Why it matters for AI services: when a user closes the browser mid-stream, r.Context() is cancelled, and that cancellation flows into your model call, your DB query, and every worker goroutine β€” so you stop paying for tokens nobody will read.

// Values: request-scoped metadata only, with an unexported key type.
type ctxKey struct{}
var tenantKey ctxKey

ctx = context.WithValue(ctx, tenantKey, tenant)
tenant, ok := ctx.Value(tenantKey).(string)
Enter fullscreen mode Exit fullscreen mode

Rules: ctx is the first parameter, never stored in a struct; context.Background() only in main/tests; never pass nil; values are for tracing/tenancy, never for optional arguments.

6.6 sync: when channels are overkill

"Don't communicate by sharing memory; share memory by communicating." …but a mutex around a cache is simpler than a channel, and simpler wins.

type Cache struct {
    mu sync.RWMutex                     // zero value is ready β€” no initialization
    m  map[string][]float32
}
func (c *Cache) Get(k string) ([]float32, bool) {
    c.mu.RLock()                        // many concurrent readers
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}
func (c *Cache) Put(k string, v []float32) {
    c.mu.Lock()                         // one writer, excludes readers
    defer c.mu.Unlock()
    c.m[k] = v
}

var once sync.Once
once.Do(func() { tokenizer = loadTokenizer() })      // exactly-once init

var wg sync.WaitGroup                    // wg.Add before `go`, wg.Done in a defer
var inflight atomic.Int64                // lock-free counters
inflight.Add(1); defer inflight.Add(-1)
Enter fullscreen mode Exit fullscreen mode

Use sync.Map only for its two documented patterns (write-once/read-many, or disjoint key sets per goroutine); otherwise a plain map with an RWMutex is faster and clearer. Put the mutex next to the data it protects, and document what it guards.

6.7 The race detector is not optional

go test -race ./...
go run -race ./cmd/api
Enter fullscreen mode Exit fullscreen mode

It catches unsynchronized concurrent access at runtime (~10Γ— slower, more memory β€” fine for CI). A data race in Go is undefined behaviour, not just a wrong number: a torn map write crashes the process.

6.8 Concurrency bug checklist

Symptom Cause Fix
Memory grows forever Goroutine leak β€” blocked send/receive Add <-ctx.Done() to every select; close channels
all goroutines are asleep - deadlock! Unbuffered send with no receiver; wg.Wait() before Done Check ownership; wg.Add before go
send on closed channel panic Multiple senders, or closing to signal "stop" Only the sole sender closes; cancel via context
Results in the wrong order Concurrency doesn't preserve order Carry an index, or write into a preallocated slice
Rare corrupt data Data race -race, then a mutex or channel
429s / OOM under load Unbounded fan-out g.SetLimit(n) or a worker pool
context deadline exceeded everywhere One deadline shared by N sequential calls Give each call its own budget

🎯 Actionable rules

  1. Every goroutine has a known exit path; every blocking select has <-ctx.Done().
  2. Bound concurrency explicitly β€” errgroup.SetLimit or a fixed worker pool. Never go in an unbounded loop.
  3. ctx first parameter, defer cancel() always.
  4. Run -race in CI, permanently.

7. ⚑ The Runtime: Scheduler, GC, Memory

You don't have to know this to write Go. You do have to know it to explain a p99 latency spike.

7.1 The scheduler (G-M-P)

G = goroutine   M = OS thread   P = processor (a scheduling context, GOMAXPROCS of them)

   [P0]──local run queue──> G G G        each P owns a queue of runnable Gs
   [P1]──local run queue──> G            an idle P steals work from a busy one
     ↑ bound to an M (thread) while running
   [global run queue] ── overflow ──
Enter fullscreen mode Exit fullscreen mode
  • GOMAXPROCS = how many goroutines execute Go code simultaneously. It defaults to the number of CPUs β€” and since Go 1.25 it respects the container's CPU limit. On older versions inside Kubernetes, set it from the cgroup quota (go.uber.org/automaxprocs) or your 500m-CPU pod will spawn 64 Ps and thrash.
  • When a goroutine makes a blocking syscall, the runtime detaches its M and hands the P to another thread β€” so blocking I/O doesn't stall your other goroutines. This is why Go needs no async/await colouring.
  • Since Go 1.14 the scheduler preempts asynchronously, so a tight CPU loop can't starve everyone else.
  • Channel operations, mutex contention, and network I/O park a goroutine cheaply (the netpoller integrates with epoll/kqueue).

Versus Python: asyncio gives you one thread cooperatively multiplexing coroutines, and any blocking call freezes all of them. Go gives you preemptive scheduling across every core with no code-colour distinction. That's the core reason a Go gateway holds 50k streaming connections on hardware where a Python one needs process fan-out.

7.2 Garbage collection

Go's GC is a concurrent, tri-colour mark-and-sweep collector, non-generational and non-compacting. It's tuned for latency, not throughput: sub-millisecond stop-the-world pauses, at the cost of some CPU and headroom.

GOGC=100      # default: collect when the heap doubles since the last GC
GOGC=200      # collect half as often β€” more RAM, less CPU
GOMEMLIMIT=6GiB   # soft memory ceiling (Go 1.19+) β€” the setting for containers
GODEBUG=gctrace=1 ./api    # one line per GC cycle: heap size, pause, CPU share
Enter fullscreen mode Exit fullscreen mode

In containers, set GOMEMLIMIT to ~80% of the pod's memory limit. Without it, Go sizes the heap from GOGC alone, happily grows past the cgroup limit, and gets OOM-killed with no Go-level error. With it, the GC works harder as you approach the ceiling instead of dying.

Pointer-heavy structures make GC scan more. Fewer, larger allocations of pointer-free data ([]float32 for embeddings, not []*float32) is the single biggest GC win in AI workloads.

7.3 Memory model in one paragraph

A write in one goroutine is only guaranteed visible to another if they synchronize β€” via a channel operation, a mutex, sync/atomic, sync.Once, or WaitGroup. Without that, the compiler and CPU may reorder freely, and the race detector will (eventually) tell you. There is no "volatile"; there is sync/atomic.

7.4 Escape analysis and allocation

The compiler puts values on the stack (free, no GC) unless they can outlive the function, in which case they escape to the heap.

go build -gcflags='-m' ./...      # prints "escapes to heap" / "does not escape"
Enter fullscreen mode Exit fullscreen mode

Common causes of escape: returning a pointer to a local, storing in an interface, closing over a variable, sending on a channel, fmt.Sprintf.

Allocation-reduction techniques, in order of payoff:

out := make([]Doc, 0, len(ids))         // 1. preallocate with capacity β€” avoids log(n) regrowths
m := make(map[string]int, 1000)

var b strings.Builder                    // 2. builders instead of += concatenation
b.Grow(estimate)

var bufPool = sync.Pool{                 // 3. pool big, short-lived buffers on hot paths
    New: func() any { return new(bytes.Buffer) },
}
buf := bufPool.Get().(*bytes.Buffer)
defer func() { buf.Reset(); bufPool.Put(buf) }()

func (s *Scanner) Fill(dst []byte) int   // 4. let the caller own the buffer
Enter fullscreen mode Exit fullscreen mode

Do these where a profile says they matter (Β§12), not everywhere. sync.Pool used carelessly is a memory leak with extra steps.

7.5 When Go beats Python β€” and when it doesn't

Workload Winner Why
20k concurrent SSE streams Go, decisively 2 KB goroutines vs event-loop + process fan-out
Fan-out to 50 tools/APIs per request Go errgroup + real parallelism
JSON/protobuf transformation at volume Go Compiled, GC-friendly, no interpreter overhead
Token/rate accounting, queues, schedulers Go Predictable latency, cheap primitives
Embedding, training, fine-tuning Python torch/numpy/CUDA live there
Data science, notebooks, evaluation Python The ecosystem is the product
Model-specific pre/post-processing Python Tokenizers and libraries exist already

🎯 Actionable rules

  1. In containers: set GOMEMLIMIT (~80% of the limit) and make GOMAXPROCS cgroup-aware.
  2. Preallocate slices and maps whose size you know.
  3. Prefer pointer-free bulk data ([]float32) to reduce GC scan time.
  4. Optimize allocations only where a pprof profile points.

8. πŸ“¦ The Standard Library & AI Toolkit

Go's stdlib is unusually complete: an HTTP/2 server, JSON, TLS, templating, profiling, and testing all ship with the compiler. The list below is what an AI service actually uses.

8.1 net/http β€” the server

mux := http.NewServeMux()
mux.HandleFunc("POST /v1/query", h.Query)          // Go 1.22+: method + wildcards
mux.HandleFunc("GET /v1/jobs/{id}", h.GetJob)      // r.PathValue("id")

srv := &http.Server{
    Addr:              ":8080",
    Handler:           Recoverer(RequestID(Logging(mux))),   // middleware = wrapped handlers
    ReadHeaderTimeout: 5 * time.Second,     // βœ… blocks Slowloris; the one people forget
    ReadTimeout:       30 * time.Second,
    WriteTimeout:      0,                   // 0 for SSE/streaming endpoints; set it otherwise
    IdleTimeout:       120 * time.Second,
    MaxHeaderBytes:    1 << 20,
}

// Graceful shutdown: stop accepting, let in-flight requests finish.
go func() {
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        slog.Error("listen", "err", err); os.Exit(1)
    }
}()

ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
Enter fullscreen mode Exit fullscreen mode

chi adds routers, groups, and middleware chains on top of http.Handler without inventing a new handler type β€” which is why it composes with everything (and why this repo uses it).

8.2 net/http β€” the client

var client = &http.Client{                 // βœ… ONE client for the process, reused
    Timeout: 60 * time.Second,             // total budget, including body read
    Transport: &http.Transport{
        MaxIdleConns:        200,
        MaxIdleConnsPerHost: 100,          // default is 2 β€” far too low for an LLM proxy
        IdleConnTimeout:     90 * time.Second,
    },
}

req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil { return fmt.Errorf("llm.Complete: %w", err) }
req.Header.Set("Content-Type", "application/json")

resp, err := client.Do(req)
if err != nil { return fmt.Errorf("llm.Complete: %w", err) }
defer resp.Body.Close()                    // βœ… ALWAYS β€” otherwise the connection leaks
if resp.StatusCode != http.StatusOK {
    b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))    // cap what you read on errors
    return fmt.Errorf("llm.Complete: status %d: %s", resp.StatusCode, b)
}
Enter fullscreen mode Exit fullscreen mode

Three non-negotiables: reuse the client, always close the body, always pass a context. Creating an http.Client per request disables connection pooling and exhausts sockets under load.

8.3 encoding/json

type QueryIn struct {
    Query       string   `json:"query"`
    Temperature float64  `json:"temperature,omitempty"`   // omit when zero
    Tools       []string `json:"tools,omitempty"`
    internal    string   `json:"-"`                       // never marshalled
}

b, err := json.Marshal(v)
err = json.Unmarshal(b, &v)                               // note the pointer

dec := json.NewDecoder(r.Body)                            // βœ… stream, don't ReadAll
dec.DisallowUnknownFields()                               // βœ… typo'd client fields become errors
if err := dec.Decode(&in); err != nil {
    http.Error(w, "invalid body", http.StatusBadRequest); return
}

var raw json.RawMessage                                    // defer parsing tool args
enc := json.NewEncoder(w); enc.Encode(out)                 // stream the response out
Enter fullscreen mode Exit fullscreen mode

⚠️ Only exported fields are marshalled. ⚠️ Unmarshalling into map[string]any turns every number into float64 β€” decode into a struct whenever you can. For hot paths, json.Decoder on the body avoids materializing the whole payload.

Custom marshalling for domain types:

func (r Role) MarshalJSON() ([]byte, error) { return json.Marshal(string(r)) }
Enter fullscreen mode Exit fullscreen mode

8.4 log/slog β€” structured logging (Go 1.21+)

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)

slog.Info("tool completed", "tool", name, "ms", elapsed.Milliseconds(), "tokens", n)
slog.Error("model call failed", "err", err, "model", cfg.Model, "attempt", i)

reqLog := logger.With("request_id", rid, "tenant", tenant)   // bind once, reuse per request
reqLog.Info("received")
Enter fullscreen mode Exit fullscreen mode

Structured key-value output is what makes logs queryable in Loki/Datadog. Never log prompts, keys, or full request bodies β€” log ids, counts, durations, and truncated previews.

8.5 time

time.Now(); time.Since(start)                    // monotonic for durations
30 * time.Second; 500 * time.Millisecond         // Durations are typed ints β€” no unit bugs
t.Format(time.RFC3339); time.Parse(time.RFC3339, s)
time.Now().UTC()                                 // store UTC, convert at the edge

tick := time.NewTicker(10 * time.Second)
defer tick.Stop()                                // βœ… tickers leak if not stopped
select {
case <-tick.C: flushMetrics()
case <-ctx.Done(): return
}
Enter fullscreen mode Exit fullscreen mode

8.6 io and bufio β€” the composable plumbing

io.Copy(dst, src)                                  // stream, constant memory
io.ReadAll(io.LimitReader(r, 10<<20))              // βœ… always cap untrusted input
io.MultiWriter(w, &buf)                            // tee the response into a buffer

sc := bufio.NewScanner(resp.Body)                  // line-by-line: perfect for SSE
sc.Buffer(make([]byte, 0, 64*1024), 1<<20)         // βœ… raise the 64 KB line limit
for sc.Scan() {
    line := sc.Text()
    …
}
if err := sc.Err(); err != nil { … }               // βœ… Scan() returning false isn't always EOF
Enter fullscreen mode Exit fullscreen mode

8.7 The rest, in one breath

Package Use it for
context Cancellation and deadlines (Β§6.5)
sync / sync/atomic Mutexes, WaitGroup, Once, counters (Β§6.6)
errors Is, As, Join, Unwrap (Β§5)
strconv / strings / bytes Conversion and text handling (Β§2.4)
regexp RE2 β€” linear time, no catastrophic backtracking; MustCompile at package level
os / os/signal Env, files, SIGTERM handling
flag Small CLIs; use cobra for a command tree
embed //go:embed prompts/*.md β€” bake prompts and migrations into the binary
text/template Prompt templating with named fields
database/sql (+ sqlx, pgx) SQL; always QueryContext, always defer rows.Close(), always check rows.Err()
encoding/base64, crypto/* Tokens, signatures, crypto/rand for secrets
net/http/httptest In-process HTTP tests (Β§10)
runtime/pprof, net/http/pprof Profiling (Β§12)
testing Tests, benchmarks, fuzzing β€” all built in

Third-party worth adopting: golang.org/x/sync/errgroup and singleflight, go-chi/chi, jmoiron/sqlx, stretchr/testify/require, pressly/goose, golang.org/x/time/rate, and OpenTelemetry for traces. Go culture keeps dependency trees small β€” prefer the stdlib until it genuinely hurts.

🎯 Actionable rules

  1. One http.Client per process with a timeout and a tuned transport; defer resp.Body.Close() always.
  2. Explicit http.Server timeouts and graceful shutdown on SIGTERM.
  3. json.Decoder + DisallowUnknownFields on request bodies; io.LimitReader on anything untrusted.
  4. slog with key-value pairs from day one β€” retrofitting structure is miserable.

9. πŸ€– AI Service Patterns in Go

What Go is actually for in an AI stack: the request path, the fan-out, and the streaming.

9.1 Consuming an SSE token stream

func (c *LLM) Stream(ctx context.Context, prompt string, out chan<- string) error {
    req, _ := http.NewRequestWithContext(ctx, http.MethodPost, c.url, encode(prompt))
    req.Header.Set("Accept", "text/event-stream")

    resp, err := c.http.Do(req)
    if err != nil {
        return fmt.Errorf("llm.Stream: %w", err)
    }
    defer resp.Body.Close()

    sc := bufio.NewScanner(resp.Body)
    sc.Buffer(make([]byte, 0, 64*1024), 1<<20)      // model chunks exceed the 64 KB default
    for sc.Scan() {
        line, ok := strings.CutPrefix(sc.Text(), "data: ")
        if !ok || line == "" {
            continue
        }
        if line == "[DONE]" {
            return nil
        }
        var ev struct {
            Delta struct{ Text string } `json:"delta"`
        }
        if err := json.Unmarshal([]byte(line), &ev); err != nil {
            return fmt.Errorf("llm.Stream: decode %q: %w", truncate(line, 80), err)
        }
        select {
        case out <- ev.Delta.Text:
        case <-ctx.Done():                          // client disconnected: stop paying for tokens
            return ctx.Err()
        }
    }
    return sc.Err()
}
Enter fullscreen mode Exit fullscreen mode

9.2 Serving SSE to the browser

func (h *Handler) Stream(w http.ResponseWriter, r *http.Request) {
    rc := http.NewResponseController(w)             // Go 1.20+; replaces the http.Flusher cast
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("X-Accel-Buffering", "no")       // stop nginx from buffering your stream

    ctx := r.Context()                              // cancelled when the client goes away
    tokens := make(chan string, 16)
    errc := make(chan error, 1)
    go func() { errc <- h.llm.Stream(ctx, r.FormValue("q"), tokens); close(tokens) }()

    for {
        select {
        case tok, ok := <-tokens:
            if !ok {
                fmt.Fprint(w, "data: [DONE]\n\n")
                _ = rc.Flush()
                return
            }
            fmt.Fprintf(w, "data: %s\n\n", tok)
            _ = rc.Flush()                          // βœ… without Flush nothing reaches the client
        case <-ctx.Done():
            return
        case <-time.After(30 * time.Second):
            slog.Warn("stream stalled", "path", r.URL.Path)
            return
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Remember to set WriteTimeout: 0 on the server for streaming routes (Β§8.1), or the connection dies mid-answer.

9.3 A tool registry with schemas

type Tool struct {
    Name        string          `json:"name"`
    Description string          `json:"description"`
    Schema      json.RawMessage `json:"input_schema"`     // sent verbatim to the model
    Run         func(ctx context.Context, args json.RawMessage) (string, error) `json:"-"`
}

type Registry struct {
    mu    sync.RWMutex
    tools map[string]Tool
}

func (r *Registry) Register(t Tool) error {
    r.mu.Lock(); defer r.mu.Unlock()
    if _, dup := r.tools[t.Name]; dup {
        return fmt.Errorf("registry.Register: duplicate tool %q", t.Name)
    }
    r.tools[t.Name] = t
    return nil
}

func (r *Registry) Dispatch(ctx context.Context, name string, args json.RawMessage) (string, error) {
    r.mu.RLock(); t, ok := r.tools[name]; r.mu.RUnlock()
    if !ok {
        return "", fmt.Errorf("registry.Dispatch: unknown tool %q", name)   // never trust the model
    }
    ctx, cancel := context.WithTimeout(ctx, 30*time.Second)                // βœ… per-tool budget
    defer cancel()
    return t.Run(ctx, args)
}
Enter fullscreen mode Exit fullscreen mode

Two things the model must never control: which tools exist, and how long they may run.

9.4 Retries, rate limits, and backpressure

import "golang.org/x/time/rate"

type Client struct {
    http    *http.Client
    limiter *rate.Limiter          // rate.NewLimiter(rate.Limit(50), 100) β†’ 50 rps, burst 100
    sem     chan struct{}          // concurrency cap: make(chan struct{}, 16)
}

func (c *Client) Complete(ctx context.Context, prompt string) (string, error) {
    if err := c.limiter.Wait(ctx); err != nil {          // blocks or returns on cancellation
        return "", fmt.Errorf("llm.Complete: rate wait: %w", err)
    }
    select {                                             // bound in-flight requests
    case c.sem <- struct{}{}:
        defer func() { <-c.sem }()
    case <-ctx.Done():
        return "", ctx.Err()
    }

    var lastErr error
    for attempt := range 4 {
        out, err := c.do(ctx, prompt)
        if err == nil {
            return out, nil
        }
        lastErr = err
        var re *RetryableError
        if !errors.As(err, &re) {
            return "", fmt.Errorf("llm.Complete: %w", err)          // permanent β†’ stop
        }
        delay := re.RetryAfter                                       // honour the server's hint
        if delay == 0 {
            delay = time.Duration(1<<attempt) * 200 * time.Millisecond
        }
        jitter := time.Duration(rand.Int64N(int64(delay / 2)))       // math/rand/v2
        select {
        case <-time.After(delay + jitter):
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
    return "", fmt.Errorf("llm.Complete: exhausted retries: %w", lastErr)
}
Enter fullscreen mode Exit fullscreen mode

9.5 Calling the Python ML service (the BFF shape)

// Go owns HTTP, auth, tenancy, and fan-out; Python owns the model work.
func (s *Service) Answer(ctx context.Context, tenant, q string) (Answer, error) {
    ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
    defer cancel()

    g, gctx := errgroup.WithContext(ctx)
    var (
        docs []Doc
        vec  []float32
    )
    g.Go(func() (err error) { docs, err = s.repo.Search(gctx, tenant, q); return })
    g.Go(func() (err error) { vec, err = s.python.Embed(gctx, q); return })   // internal REST
    if err := g.Wait(); err != nil {
        return Answer{}, fmt.Errorf("service.Answer: %w", err)
    }
    …
}
Enter fullscreen mode Exit fullscreen mode

Retrieval and embedding run in parallel; either failure cancels the other; the whole request shares one deadline. That is ~15 lines of Go for what needs careful orchestration elsewhere.

9.6 singleflight β€” collapse duplicate work

When 500 users ask the same question in the same second, do the expensive thing once:

import "golang.org/x/sync/singleflight"

var group singleflight.Group

func (c *Cache) Embed(ctx context.Context, text string) ([]float32, error) {
    key := hash(text)
    if v, ok := c.Get(key); ok {
        return v, nil
    }
    v, err, _ := group.Do(key, func() (any, error) {     // concurrent callers share one result
        return c.upstream.Embed(ctx, text)
    })
    if err != nil {
        return nil, fmt.Errorf("cache.Embed: %w", err)
    }
    return v.([]float32), nil
}
Enter fullscreen mode Exit fullscreen mode

🎯 Actionable rules

  1. Propagate r.Context() into every model call so a disconnect stops the spend.
  2. Bound everything: rate limiter, concurrency semaphore, per-tool timeout, retry cap.
  3. Flush after every SSE write, and disable proxy buffering.
  4. Validate tool names against the registry β€” the model's output is untrusted input.

10. πŸ§ͺ Testing, Benchmarks, Fuzzing

Testing is in the standard library, in the same package, with no framework to choose. That's a feature.

10.1 Table-driven tests β€” the Go idiom

// internal/service/summarize_test.go
package service

func TestSummarize(t *testing.T) {
    tests := []struct {
        name     string
        text     string
        maxWords int
        want     string
        wantErr  bool
    }{
        {name: "truncates", text: "a b c d", maxWords: 2, want: "a b"},
        {name: "collapses whitespace", text: " a   b ", maxWords: 5, want: "a b"},
        {name: "rejects zero", text: "a", maxWords: 0, wantErr: true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {           // a named subtest per case
            t.Parallel()                              // βœ… subtests run concurrently
            got, err := Summarize(tt.text, tt.maxWords)
            if tt.wantErr {
                require.Error(t, err)
                return
            }
            require.NoError(t, err)
            require.Equal(t, tt.want, got)
        })
    }
}
Enter fullscreen mode Exit fullscreen mode

go test failures print the subtest path (TestSummarize/rejects_zero), so you know exactly which case broke. Per this repo's conventions, use testify/require (stops the test) over assert (continues) and over bare t.Fatal.

Helpers that pay for themselves:

t.Helper()                    // in a helper: failures report the CALLER's line
t.Cleanup(func() { … })       // teardown, LIFO, runs even on failure β€” better than defer
t.TempDir()                   // auto-removed temp directory
t.Setenv("MODEL", "x")        // auto-restored env (forbids t.Parallel in that test)
t.Context()                   // Go 1.24+: a context cancelled at test end
testing.Short()               // skip slow tests under `go test -short`
Enter fullscreen mode Exit fullscreen mode

10.2 Fakes, not mocks

Because interfaces are structural and defined by the consumer, a fake is just a struct:

type fakeLLM struct {
    replies []string
    calls   int
}

func (f *fakeLLM) Complete(ctx context.Context, prompt string) (string, error) {
    if f.calls >= len(f.replies) {
        return "", errors.New("fakeLLM: out of replies")
    }
    f.calls++
    return f.replies[f.calls-1], nil
}

func TestAgentUsesCalculator(t *testing.T) {
    llm := &fakeLLM{replies: []string{`{"tool":"calculator","args":{"expression":"10*5"}}`}}
    agent, err := NewAgent(AgentConfig{Name: "t"}, llm)
    require.NoError(t, err)

    resp, err := agent.Run(t.Context(), "calculate 10 * 5")
    require.NoError(t, err)
    require.Equal(t, StatusOK, resp.Status)
    require.Equal(t, 1, llm.calls)
}
Enter fullscreen mode Exit fullscreen mode

No mocking library, no code generation, no patching. If faking your interface is painful, the interface is too big.

10.3 HTTP tests with httptest

// Test a handler without a network.
func TestQueryHandler(t *testing.T) {
    h := NewHandler(&fakeService{})
    req := httptest.NewRequest(http.MethodPost, "/v1/query", strings.NewReader(`{"query":"hi"}`))
    rec := httptest.NewRecorder()

    h.Query(rec, req)

    require.Equal(t, http.StatusOK, rec.Code)
    require.JSONEq(t, `{"answer":"hi!"}`, rec.Body.String())
}

// Stub an upstream provider with a real server.
func TestClientRetriesOn429(t *testing.T) {
    var hits atomic.Int32
    srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if hits.Add(1) < 3 {
            w.WriteHeader(http.StatusTooManyRequests)
            return
        }
        _, _ = io.WriteString(w, `{"content":"ok"}`)
    }))
    defer srv.Close()

    c := NewClient(srv.URL)
    out, err := c.Complete(t.Context(), "hi")
    require.NoError(t, err)
    require.Equal(t, "ok", out)
    require.EqualValues(t, 3, hits.Load())
}
Enter fullscreen mode Exit fullscreen mode

10.4 Golden files for large outputs

var update = flag.Bool("update", false, "update golden files")

func TestPromptRendering(t *testing.T) {
    got := RenderSystemPrompt(cfg)
    golden := filepath.Join("testdata", "system_prompt.golden")
    if *update {
        require.NoError(t, os.WriteFile(golden, []byte(got), 0o644))
    }
    want, err := os.ReadFile(golden)          // testdata/ is ignored by the go tool
    require.NoError(t, err)
    require.Equal(t, string(want), got)
}
Enter fullscreen mode Exit fullscreen mode

go test ./... -update regenerates; the diff shows up in code review. Ideal for prompts, schemas, and serialized payloads.

10.5 Integration tests behind a build tag

//go:build integration

package repo_test
// … tests that need a real Postgres (testcontainers-go), run with:
//   go test -tags integration ./...
Enter fullscreen mode Exit fullscreen mode

Fast unit tests in pre-commit, tagged integration tests in CI β€” the split this repo's CLAUDE.md prescribes.

10.6 Benchmarks

func BenchmarkChunk(b *testing.B) {
    doc := strings.Repeat("word ", 100_000)
    b.ReportAllocs()
    b.ResetTimer()
    for b.Loop() {                 // Go 1.24+; older: for i := 0; i < b.N; i++
        sink = Chunk(doc, 1000, 200)
    }
}
var sink []string                  // package-level: stops the compiler optimizing the call away
Enter fullscreen mode Exit fullscreen mode
go test -bench=Chunk -benchmem -count=10 ./internal/text | tee new.txt
benchstat old.txt new.txt          # statistically meaningful comparison, not one lucky run
Enter fullscreen mode Exit fullscreen mode

-benchmem prints B/op and allocs/op β€” usually more actionable than ns/op, because allocations drive GC pressure.

10.7 Fuzzing

func FuzzParseToolCall(f *testing.F) {
    f.Add(`{"tool":"calc","args":{}}`)                 // seed corpus
    f.Fuzz(func(t *testing.T, s string) {
        _, _ = ParseToolCall([]byte(s))                // must never panic on any input
    })
}
Enter fullscreen mode Exit fullscreen mode
go test -fuzz=FuzzParseToolCall -fuzztime=60s ./internal/agent
Enter fullscreen mode Exit fullscreen mode

Anything that parses model output is a prime fuzz target: LLMs emit truncated JSON, nested fences, and 10 MB of whitespace. Crashes land in testdata/fuzz/ and become permanent regression tests.

10.8 Running tests

go test ./...                       # everything
go test -race ./...                 # βœ… what CI must run
go test -run TestAgent/calculator    # by name, subtests included
go test -short ./...                # skip the slow ones
go test -cover ./... && go tool cover -html=cover.out
go test -count=1 ./...              # bypass the test cache
Enter fullscreen mode Exit fullscreen mode

🎯 Actionable rules

  1. Table-driven + t.Run + t.Parallel as the default shape.
  2. Hand-written fakes over mock frameworks; keep interfaces small enough to fake.
  3. -race in CI, always; fuzz anything that parses untrusted or model-generated input.
  4. Benchmark with -benchmem and compare with benchstat, never by eyeballing one run.

11. πŸ—‚οΈ Project Layout & Tooling

11.1 Modules

go mod init github.com/acme/agent-service
go get github.com/go-chi/chi/v5@latest
go get -u ./...            # update dependencies
go mod tidy                # add what's used, drop what isn't β€” run before every commit
go mod download            # populate the module cache (Docker builds)
go mod why github.com/x/y  # who pulled this in?
go work init ./backend-go ./shared    # multi-module workspaces
Enter fullscreen mode Exit fullscreen mode

go.mod declares the module path, Go version, and dependencies; go.sum holds cryptographic hashes. Commit both. There is no venv: the toolchain resolves per-module, and builds are reproducible by construction.

Versioning is semantic import versioning: v2+ changes the import path (.../chi/v5). Awkward at first, but it makes two major versions coexist in one build.

11.2 Layout that scales

backend-go/
β”œβ”€β”€ go.mod / go.sum
β”œβ”€β”€ Makefile
β”œβ”€β”€ cmd/
β”‚   └── api/
β”‚       β”œβ”€β”€ main.go            # wiring only: config β†’ deps β†’ server
β”‚       └── routes.go
β”œβ”€β”€ internal/                  # ← the compiler FORBIDS imports from outside this module
β”‚   β”œβ”€β”€ handler/               # HTTP: decode, call service, encode. No business logic.
β”‚   β”œβ”€β”€ service/               # business logic. No HTTP types, no SQL.
β”‚   β”œβ”€β”€ repo/                  # DB access (sqlx). No business rules.
β”‚   β”œβ”€β”€ model/                 # domain types shared across layers
β”‚   └── middleware/
β”œβ”€β”€ pkg/                       # only for packages you intend other repos to import
β”œβ”€β”€ migrations/                # goose SQL files
└── testdata/                  # golden files, fixtures
Enter fullscreen mode Exit fullscreen mode
  • internal/ is enforced by the compiler β€” the strongest module boundary any mainstream language gives you. Default to it; pkg/ is opt-in publicity.
  • One-directional imports: handler β†’ service β†’ repo. If two packages need each other, extract the shared type into model/. Go rejects import cycles at compile time, so bad layering fails the build rather than rotting.
  • Package names are lowercase, short, and not stuttering: service.Agent, not service.ServiceAgent. The package name is part of every call site.
  • main.go does wiring and nothing else: read config, construct dependencies, start the server, handle SIGTERM.

11.3 The toolchain

gofmt -l .            # formatting is not a debate; gofmt decides
go vet ./...          # correctness heuristics: printf verbs, lost cancels, copied locks
go build ./...
go test -race ./...
go run ./cmd/api
go generate ./...     # //go:generate directives (mocks, enums, sqlc)
govulncheck ./...     # βœ… CVEs in YOUR call paths, not just in go.sum
Enter fullscreen mode Exit fullscreen mode

golangci-lint bundles the linters worth running:

# .golangci.yml
linters:
  enable:
    - errcheck      # unchecked errors ← the highest-value linter in Go
    - govet
    - staticcheck   # the deep one: dead code, misuse, simplifications
    - revive        # style + doc comments
    - ineffassign
    - bodyclose     # unclosed HTTP response bodies
    - noctx         # HTTP requests built without a context
    - sqlclosecheck
    - gosec
issues:
  exclude-rules:
    - path: _test\.go
      linters: [gosec, errcheck]
Enter fullscreen mode Exit fullscreen mode

Add air for hot reload in development (make dev-go in this repo), and a Makefile so every service has the same verbs: make dev, make test, make lint, make migrate-up.

11.4 Config and secrets

type Config struct {
    DatabaseURL string
    RedisURL    string
    Port        int
    APIKey      string
}

func Load() (Config, error) {
    c := Config{
        RedisURL: "redis://localhost:6379/0",     // defaults in code
        Port:     8080,
    }
    var missing []string
    for _, f := range []struct{ key string; dst *string }{
        {"DATABASE_URL", &c.DatabaseURL},
        {"ANTHROPIC_API_KEY", &c.APIKey},
    } {
        if *f.dst = os.Getenv(f.key); *f.dst == "" {
            missing = append(missing, f.key)
        }
    }
    if len(missing) > 0 {
        return Config{}, fmt.Errorf("config.Load: missing env: %s", strings.Join(missing, ", "))
    }
    …
    return c, nil
}
Enter fullscreen mode Exit fullscreen mode

Validate everything in main and exit non-zero on failure. A service that refuses to start beats one that fails on request #4000. (kelseyhightower/envconfig or caarlos0/env do this with struct tags if you prefer.)

11.5 A production-grade Dockerfile

Go's single static binary makes this dramatically simpler than the Python equivalent β€” the final image can contain only your binary.

# syntax=docker/dockerfile:1.9

# ────────────────────────── Stage 1: build ──────────────────────────
FROM golang:1.23-bookworm AS builder
WORKDIR /src

# Dependencies first: this layer is cached until go.mod/go.sum change.
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download

COPY . .

ARG VERSION=dev
ARG COMMIT=unknown
# CGO_ENABLED=0 β†’ a fully static binary that runs on scratch/distroless.
# -trimpath      β†’ no local paths in the binary (reproducible builds).
# -ldflags "-s -w" β†’ strip symbols/DWARF: ~25% smaller.
# -X             β†’ stamp build metadata into vars for /healthz and logs.
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build \
      -trimpath \
      -ldflags="-s -w -X main.version=${VERSION} -X main.commit=${COMMIT}" \
      -o /out/api ./cmd/api

# ────────────────────────── Stage 2: runtime ──────────────────────────
FROM gcr.io/distroless/static-debian12:nonroot AS runtime
# distroless/static = CA certs + tzdata + /etc/passwd, no shell, no package manager.
# Use :nonroot (uid 65532) so the container never runs as root.

COPY --from=builder /out/api /api
COPY --from=builder /src/migrations /migrations     # only if the binary applies them

USER nonroot:nonroot
EXPOSE 8080
ENV GOMEMLIMIT=450MiB GOMAXPROCS=2                  # match the pod's limits (see Β§7.2)

ENTRYPOINT ["/api"]
Enter fullscreen mode Exit fullscreen mode

Why each decision:

Decision Reason
CGO_ENABLED=0 Removes the libc dependency, so the binary runs on scratch/distroless. If you need cgo (SQLite, some crypto), build on and ship to a matching glibc base instead.
distroless/static, not alpine or ubuntu No shell, no package manager, no CVE churn from utilities you never use. Final image β‰ˆ your binary + 2 MB. scratch is even smaller but lacks CA certs and tzdata, which any HTTPS client needs.
:nonroot tag Runs as uid 65532 with no writable filesystem β€” satisfies runAsNonRoot policies out of the box.
Deps before source Same caching logic as everywhere: go mod download is reused until go.sum changes.
BuildKit cache mounts Keeps the module and build caches between builds without baking them into layers.
-trimpath + -ldflags="-s -w" Reproducible and ~25% smaller; strip only after you've decided you don't need symbols in prod profiles.
-X main.version=… The binary can report its own build; invaluable when three replicas disagree.
No HEALTHCHECK Distroless has no shell or curl. Let Kubernetes do an HTTP probe against /healthz; a Docker-level healthcheck would force you to ship a fatter image.
GOMEMLIMIT / GOMAXPROCS The Go runtime doesn't see cgroup limits before Go 1.25 β€” set them explicitly to the pod's limits (Β§7.1–§7.2).
ENTRYPOINT in exec form Your binary is PID 1 and receives SIGTERM directly β€” which is exactly what srv.Shutdown needs (Β§8.1).
# Need HTTPS + timezones on scratch? Copy them in rather than adding a base OS:
# COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
Enter fullscreen mode Exit fullscreen mode
DOCKER_BUILDKIT=1 docker build \
  --platform linux/amd64 \
  --build-arg VERSION=1.4.2 --build-arg COMMIT=$(git rev-parse --short HEAD) \
  -t agent-api:1.4.2 .

docker run --rm -p 8080:8080 --env-file .env --read-only --cap-drop=ALL agent-api:1.4.2
docker images agent-api:1.4.2          # expect ~15–30 MB total
Enter fullscreen mode Exit fullscreen mode

.dockerignore:

.git/
bin/
tmp/
*_test.go
testdata/
.env
Dockerfile
Enter fullscreen mode Exit fullscreen mode

Pre-ship checklist: image under ~30 MB Β· docker run … --read-only works Β· SIGTERM drains in-flight requests within the grace period Β· no secrets in docker history Β· govulncheck clean Β· --platform linux/amd64 when building on Apple silicon for x86 nodes.

🎯 Actionable rules

  1. Put everything in internal/ unless another repo must import it.
  2. go mod tidy, gofmt, go vet, golangci-lint, govulncheck β€” all in CI.
  3. Validate config in main and exit non-zero on anything missing.
  4. Ship a distroless static binary and set GOMEMLIMIT/GOMAXPROCS to the pod's limits.

12. 🐞 Debugging & Profiling

12.1 Delve and VS Code

// .vscode/launch.json
{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Run API",
      "type": "go",
      "request": "launch",
      "mode": "debug",
      "program": "${workspaceFolder}/cmd/api",
      "env": { "DATABASE_URL": "postgres://dev:dev@localhost:5432/app", "LOG_LEVEL": "debug" },
      "args": ["--verbose"]
    },
    {
      "name": "Debug current test",
      "type": "go",
      "request": "launch",
      "mode": "test",
      "program": "${fileDirname}",
      "args": ["-test.run", "TestAgentUsesCalculator", "-test.v"],
      "buildFlags": "-race"
    },
    {
      "name": "Attach to container (dlv)",
      "type": "go",
      "request": "attach",
      "mode": "remote",
      "port": 2345,
      "host": "127.0.0.1",
      "substitutePath": [{ "from": "${workspaceFolder}", "to": "/src" }]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode
# In the container, for the attach config:
dlv exec --headless --listen=:2345 --api-version=2 --accept-multiclient /api
# Build with debug info: go build -gcflags="all=-N -l"   (disables inlining/optimization)
Enter fullscreen mode Exit fullscreen mode
// .vscode/settings.json
{
  "go.useLanguageServer": true,
  "go.lintTool": "golangci-lint",
  "go.lintOnSave": "package",
  "go.testFlags": ["-race", "-count=1"],
  "gopls": { "ui.semanticTokens": true, "staticcheck": true }
}
Enter fullscreen mode Exit fullscreen mode

Breakpoint techniques that matter: conditional breakpoints (docID == "doc-9182") to catch iteration 4000 of a loop; logpoints for tracing without a rebuild; and the Goroutines panel, which is Go-specific and invaluable β€” it shows every live goroutine with its stack, so a leak or a deadlock is visible directly. The Debug Console evaluates expressions and lets you change variables to force an error branch.

Delve on the command line, when you're on a server:

dlv debug ./cmd/api
(dlv) break service.(*Agent).Run
(dlv) condition 1 prompt == "calculate 10 * 5"
(dlv) continue ; locals ; goroutines ; stack ; print cfg
Enter fullscreen mode Exit fullscreen mode

12.2 pprof β€” the reason Go debugging is pleasant

import _ "net/http/pprof"       // registers /debug/pprof/* on the DefaultServeMux

go func() {
    // βœ… bind to localhost or an admin port β€” never expose pprof publicly
    slog.Error("pprof", "err", http.ListenAndServe("127.0.0.1:6060", nil))
}()
Enter fullscreen mode Exit fullscreen mode
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30   # CPU
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/heap                 # memory
go tool pprof http://localhost:6060/debug/pprof/allocs                           # all allocations
curl "http://localhost:6060/debug/pprof/goroutine?debug=2"   # every goroutine's stack ← leaks
curl "http://localhost:6060/debug/pprof/block"               # blocking (needs SetBlockProfileRate)
curl "http://localhost:6060/debug/pprof/mutex"               # contention (needs SetMutexProfileFraction)
Enter fullscreen mode Exit fullscreen mode

-http=:8081 opens an interactive flame graph in the browser. The workflow for the three problems you'll actually hit:

Symptom Profile What you're looking for
High CPU profile?seconds=30 The widest frame in the flame graph
Memory grows without bound heap + goroutine?debug=2 A goroutine count that only rises = a leak
Latency spikes at steady CPU block, mutex, gctrace=1 Lock contention or GC pressure

The execution tracer shows scheduling, GC, and syscalls on a timeline:

curl -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"
go tool trace trace.out
Enter fullscreen mode Exit fullscreen mode

12.3 Runtime switches worth knowing

GODEBUG=gctrace=1 ./api            # one line per GC: heap, pause, CPU share
GODEBUG=schedtrace=1000 ./api      # scheduler state every second
GODEBUG=inittrace=1 ./api          # slow package init
GOTRACEBACK=all ./api              # dump ALL goroutine stacks on a fatal panic
go build -gcflags='-m' ./...       # escape analysis decisions
go test -race ./...                # data races
go tool nm -size bin/api | sort -k2 -n | tail   # what's making the binary big
Enter fullscreen mode Exit fullscreen mode

kill -QUIT <pid> on a hung Go process dumps every goroutine's stack to stderr β€” the Go equivalent of py-spy dump, and it's built in.

🎯 Actionable rules

  1. Ship net/http/pprof on a private port in every service; you cannot profile what isn't instrumented.
  2. Rising goroutine count = a leak. Check it before you check memory.
  3. Use the Goroutines panel / goroutine?debug=2 for deadlocks and leaks β€” stacks tell you exactly who's blocked on what.
  4. GOTRACEBACK=all and kill -QUIT for production hangs.

13. πŸ›οΈ Patterns That Earn Their Keep

13.1 Functional options

What: variadic Option functions that configure a constructor. Why: Go has no default or keyword arguments, so a growing config would otherwise mean a growing parameter list or a mutable public struct.

type Option func(*Client)

func WithTimeout(d time.Duration) Option   { return func(c *Client) { c.timeout = d } }
func WithRetries(n int) Option             { return func(c *Client) { c.retries = n } }
func WithLogger(l *slog.Logger) Option     { return func(c *Client) { c.log = l } }

func NewClient(baseURL string, opts ...Option) (*Client, error) {
    c := &Client{baseURL: baseURL, timeout: 30 * time.Second, retries: 3, log: slog.Default()}
    for _, opt := range opts {
        opt(c)
    }
    if c.baseURL == "" {
        return nil, errors.New("client: baseURL is required")
    }
    return c, nil
}

c, err := NewClient(url, WithTimeout(90*time.Second), WithRetries(5))
Enter fullscreen mode Exit fullscreen mode

Required arguments stay positional; optional ones are named and additive. Adding an option never breaks an existing caller.

13.2 Middleware β€” decorators for http.Handler

What: func(http.Handler) http.Handler. Why: logging, auth, tenancy, tracing, and rate limits belong around handlers, not inside them.

func Logging(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        ww := &statusWriter{ResponseWriter: w, status: http.StatusOK}
        next.ServeHTTP(ww, r)
        slog.Info("request",
            "method", r.Method, "path", r.URL.Path,
            "status", ww.status, "ms", time.Since(start).Milliseconds())
    })
}

func Tenant(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        t := r.Header.Get("X-Tenant")
        if t == "" {
            http.Error(w, "missing tenant", http.StatusUnauthorized); return
        }
        next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tenantKey, t)))
    })
}

handler := Recoverer(RequestID(Logging(Tenant(mux))))   // or r.Use(...) with chi
Enter fullscreen mode Exit fullscreen mode

The same shape works for any interface: wrap it, keep the type, add behaviour.

13.3 Constructor injection, wired in main

What: every dependency arrives through a constructor; main is the only place that knows the concrete types. Why: it's Go's whole DI story β€” no framework, no reflection, no runtime surprises. This repo's convention (CLAUDE.md): "dependency injection via constructor functions β€” no global state, no init()."

func main() {
    cfg, err := config.Load()
    if err != nil { fatal(err) }

    db, err := sqlx.Connect("pgx", cfg.DatabaseURL)
    if err != nil { fatal(err) }
    defer db.Close()

    var (
        docs  = repo.NewDocs(db)                       // concrete
        llm   = llmclient.New(cfg.APIKey)              // concrete
        svc   = service.NewAgent(docs, llm)            // takes interfaces
        h     = handler.New(svc)                       // takes an interface
    )
    …
}
Enter fullscreen mode Exit fullscreen mode

Read main top to bottom and you know the entire architecture. Every layer is testable because every layer takes interfaces it doesn't construct.

13.4 A reusable, generic worker pool

What: bounded parallel map, order preserved. Why: you'll write this loop in every AI service β€” embed, rerank, enrich, fan out to tools.

// ParallelMap applies f to every element with at most n concurrent calls.
// Results keep the input order; the first error cancels the rest.
func ParallelMap[T, U any](ctx context.Context, in []T, n int, f func(context.Context, T) (U, error)) ([]U, error) {
    out := make([]U, len(in))
    g, ctx := errgroup.WithContext(ctx)
    g.SetLimit(n)
    for i, v := range in {
        g.Go(func() error {
            u, err := f(ctx, v)
            if err != nil {
                return fmt.Errorf("item %d: %w", i, err)
            }
            out[i] = u                 // distinct index per goroutine β†’ no lock needed
            return nil
        })
    }
    if err := g.Wait(); err != nil {
        return nil, err
    }
    return out, nil
}

vecs, err := ParallelMap(ctx, chunks, 8, embedOne)
Enter fullscreen mode Exit fullscreen mode

13.5 Graceful lifecycle

What: start dependencies, block on a signal, shut down in reverse. Why: rolling deploys happen constantly; dropping in-flight streams on every deploy is a self-inflicted SLO breach.

func run(ctx context.Context, cfg config.Config) error {
    ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
    defer stop()

    srv := newServer(cfg)
    errc := make(chan error, 1)
    go func() { errc <- srv.ListenAndServe() }()

    select {
    case err := <-errc:
        if !errors.Is(err, http.ErrServerClosed) { return err }
    case <-ctx.Done():
        slog.Info("shutting down")
    }

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    return srv.Shutdown(shutdownCtx)     // stop accepting, drain in-flight
}

func main() {
    if err := run(context.Background(), cfg); err != nil {
        slog.Error("fatal", "err", err); os.Exit(1)
    }
}
Enter fullscreen mode Exit fullscreen mode

Putting the body in run(ctx) error β€” with main only handling the exit code β€” makes the whole startup path testable.

13.6 Domain types instead of bare strings

What: type TenantID string, type Role string. Why: the compiler stops you passing a user ID where a tenant ID belongs, at zero runtime cost.

type (
    TenantID string
    DocID    string
)
func (r *Repo) Search(ctx context.Context, t TenantID, q string) ([]Doc, error) { … }

r.Search(ctx, TenantID(hdr), q)     // βœ… explicit conversion at the boundary
r.Search(ctx, userID, q)            // ❌ compile error β€” exactly what you want
Enter fullscreen mode Exit fullscreen mode

13.7 Putting it together

func (a *Agent) Run(ctx context.Context, userInput string) (AgentResponse, error) {
    a.AddMessage(RoleUser, userInput)

    var reply string
    switch lower := strings.ToLower(userInput); {
    case strings.Contains(lower, "calculate"):
        expr := strings.TrimSpace(strings.SplitN(lower, "calculate", 2)[1])
        res, err := a.tools.Dispatch(ctx, "calculator", mustArgs("expression", expr))
        if err != nil {
            return AgentResponse{}, fmt.Errorf("agent.Run: %w", err)
        }
        a.results = append(a.results, res)
        reply = "Result: " + res.Output

    case strings.Contains(lower, "count"):
        res, err := a.tools.Dispatch(ctx, "word_count", mustArgs("text", userInput))
        if err != nil {
            return AgentResponse{}, fmt.Errorf("agent.Run: %w", err)
        }
        a.results = append(a.results, res)
        top := make([]string, 0, 3)
        for _, kv := range topN(res.Counts, 3) {
            top = append(top, fmt.Sprintf("%s=%d", kv.Key, kv.Count))
        }
        reply = "Top words: " + strings.Join(top, ", ")

    default:
        reply = fmt.Sprintf("Echo [%s]: %s", a.cfg.Name, userInput)
    }

    a.AddMessage(RoleAssistant, reply)
    return AgentResponse{
        Messages:    a.History(),
        ToolResults: a.results,
        TotalSteps:  1,
        Status:      StatusOK,
    }, nil
}
Enter fullscreen mode Exit fullscreen mode

Everything in one method: ctx first, typed Role constants, switch with an init statement, error wrapping at every boundary, preallocated slices, and a struct return instead of a tuple.

🎯 Actionable rules

  1. Functional options for anything with more than two optional settings.
  2. Wire concrete types in main; pass interfaces everywhere else.
  3. Middleware for cross-cutting concerns; defer for resources.
  4. Named domain types for identifiers β€” free compile-time safety.

14. βš–οΈ Good vs Bad, Side by Side

Twenty-two rewrites you can apply in your next code review.

1. Never discard an error silently

// ❌ the failure vanishes; the zero value flows onward
data, _ := json.Marshal(payload)
Enter fullscreen mode Exit fullscreen mode
// βœ… handle it, or say in writing why it can't happen
data, err := json.Marshal(payload)
if err != nil {
    return fmt.Errorf("handler.Query: marshal response: %w", err)
}
Enter fullscreen mode Exit fullscreen mode

2. Add context when you propagate

// ❌ "sql: no rows in result set" β€” from where? which id? which layer?
if err != nil { return err }
Enter fullscreen mode Exit fullscreen mode
// βœ… the chain reads like a stack trace you designed
if err != nil { return fmt.Errorf("repo.GetDoc(%s): %w", id, err) }
Enter fullscreen mode Exit fullscreen mode

3. Keep the happy path at the left margin

// ❌ the success case is buried three levels deep
if resp != nil {
    if resp.StatusCode == 200 {
        if body, err := io.ReadAll(resp.Body); err == nil {
            return parse(body)
        }
    }
}
return nil, errors.New("failed")
Enter fullscreen mode Exit fullscreen mode
// βœ… fail fast, one indent level, every error distinguishable
if resp.StatusCode != http.StatusOK {
    return nil, fmt.Errorf("llm.Complete: status %d", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxBody))
if err != nil {
    return nil, fmt.Errorf("llm.Complete: read body: %w", err)
}
return parse(body)
Enter fullscreen mode Exit fullscreen mode

4. Close what you open, immediately after the error check

// ❌ leaks a connection on every error path β€” and exhausts the pool under load
resp, err := client.Do(req)
if err != nil { return err }
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
Enter fullscreen mode Exit fullscreen mode
// βœ…
resp, err := client.Do(req)
if err != nil { return fmt.Errorf("fetch: %w", err) }
defer resp.Body.Close()
Enter fullscreen mode Exit fullscreen mode

5. One client, with timeouts

// ❌ new pool per call, and no timeout: a hung upstream hangs you forever
resp, err := (&http.Client{}).Get(url)
Enter fullscreen mode Exit fullscreen mode
// βœ… package-level, pooled, bounded (see Β§8.2)
var client = &http.Client{Timeout: 60 * time.Second, Transport: tunedTransport}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
Enter fullscreen mode Exit fullscreen mode

6. Preallocate when you know the size

// ❌ ~log(n) reallocations and copies
var out []Doc
for _, id := range ids { out = append(out, load(id)) }
Enter fullscreen mode Exit fullscreen mode
// βœ… one allocation
out := make([]Doc, 0, len(ids))
for _, id := range ids { out = append(out, load(id)) }
Enter fullscreen mode Exit fullscreen mode

7. Build strings with a Builder

// ❌ O(n²): every += copies the whole prompt
prompt := ""
for _, m := range history { prompt += m.Role + ": " + m.Content + "\n" }
Enter fullscreen mode Exit fullscreen mode
// βœ… O(n)
var b strings.Builder
for _, m := range history {
    fmt.Fprintf(&b, "%s: %s\n", m.Role, m.Content)
}
prompt := b.String()
Enter fullscreen mode Exit fullscreen mode

8. Bound your fan-out

// ❌ 10 000 goroutines, 10 000 sockets, instant 429s, no error handling
for _, id := range ids {
    go fetch(id)
}
Enter fullscreen mode Exit fullscreen mode
// βœ… at most 8 in flight, first error cancels the rest
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, id := range ids {
    g.Go(func() error { return fetch(ctx, id) })
}
if err := g.Wait(); err != nil { return fmt.Errorf("service.LoadAll: %w", err) }
Enter fullscreen mode Exit fullscreen mode

9. Every goroutine needs an exit

// ❌ if nobody ever receives, this goroutine (and its captures) leaks forever
go func() { results <- expensive() }()
Enter fullscreen mode Exit fullscreen mode
// βœ… cancellation always wins
go func() {
    select {
    case results <- expensive():
    case <-ctx.Done():
    }
}()
Enter fullscreen mode Exit fullscreen mode

10. Propagate the caller's context

// ❌ the client hung up 20 seconds ago; you're still paying for tokens
func (s *Service) Answer(q string) (string, error) {
    return s.llm.Complete(context.Background(), q)
}
Enter fullscreen mode Exit fullscreen mode
// βœ… ctx first, always β€” cancellation flows all the way down
func (s *Service) Answer(ctx context.Context, q string) (string, error) {
    return s.llm.Complete(ctx, q)
}
Enter fullscreen mode Exit fullscreen mode

11. Use comma-ok when absent β‰  zero

// ❌ a missing tool and a tool with score 0 are indistinguishable
if scores[name] == 0 { skip() }
Enter fullscreen mode Exit fullscreen mode
// βœ…
score, ok := scores[name]
if !ok { return fmt.Errorf("unknown tool %q", name) }
Enter fullscreen mode Exit fullscreen mode

12. Small interfaces, defined by the consumer

// ❌ a 9-method interface exported by the implementer β€” impossible to fake in a test
package llm
type Provider interface {
    Complete(...); Stream(...); Embed(...); Tokenize(...); Models(...); /* … */
}
Enter fullscreen mode Exit fullscreen mode
// βœ… each consumer declares the one or two methods it needs
package service
type Completer interface {
    Complete(ctx context.Context, prompt string) (string, error)
}
Enter fullscreen mode Exit fullscreen mode

13. Don't copy a struct that contains a mutex

// ❌ `go vet` error: passes a copy of the lock; the copy protects nothing
func (c Cache) Get(k string) string { c.mu.RLock(); … }
Enter fullscreen mode Exit fullscreen mode
// βœ… pointer receiver, consistently across all methods
func (c *Cache) Get(k string) string { c.mu.RLock(); defer c.mu.RUnlock(); … }
Enter fullscreen mode Exit fullscreen mode

14. Guard shared maps

// ❌ "fatal error: concurrent map writes" β€” unrecoverable, takes the process down
var cache = map[string][]float32{}
go func() { cache[k] = v }()
Enter fullscreen mode Exit fullscreen mode
// βœ… mutex next to the data it protects (or a channel-owned goroutine)
type Cache struct {
    mu sync.RWMutex
    m  map[string][]float32
}
Enter fullscreen mode Exit fullscreen mode

15. errors.Is, not ==

// ❌ breaks the moment any layer wraps the error
if err == sql.ErrNoRows { return NotFound }
Enter fullscreen mode Exit fullscreen mode
// βœ… traverses the whole wrap chain
if errors.Is(err, sql.ErrNoRows) { return NotFound }
Enter fullscreen mode Exit fullscreen mode

16. Never return a typed nil as an error

// ❌ err != nil is TRUE even when everything succeeded
func do() error {
    var e *ToolError          // nil pointer…
    return e                  // …wrapped in a non-nil interface
}
Enter fullscreen mode Exit fullscreen mode
// βœ… return the literal nil
func do() error {
    var e *ToolError
    if failed { e = &ToolError{…}; return e }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

17. Don't retain a slice of a huge buffer

// ❌ keeps the entire 50 MB document alive for a 100-byte snippet
snippet := bigDoc[:100]
cache.Put(id, snippet)
Enter fullscreen mode Exit fullscreen mode
// βœ… copy out what you keep
cache.Put(id, slices.Clone(bigDoc[:100]))
Enter fullscreen mode Exit fullscreen mode

18. Decode streams; reject unknown fields

// ❌ buffers the whole body, silently ignores typo'd client fields
b, _ := io.ReadAll(r.Body)
json.Unmarshal(b, &in)
Enter fullscreen mode Exit fullscreen mode
// βœ… streaming, bounded, strict
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&in); err != nil {
    http.Error(w, "invalid body", http.StatusBadRequest); return
}
Enter fullscreen mode Exit fullscreen mode

19. Decode into structs, not map[string]any

// ❌ every number becomes float64; every access is an unchecked assertion
var m map[string]any
json.Unmarshal(b, &m)
n := int(m["max_tokens"].(float64))     // panics on any surprise
Enter fullscreen mode Exit fullscreen mode
// βœ… the shape is documented, validated, and autocompleted
var in struct {
    MaxTokens int    `json:"max_tokens"`
    Model     string `json:"model"`
}
Enter fullscreen mode Exit fullscreen mode

20. Panic is not error handling

// ❌ one malformed model response kills the process
func mustParse(b []byte) Call {
    var c Call
    if err := json.Unmarshal(b, &c); err != nil { panic(err) }
    return c
}
Enter fullscreen mode Exit fullscreen mode
// βœ… expected failures are values
func parseCall(b []byte) (Call, error) {
    var c Call
    if err := json.Unmarshal(b, &c); err != nil {
        return Call{}, fmt.Errorf("agent.parseCall: %w", err)
    }
    return c, nil
}
Enter fullscreen mode Exit fullscreen mode

21. Log once, at the boundary

// ❌ the same failure appears five times in the logs, at five layers
if err != nil {
    slog.Error("query failed", "err", err)
    return err
}
Enter fullscreen mode Exit fullscreen mode
// βœ… lower layers add context; only the handler logs
if err != nil { return fmt.Errorf("repo.GetDoc: %w", err) }   // repo
…
if err != nil {                                                // handler
    slog.Error("query failed", "err", err, "path", r.URL.Path)
    http.Error(w, "internal error", http.StatusInternalServerError)
}
Enter fullscreen mode Exit fullscreen mode

22. Name your durations and limits

// ❌ what is 30? seconds? retries? tokens?
ctx, cancel := context.WithTimeout(ctx, 30)     // 30 NANOSECONDS β€” instant timeout
Enter fullscreen mode Exit fullscreen mode
// βœ… typed durations and named constants make the unit impossible to get wrong
const toolTimeout = 30 * time.Second
ctx, cancel := context.WithTimeout(ctx, toolTimeout)
defer cancel()
Enter fullscreen mode Exit fullscreen mode

15. ⚠️ Anti-Patterns and Misconceptions

15.1 Misconceptions that cost real hours

Belief Reality
"Goroutines are free" Cheap, not free. Unbounded goroutines = unbounded memory, sockets, and downstream load.
"Channels are the answer to everything" A mutex around a map is simpler and faster. Channels are for transferring ownership, not for protecting state.
"Buffered channels prevent blocking" They delay it. A full buffer blocks exactly like an unbuffered one β€” the buffer just hides the backpressure until it's worse.
"close(ch) stops the consumer" It signals no more values. To stop work, cancel the context.
"Go has no memory leaks; there's a GC" Goroutine leaks, retained slice backing arrays, and unstopped tickers are all leaks the GC can't help with.
"err != nil everywhere is boilerplate" It's the feature. Every failure path is visible and testable β€” the reason Go services behave predictably.
"Empty interface = Python's dynamic typing" any costs you every compile-time guarantee, plus an allocation. Use concrete types.
"Go is slow at JSON / it needs a framework" encoding/json handles most loads; net/http is a production HTTP/2 server. Reach for libraries after profiling.
"sync.Map is a faster map" It's slower for most workloads. It exists for two specific access patterns.
"GOMAXPROCS handles containers" Only from Go 1.25. Before that, set it from the cgroup quota or your 500m pod spawns dozens of Ps.
"The GC keeps me under the memory limit" Not without GOMEMLIMIT. Otherwise the heap grows past the cgroup limit and the OOM killer wins.
"Generics replace interfaces" Different tools. Interfaces for polymorphism, generics for eliminating duplicate code over types.
"A panic in a goroutine is caught by my middleware" recover is per-goroutine. An unrecovered panic anywhere kills the process.
"Interfaces should be defined next to the implementation" Java habit. In Go the consumer declares what it needs.

15.2 Anti-patterns, with the fix

1. interface{}/any in your own APIs. It pushes type errors to runtime and allocates. β†’ Concrete types, or generics if you truly need several.

2. Package utils/common/helpers. It becomes a dependency magnet and an import-cycle factory. β†’ Name packages for what they provide: tokens, retry, chunk.

3. Stuttering names. service.ServiceAgent, model.ModelMessage. β†’ The package qualifies the name: service.Agent.

4. Storing context.Context in a struct. It outlives the request and cancellation stops matching reality. β†’ Pass it as the first parameter, every time.

5. Global mutable state. var db *sql.DB at package scope makes tests order-dependent and races invisible. β†’ Constructor injection (Β§13.3).

6. Giant interfaces / interfaces with one implementation. Premature abstraction with a compile-time cost. β†’ Write the concrete type; extract an interface at the consumer when a second implementation (or a test fake) appears.

7. defer inside a loop. Resources accumulate until the function returns (Β§3.4). β†’ Wrap the body in a function.

8. Ignoring rows.Err() / scanner.Err(). for rows.Next() ending doesn't mean success β€” it may have failed mid-iteration. β†’ Check the error after the loop, always.

9. Unbounded append on request data. An unbounded history slice or in-memory result buffer is an OOM on a slow day. β†’ Cap it: window the history, stream the results.

10. Time-based tests. time.Sleep(100*time.Millisecond) to "wait for the goroutine" is flaky by construction. β†’ Synchronize with a channel or WaitGroup; inject a clock.

11. Reinventing errgroup, singleflight, or rate. These are hard to get right and already exist in golang.org/x/....

12. log.Fatal outside main. It calls os.Exit, skipping every defer β€” no flush, no shutdown, no cleanup. β†’ Return an error; let main decide.

13. Struct literals without field names. AgentConfig{"a", "b", 0.7} silently breaks when a field is inserted. β†’ Always Field: value.

14. Exporting everything. Every exported identifier is API you must keep working. β†’ Start lowercase; export on demand.

15. Swallowing ctx.Err(). Treating cancellation as a generic failure produces 500s for clients that simply disconnected. β†’ Check errors.Is(err, context.Canceled) and return early without logging noise.


16. πŸ—ΊοΈ The 30-Day Path to Pro

Days Focus Ship this
1–3 Β§1–§2: syntax, slices, maps, structs A CLI that chunks a file and prints word stats
4–6 Β§3–§4: functions, defer, methods, interfaces A Tool interface with two implementations and a registry
7–9 Β§5: errors, wrapping, Is/As A typed error hierarchy with sentinels and errors.As handling
10–14 Β§6: goroutines, channels, context A bounded worker pool that embeds 10k chunks and cancels cleanly
15–17 Β§8: net/http, json, slog A JSON API with timeouts, middleware, and graceful shutdown
18–20 Β§9: streaming, retries, limits An SSE endpoint proxying a real model with backpressure
21–23 Β§10: tests, fakes, benchmarks Table-driven tests + httptest + a fuzz target, all -race clean
24–26 Β§7, Β§12: runtime and profiling Profile it, cut allocations 50%, write down what you learned
27–30 Β§11, Β§13–§15 Distroless image, CI with lint+race, refactor against Β§14

The one-page cheat sheet

// Declarations
x := 5                              // infer          var x int  // zero value 0
m := make(map[string]int, 100)      // βœ… never a nil map you write to
s := make([]T, 0, n)                // preallocate
v, ok := m[k]                       // comma-ok
s = append(s, xs...)                // always reassign

// Errors
if err != nil { return fmt.Errorf("pkg.Func: %w", err) }
errors.Is(err, ErrNotFound) Β· errors.As(err, &myErr) Β· errors.Join(errs...)
defer resp.Body.Close()             // right after the error check

// Concurrency
g, ctx := errgroup.WithContext(ctx); g.SetLimit(8); g.Go(func() error { … }); g.Wait()
select { case v := <-ch: … case <-ctx.Done(): return ctx.Err() }
var mu sync.RWMutex; mu.RLock(); defer mu.RUnlock()
ctx, cancel := context.WithTimeout(ctx, 30*time.Second); defer cancel()

// Interfaces
type Completer interface { Complete(context.Context, string) (string, error) }
var _ Completer = (*Client)(nil)    // compile-time check
switch x := v.(type) { case string: … }

// Format
%v %+v %#v %q %T %w %.2f %d

// Commands
go test -race ./... Β· go test -bench=. -benchmem Β· go test -fuzz=Fuzz
go vet ./... Β· golangci-lint run Β· govulncheck ./... Β· go mod tidy
go tool pprof -http=:8081 http://localhost:6060/debug/pprof/profile?seconds=30
curl localhost:6060/debug/pprof/goroutine?debug=2      # leak hunting
GODEBUG=gctrace=1 ./api Β· GOMEMLIMIT=450MiB Β· kill -QUIT <pid>
Enter fullscreen mode Exit fullscreen mode

The ten habits that separate pro from proficient

  1. Handle every error where it happens, wrapped with pkg.Func: context; log once at the top.
  2. Every goroutine has a known exit path, and every blocking select has <-ctx.Done().
  3. Bound everything: concurrency, retries, timeouts, buffer sizes, history length.
  4. ctx is the first parameter of anything that does I/O β€” and it comes from the caller.
  5. Small interfaces, declared by the consumer; concrete types returned.
  6. Wire dependencies in main; no globals, no init().
  7. -race in CI, pprof in production. Both cost almost nothing and save entire weekends.
  8. Make the zero value useful, and prefer values to pointers until a profile says otherwise.
  9. Set GOMEMLIMIT and GOMAXPROCS to the container's real limits.
  10. Write the boring version. Go rewards code that reads like it was written by someone who expected to be woken at 3 a.m. by it.

Where to go next: 🐍 Python for AI Developers for the other half of the stack, πŸ“˜ The Complete Guide to LLMs and AI Agents πŸ€–
to understand modern AI deeply, ⚠️ Common Issues πŸͺ² with LLMs & AI Agents β€” and How to Fix Them πŸ› οΈ, πŸ—οΈ Building High-Quality AI Agents for the agent architecture on top of this foundation, πŸ”„ The Agentic Loop Guide for the control loop itself, and 🏒 Enterprise-Ready AI Agents for multi-tenancy, security, and scale, and πŸ› οΈ The Senior Software Engineer Playbook πŸ“–.

Go gives you fewer ways to write it, so there are fewer ways to get it wrong. Learn error, interface, defer, context, and the scheduler β€” the rest of the language fits on one page, which was always the point.


If you found this helpful, let me know by leaving a πŸ‘ or a comment!, or if you think this post could help someone, feel free to share it! Thank you very much! πŸ˜ƒ

Top comments (0)