Go - Design Guidelines

Concept: Go prioritizes code readability and maintainability by reducing code volume

Why it matters: Fewer lines = fewer bugs; every line has a probability of containing an error.

package main

import "fmt"

// fewer lines means fewer places for bugs to hide
func sum(nums []int) int {
	total := 0
	for _, n := range nums {
		total += n
	}
	return total
}

func main() { fmt.Println(sum([]int{1, 2, 3})) }

Gotcha: Adding abstraction to "feel cleaner" increases lines and hides cost.


Concept: Thin layers of abstraction.

Why it matters: Every abstraction layer adds indirection cost; Go keeps layers shallow and explicit.

package main

import "fmt"

// one thin layer: wraps a concern without hiding cost
type Multiplier struct{ factor int }

func (m Multiplier) Apply(n int) int { return n * m.factor }

func main() {
	double := Multiplier{factor: 2}
	fmt.Println(double.Apply(5)) // cost is obvious — no hidden alloc
}

Gotcha: Wrapping a wrapper to "future-proof" adds hidden cost with zero current benefit.


Concept: Mechanical Sympathy.

Why it matters: Code that mirrors hardware layout (cache lines, memory access) runs faster without algorithmic change.

package main

import "fmt"

// sequential access matches how CPU prefetcher loads cache lines
func sumSlice(data []int64) int64 {
	var total int64
	for _, v := range data { // stride-1 access — every element in cache line used
		total += v
	}
	return total
}

func main() {
	data := make([]int64, 1024)
	fmt.Println(sumSlice(data))
}

Gotcha: Using []interface{} instead of []int64 breaks cache-line locality — 8× slower.


Concept: Every programming decision comes with a cost.

Why it matters: Knowing the cost of a decision (allocation, indirection, GC pressure) prevents accidental performance problems.

package main

import "fmt"

// value return: stack allocation, zero GC cost
func newPointValue() [2]int { return [2]int{1, 2} }

// pointer return: heap allocation, GC must track it
func newPointPtr() *[2]int { return &[2]int{1, 2} }

func main() {
	v := newPointValue() // cheap — stays on stack
	p := newPointPtr()   // costs a heap alloc — only use when sharing is needed
	fmt.Println(v, p)
}

Gotcha: Reflexively returning pointers "for efficiency" actually adds GC overhead for small values.


Concept: Reading code is as important as writing it.

Why it matters: Code is read 10× more than written; optimizing for the reader reduces bugs and onboarding time.

package main

import "fmt"

// named return makes intent readable without a comment
func divide(a, b float64) (result float64, err error) {
	if b == 0 {
		err = fmt.Errorf("divide: zero divisor")
		return // naked return — only acceptable when function is very short
	}
	result = a / b
	return
}

func main() {
	r, err := divide(10, 2)
	fmt.Println(r, err)
}

Gotcha: Named returns in long functions cause confusion — the return value is non-obvious at the call site.


Concept: Mental models are essential for debugging and maintaining complex codebases.

Why it matters: A developer without a mental model spends hours re-reading code to understand state; good structure builds the model automatically.

package main

import "fmt"

// consistent type + single responsibility = easy mental model
type Account struct {
	id      int
	balance float64
}

func (a *Account) Deposit(amount float64) { a.balance += amount }
func (a *Account) Balance() float64       { return a.balance }

func main() {
	acc := Account{id: 1}
	acc.Deposit(100)
	fmt.Println(acc.Balance()) // reader instantly knows what state changed
}

Gotcha: Spreading state across multiple global variables destroys the mental model instantly.


Concept: As projects grow beyond 10,000 lines of code, maintaining a shared mental model within a team becomes increasingly important.

Why it matters: Past ~10k lines, no single developer holds the full model — the code structure must carry it.

package main

// package-level doc comment = shared mental model encoded in code
// Package inventory manages stock levels and reservation logic.
// It owns no network I/O; callers supply the persistence layer.
package main

import "fmt"

type Inventory struct{ items map[string]int }

func NewInventory() *Inventory { return &Inventory{items: make(map[string]int)} }
func (inv *Inventory) Add(sku string, qty int) { inv.items[sku] += qty }
func (inv *Inventory) Stock(sku string) int    { return inv.items[sku] }

func main() {
	inv := NewInventory()
	inv.Add("widget", 10)
	fmt.Println(inv.Stock("widget"))
}

Gotcha: Skipping package-level doc comments forces every reader to reconstruct the mental model from scratch.


Concept: Debuggers should be used as a last resort.

Why it matters: Relying on a debugger means your mental model failed; writing code you can reason about eliminates most debugging.

package main

import "fmt"

// instrument with fmt/log first — understand state without a debugger
func processOrder(id int, qty int) (total float64, err error) {
	fmt.Printf("processOrder: id=%d qty=%d\n", id, qty) // trace the state
	if qty <= 0 {
		return 0, fmt.Errorf("processOrder: invalid qty %d", qty)
	}
	total = float64(qty) * 9.99
	fmt.Printf("processOrder: total=%.2f\n", total)
	return total, nil
}

func main() {
	t, err := processOrder(42, 3)
	fmt.Println(t, err)
}

Gotcha: Removing trace logs after debugging means you'll add them back the next time — keep structured logging.


Concept: Productivity vs. Performance — Historically, the industry prioritized productivity over performance.

Why it matters: Understanding this history explains why Go was designed differently — it refuses to accept the false tradeoff.

package main

import (
	"fmt"
	"strings"
)

// Go standard library is both productive AND performant — strings.Builder example
func buildReport(lines []string) string {
	var b strings.Builder          // productive: easy to use
	b.Grow(len(lines) * 40)        // performant: single pre-allocation
	for _, l := range lines {
		b.WriteString(l)
		b.WriteByte('\n')
	}
	return b.String()
}

func main() {
	fmt.Print(buildReport([]string{"line1", "line2", "line3"}))
}

Gotcha: Using += on strings in a loop is productive to write but O(n²) to run — use Builder.


Concept: "Fast Enough" Philosophy.

Why it matters: Optimizing beyond requirements wastes engineering time and reduces readability with no user-visible benefit.

package main

import "fmt"

// fast enough: map lookup is O(1) amortized — no need for a custom hash table
func findUser(users map[int]string, id int) (string, bool) {
	name, ok := users[id]
	return name, ok
}

func main() {
	users := map[int]string{1: "Harish", 2: "Alice"}
	name, ok := findUser(users, 1)
	fmt.Println(name, ok)
}

Gotcha: Replacing a map with a sorted slice + binary search for "performance" before profiling proves the bottleneck.


Concept: Correctness — the primary focus should be on correctness, not performance.

Why it matters: Incorrect fast code is worse than correct slow code — you can't optimize what doesn't work.

package main

import "fmt"

// correct first: boundary conditions handled before any optimization
func safeDivide(a, b int) (int, bool) {
	if b == 0 {
		return 0, false // correctness: handle the edge case explicitly
	}
	return a / b, true
}

func main() {
	if r, ok := safeDivide(10, 0); ok {
		fmt.Println(r)
	} else {
		fmt.Println("cannot divide")
	}
}

Gotcha: Adding a fast-path optimization before writing the correct slow-path creates two bugs to fix later.


Concept: Correctness encompasses code integrity, readability, and simplicity.

Why it matters: These three properties together define whether code can be trusted, understood, and maintained.

package main

import "fmt"

// integrity: pure function — same input always produces same output
// readability: names explain intent without comments
// simplicity: does exactly one thing
func celsiusToFahrenheit(c float64) float64 {
	return c*9/5 + 32
}

func main() {
	fmt.Println(celsiusToFahrenheit(100)) // 212
	fmt.Println(celsiusToFahrenheit(0))   // 32
}

Gotcha: Merging two unrelated concerns into one function destroys all three properties simultaneously.

Concept: Refactoring is an essential part of the development process.

Why it matters: First drafts solve the problem; refactoring makes the solution understandable and maintainable long-term.

package main

import "fmt"

// before refactoring: everything inline, hard to test
func processV1(data []int) int {
	total := 0
	for _, v := range data {
		if v > 0 {
			total += v
		}
	}
	return total
}

// after refactoring: extracted predicate — each part independently testable
func isPositive(n int) bool { return n > 0 }

func processV2(data []int) int {
	total := 0
	for _, v := range data {
		if isPositive(v) {
			total += v
		}
	}
	return total
}

func main() { fmt.Println(processV2([]int{-1, 2, 3, -4, 5})) }

Gotcha: Refactoring without tests means you can't verify the behavior was preserved.


Concept: "Drafting" Code — iterate and not strive for perfection in the initial stages.

Why it matters: Drafting removes the cognitive block of writing "perfect" code; iteration always produces better results than one-shot perfection.

package main

import "fmt"

// draft 1: just make it work
func filterV1(nums []int, min int) []int {
	var out []int
	for _, n := range nums {
		if n >= min {
			out = append(out, n)
		}
	}
	return out
}

// draft 2: generalized after seeing repeated pattern
func filter(nums []int, keep func(int) bool) []int {
	var out []int
	for _, n := range nums {
		if keep(n) {
			out = append(out, n)
		}
	}
	return out
}

func main() {
	data := []int{1, 5, 2, 8, 3}
	fmt.Println(filterV1(data, 3))
	fmt.Println(filter(data, func(n int) bool { return n >= 3 }))
}

Gotcha: Never merging drafts back — leaving dead V1 code alongside V2 creates confusion.


Concept: Integrity — code reliability at both micro level (individual read/write operations) and macro level (data transformation accuracy).

Why it matters: A single corrupted read or write cascades into data loss or silent wrong results at the macro level.

package main

import (
	"fmt"
	"sync"
)

// micro integrity: every write to shared state is protected
type SafeCounter struct {
	mu    sync.Mutex
	value int
}

func (c *SafeCounter) Inc() {
	c.mu.Lock()
	c.value++ // atomic at the micro level — no torn write
	c.mu.Unlock()
}

func (c *SafeCounter) Get() int {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.value
}

func main() {
	c := SafeCounter{}
	var wg sync.WaitGroup
	for i := 0; i < 100; i++ {
		wg.Add(1)
		go func() { defer wg.Done(); c.Inc() }()
	}
	wg.Wait()
	fmt.Println(c.Get()) // always 100 — macro result is correct
}

Gotcha: Locking on write but not on read still produces a data race — both operations need the lock.


Concept: Writing less code reduces the potential for introducing bugs.

Why it matters: Each line of code has a nonzero probability of containing a bug; the only bug-free code is code that doesn't exist.

package main

import (
	"fmt"
	"sort"
)

// use the standard library instead of reimplementing — less code, fewer bugs
func topN(nums []int, n int) []int {
	sort.Sort(sort.Reverse(sort.IntSlice(nums))) // stdlib does the work
	if n > len(nums) {
		n = len(nums)
	}
	return nums[:n]
}

func main() {
	fmt.Println(topN([]int{3, 1, 4, 1, 5, 9, 2, 6}, 3)) // [9 6 5]
}

Gotcha: Re-implementing sorting/searching from scratch to "avoid dependencies" creates untested code paths.


Concept: Thorough error handling ensures the software can gracefully handle unexpected situations.

Why it matters: Unhandled errors turn recoverable situations into crashes or silent data corruption.

package main

import (
	"errors"
	"fmt"
)

var ErrInvalidInput = errors.New("invalid input")

// every error path explicitly handled — no silent failures
func sqrt(n float64) (float64, error) {
	if n < 0 {
		return 0, fmt.Errorf("sqrt: %w: got %v", ErrInvalidInput, n)
	}
	// Newton's method (simplified)
	x := n
	for i := 0; i < 10; i++ {
		x = (x + n/x) / 2
	}
	return x, nil
}

func main() {
	if r, err := sqrt(-1); err != nil {
		fmt.Println("error:", err)
		fmt.Println("is invalid input:", errors.Is(err, ErrInvalidInput))
	} else {
		fmt.Println(r)
	}
}

Gotcha: Using _ = someFunc() discards errors — the function may have partially mutated state.


Concept: Readability — the cost of code operations should be readily apparent (Transparency).

Why it matters: Hidden costs (allocations, locks, I/O) make performance unpredictable and debugging hard.

package main

import "fmt"

// cost is visible at the call site — caller knows what they're paying
func loadUsers() ([]string, error) { // name signals I/O + error possible
	return []string{"alice", "bob"}, nil // in production: DB call
}

func main() {
	// reader sees: this might fail, this allocates a slice
	users, err := loadUsers()
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(users)
}

Gotcha: Naming a DB-calling function getUsers() hides the I/O cost — loadUsers or fetchUsers signals it.


Concept: The average developer on the team should possess a comprehensive mental model of the codebase.

Why it matters: Code only the author understands is a liability — it can't be reviewed, debugged, or extended by the team.

package main

import "fmt"

// self-documenting: variable names and structure carry the model
type OrderStatus int

const (
	StatusPending   OrderStatus = iota
	StatusConfirmed             // iota makes the sequence obvious to any reader
	StatusShipped
	StatusDelivered
)

func (s OrderStatus) String() string {
	return [...]string{"pending", "confirmed", "shipped", "delivered"}[s]
}

func main() {
	status := StatusConfirmed
	fmt.Println(status) // "confirmed" — no magic numbers in the codebase
}

Gotcha: Using raw int constants (0, 1, 2, 3) for status values forces every reader to look up the mapping.


Concept: Simplicity — achieved through refactoring, aims to hide complexity without sacrificing readability.

Why it matters: Simplicity is the result of removing everything that isn't necessary while keeping everything that is.

package main

import (
	"fmt"
	"strings"
)

// complex version hidden behind a simple interface — reader sees only the contract
type Pipeline struct{ steps []func(string) string }

func (p *Pipeline) Add(step func(string) string) { p.steps = append(p.steps, step) }

func (p *Pipeline) Run(input string) string {
	for _, step := range p.steps {
		input = step(input)
	}
	return input
}

func main() {
	p := Pipeline{}
	p.Add(strings.TrimSpace)
	p.Add(strings.ToLower)
	p.Add(func(s string) string { return strings.ReplaceAll(s, " ", "_") })

	fmt.Println(p.Run("  Hello World  ")) // hello_world
}

Gotcha: Hiding complexity in a way that prevents the caller from understanding error conditions is not simplicity — it's opacity.


Concept: External latencies — delays caused by network communication, disk I/O, and system calls (measured in milliseconds).

Why it matters: External latencies dominate wall-clock time in most services — optimizing CPU while ignoring I/O is wasted effort.

package main

import (
	"fmt"
	"time"
)

// simulate and measure external latency — make it visible
func callExternalService(url string) (string, time.Duration) {
	start := time.Now()
	time.Sleep(50 * time.Millisecond) // simulates network round-trip
	return "response", time.Since(start)
}

func main() {
	result, latency := callExternalService("https://api.example.com")
	fmt.Printf("result=%s latency=%v\n", result, latency)
	// if latency > 100ms, investigate the network path, not the Go code
}

Gotcha: Profiling CPU when the bottleneck is a 100ms database query — pprof shows the wait, not the work.


Concept: Internal latencies — garbage collection, memory allocation, and synchronization mechanisms (measured in microseconds).

Why it matters: Internal latencies accumulate across millions of operations; reducing allocations directly reduces GC pauses.

package main

import (
	"fmt"
	"runtime"
)

// reusing a buffer eliminates per-call allocation — reduces GC pressure
type Processor struct{ buf []byte }

func NewProcessor() *Processor {
	return &Processor{buf: make([]byte, 0, 4096)} // pre-allocate once
}

func (p *Processor) Process(data []byte) []byte {
	p.buf = p.buf[:0]              // reset length, keep capacity — zero allocation
	p.buf = append(p.buf, data...)
	return p.buf
}

func main() {
	var before, after runtime.MemStats
	runtime.ReadMemStats(&before)

	proc := NewProcessor()
	for i := 0; i < 10000; i++ {
		proc.Process([]byte("hello world"))
	}

	runtime.ReadMemStats(&after)
	fmt.Printf("allocs: %d\n", after.Mallocs-before.Mallocs) // near zero
}

Gotcha: Creating a new []byte inside a hot loop causes one allocation per iteration — multiplied by millions of requests.


Concept: Data access patterns significantly influence performance — optimizing data storage and retrieval strategies.

Why it matters: The same algorithm runs 10× faster with cache-friendly data layout versus pointer-chasing structures.

package main

import "fmt"

// struct of arrays (cache-friendly) vs array of structs (cache-unfriendly) for hot fields
// cache-friendly: access all X values sequentially — fits in cache lines
type ParticlesSOA struct {
	x []float64 // all X in one contiguous block
	y []float64
}

// cache-unfriendly for X-only loops: X and Y interleaved — Y wastes cache space
type ParticleAOS struct {
	x, y float64
}

func sumX_SOA(p ParticlesSOA) float64 {
	total := 0.0
	for _, x := range p.x { // stride-1, all cache hits
		total += x
	}
	return total
}

func main() {
	p := ParticlesSOA{
		x: []float64{1, 2, 3, 4},
		y: []float64{5, 6, 7, 8},
	}
	fmt.Println(sumX_SOA(p))
}

Gotcha: Adding rarely-used fields to a hot struct pollutes cache lines even when those fields aren't accessed.


Concept: Algorithm efficiency plays a role particularly within tight loops — readability should not be sacrificed for marginal algorithmic gains.

Why it matters: A 5% algorithmic gain that makes the code unreadable creates maintenance debt worth more than the gain.

package main

import "fmt"

// O(n) linear scan is "fast enough" for small n — readable wins over micro-optimized
func contains(haystack []string, needle string) bool {
	for _, s := range haystack { // clear, correct, fast enough for n < 1000
		if s == needle {
			return true
		}
	}
	return false
}

func main() {
	fruits := []string{"apple", "banana", "cherry"}
	fmt.Println(contains(fruits, "banana"))  // true
	fmt.Println(contains(fruits, "mango"))   // false
}

Gotcha: Replacing a readable linear scan with a binary search (requiring a sorted slice) before profiling proves it matters.