| name | bob-internal-go-coding |
| description | Go coding guidelines for production-quality code — pool lifetimes, concurrency safety, numeric type boundaries, error handling, and test discipline |
| user-invocable | false |
| category | reference |
Go Coding Guidelines
These guidelines address patterns that appear in code review again and again. Read this before writing Go code that touches pooled resources, concurrent I/O, or external data sources.
Also injected into fix cycles via .bob/state/fix-prompt.md so workflow-coder follows these rules during every repair iteration.
1. Pool and Resource Lifetime
The rule: Return a pooled object to the pool only when ALL data derived from it has been consumed.
❌ Wrong — released while derived data is still live
buf := pool.Get().(*[]byte)
results := process(buf)
pool.Put(buf)
return results
✅ Right — caller owns the release
buf := pool.Get().(*[]byte)
results := process(buf)
return results, buf
Ask before every pool.Put / Release call:
- Does this function return anything derived from the pooled object?
- Can any caller still read from that returned data?
- If yes: don't release here — pass ownership to the caller.
Typed-nil guard: Any release function that accepts an interface must guard against typed-nil — a (*T)(nil) passes a type assertion but panics on dereference:
func ReleaseWidget(w Widget) {
if c, ok := w.(*concreteWidget); ok && c != nil {
widgetPool.Put(c)
}
}
Pool + testing.AllocsPerRun: sync.Pool drops entries at GC, which AllocsPerRun triggers. Don't assert exactly 0 allocations on pooled code paths — accept ≤1, or warm the pool inside the measurement closure.
2. Concurrency Safety
File writes: use unique temp paths
tmp := dest + ".tmp"
os.WriteFile(tmp, data, 0o600)
os.Rename(tmp, dest)
f, err := os.CreateTemp(filepath.Dir(dest), filepath.Base(dest)+".tmp-*")
if err != nil { return err }
tmp := f.Name()
if _, err := f.Write(data); err != nil {
f.Close()
os.Remove(tmp)
return err
}
f.Close()
return os.Rename(tmp, dest)
Eviction / index update: keep lock held across file removal, or use unique names
mu.Lock()
delete(index, key)
mu.Unlock()
os.Remove(path)
mu.Lock()
delete(index, key)
os.Remove(path)
mu.Unlock()
Duplicate writes: use singleflight
if _, ok := index[key]; !ok {
write(key, value)
}
var sf singleflight.Group
sf.Do(key, func() (any, error) {
return nil, writeAndIndex(key, value)
})
Goroutine fan-out: always cap concurrency
for _, item := range items {
go func(item Item) { process(item) }(item)
}
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
item := item
g.Go(func() error { return process(ctx, item) })
}
if err := g.Wait(); err != nil { return err }
Early-stop: cancel the context, don't just return
ctx, cancel := context.WithCancel(parentCtx)
defer cancel()
for _, item := range items {
result, err := fetch(ctx, item)
if err != nil { return err }
if done(result) {
cancel()
break
}
}
Pre-fetch: pipeline, don't batch-all-then-process
all := fetchAll(items)
for _, r := range all { process(r) }
sem := make(chan struct{}, 4)
for _, item := range items {
if ctx.Err() != nil { break }
sem <- struct{}{}
go func(item Item) {
defer func() { <-sem }()
r := fetch(ctx, item)
process(r)
}(item)
}
3. Numeric Type Boundaries
Go's make, slice indices, and most standard library functions require int, not int64. Sizes from external sources (disk, network, protobuf) are often int64 or uint64 — always validate and convert explicitly.
size := computeSize()
buf := make([]byte, size)
chunk := buf[offset : offset+length]
if size < 0 || size > maxAllowed {
return fmt.Errorf("invalid size %d", size)
}
buf := make([]byte, int(size))
Sizes derived from external data (files, headers, wire format):
dataSize := totalSize - int64(headerLen)
if dataSize < 0 {
return fmt.Errorf("corrupt header: negative data size %d", dataSize)
}
if dataSize > maxDataSize {
return fmt.Errorf("data too large: %d bytes", dataSize)
}
Length fields from untrusted sources:
n := binary.LittleEndian.Uint32(buf[0:4])
if n == 0 || n > maxLen {
return fmt.Errorf("invalid length field %d", n)
}
data := make([]byte, int(n))
4. Error Handling
Distinguish "not found" from "broken"
data, err := readFromStore(key)
if err != nil {
return nil, false, nil
}
data, err := readFromStore(key)
if errors.Is(err, fs.ErrNotExist) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("store read %q: %w", key, err)
}
Validate untrusted input before use
r := io.LimitReader(src, maxBytes)
data, err := io.ReadAll(r)
if n > maxAllowed {
return fmt.Errorf("length %d exceeds maximum %d", n, maxAllowed)
}
Wrap errors with context
return err
return fmt.Errorf("load config from %s: %w", path, err)
Don't swallow errors with blank identifier
_ = file.Close()
if err := file.Close(); err != nil {
return fmt.Errorf("close %s: %w", file.Name(), err)
}
5. Test Discipline
Name tests to match what they actually assert
func TestProcessZeroAllocs(t *testing.T) {
allocs := testing.AllocsPerRun(100, fn)
assert.Less(t, allocs, 5.0)
}
func TestProcessNoMapAllocPerCall(t *testing.T) {
allocs := testing.AllocsPerRun(100, fn)
assert.Less(t, allocs, 2.0)
}
GC-safe tests for weak references and sync.Pool
func storeValue(c *Cache[Thing]) {
v := &Thing{id: 1}
c.Put("key", v)
}
func TestEvictedAfterGC(t *testing.T) {
c := NewCache[Thing]()
storeValue(c)
runtime.GC()
runtime.GC()
_, ok := c.Get("key")
assert.False(t, ok)
}
Keep strong references alive until after the assertion
v := &Thing{id: 1}
c.Put("key", v)
got, ok := c.Get("key")
require.True(t, ok)
assert.Equal(t, 1, got.id)
runtime.KeepAlive(v)
Concurrent tests must exercise actual concurrent access
c.Put("key", v)
var wg sync.WaitGroup
for range 10 {
wg.Add(1)
go func() { defer wg.Done(); c.Get("key") }()
}
var wg sync.WaitGroup
for i := range 10 {
wg.Add(1)
go func(i int) {
defer wg.Done()
c.Put(fmt.Sprintf("k%d", i%3), &Thing{id: i})
c.Get(fmt.Sprintf("k%d", i%3))
}(i)
}
wg.Wait()
Assert panic values, not just panic occurrence
require.Panics(t, func() { c.Put("k", nil) })
require.PanicsWithValue(t, "cache: value must be non-nil", func() {
c.Put("k", nil)
})
Quick Checklist (Before Every Commit)