Reading Go
0%

The Backend Ladder  ·  Deep dive  ·  Pillar B of three

Reading Go
like a reviewer.

Go is small enough to learn in a weekend and subtle enough to get wrong for years. This page is about the subtleties that actually appear in review: what a slice really is, where an allocation comes from, which timeout the server is missing, and why that goroutine will still be running tomorrow. Mechanisms drawn, then the code that shows them.

3 wordsA slice valuePointer, length, capacity. Almost every surprising slice bug follows from the pointer being shared.
~20nsUncontended mutexA channel operation costs several times more. “Share by communicating” is a default, not a performance claim.
5Timeouts on an HTTP serverRead header, read, handler, write, idle. Miss one and a slow client can hold a connection open indefinitely.
Part one

Go essentials

The language surface you need to read a diff without pausing. None of it is hard; all of it has one detail that turns into a bug when you assume it works like the last language you used.

Go deep · 01

Zero values, nil, and what is usable uninitialised

Every Go variable starts at its zero value, and the standard library is designed so that most zero values are already usable. The exception — maps — is the one that panics in production.

A · NIL SLICE vs EMPTY SLICE var a []int // nil b := []int{} // empty Identical for len, cap, range and append. json.Marshal differs a → null b → [] and a client will crash on null Return an empty slice from API handlers, a nil slice everywhere else. B · NIL MAP — READS FINE, WRITES PANIC var m map[string]int v := m["k"] // 0, no panic len(m) // 0, fine m["k"] = 1 // PANIC The read path hides the bug in tests and the write path finds it in production. A struct field of map type needs a constructor. C · ZERO VALUES THAT JUST WORK var mu sync.Mutex var wg sync.WaitGroup var buf bytes.Buffer var once sync.Once var t time.Time no New(), no init — ready to use and safe to embed in a struct This is a design rule, not an accident: give your own types a useful zero value and callers need no constructor. The other nils, briefly A nil channel blocks forever (useful for disabling a select case). A nil pointer receiver is fine as long as the method never dereferences it. A nil function value panics when called. And a nil pointer inside an interface is not a nil interface — the trap from the interfaces section.
The rule to carry away: a zero value that works is a feature you give your callers. sync.Mutex needs no constructor, so a struct containing one needs no constructor either — and every type in your codebase that does require New() should require it for a reason you can name.
var (
	i  int            // 0
	s  string         // "" — a string is never nil
	p  *User          // nil
	m  map[string]int // nil: reads work, writes panic
	sl []int          // nil: len 0, and append still works
	ch chan int       // nil: send and receive block forever
	mu sync.Mutex     // ready to use — that is the whole point
)

fmt.Println(m["missing"])   // 0, no panic — reads of a nil map are fine
m["k"] = 1                  // panic: assignment to entry in nil map
sl = append(sl, 1)          // fine: append allocates on first use

// so this struct is usable with no constructor at all …
type Counter struct {
	mu sync.Mutex
	n  int
}
var c Counter; c.mu.Lock()  // … which is why NewCounter() often earns nothing
Go deep · 02

defer, panic, recover — exact semantics

defer looks like a finally block and behaves differently in three ways that matter: when its arguments are evaluated, when it runs, and what it can do to the return value.

ONE FUNCTION CALL, LEFT TO RIGHT defer f(x) x copied NOW defer g() queued second x = 99 too late for f return, or panic deferred calls run here g() runs first — LIFO then f(1) — with the OLD x NAMED RETURNS func do() (err error) { defer func() { err = wrap(err) }() } A deferred closure can change a named result after return. This is how Close() errors and rollbacks get reported at all. Two traps worth naming in review defer inside a loop does not run at the end of the iteration — it queues until the function returns. Ten thousand open files, then a crash. recover() only works directly inside a deferred function, and only on the goroutine that panicked. A panic in a spawned goroutine kills the process.
That last point deserves emphasis: your HTTP middleware's recover() protects the request goroutine, and nothing else. A background worker started with go needs its own deferred recover at the top of its own function, or one bad message takes the whole pod down.
// The loop trap and its fix.
for _, name := range files {
    f, err := os.Open(name)
    if err != nil { return err }
    defer f.Close()          // piles up: nothing closes until the function ends
    process(f)
}

for _, name := range files {
    if err := func() error { // give each iteration its own scope
        f, err := os.Open(name)
        if err != nil { return err }
        defer f.Close()      // runs at the end of THIS iteration
        return process(f)
    }(); err != nil { return err }
}
Go deep · 03

Strings, runes, bytes — and maps

A Go string is an immutable sequence of bytes, not characters. Indexing gives you a byte; ranging gives you a rune. Confusing the two is how emoji and accented names get corrupted.

s := "héllo" BYTES — what len() and s[i] see h 0xc3 0xa9 l l o one rune, two bytes len(s) == 6  ·  s[1] is a byte, not 'é' RUNES — what range gives you h é l l o i = 0      1            3      4      5 The index jumps — it is a byte offset, not a counter. CONVERSIONS AND COST []byte(s) // copies string(b) // copies strings are immutable, so a conversion allocates s += x in a loop rebuilds the whole string each time — quadratic use strings.Builder Truncating a UTF-8 string by byte count splits a character in half. Use runes. MAPS — FOUR FACTS v, ok := m[k] tells zero-value from missing range order is random deliberately — sort the keys &m[k] is illegal entries move; store pointers concurrent write = fatal not a race warning — a crash Deleting during a range is legal; adding may or may not be visited. Why map iteration is randomised on purpose So you cannot accidentally depend on an order that was never guaranteed. A test that passes because the map happened to iterate alphabetically is a bug waiting for a different Go version — the runtime makes it fail immediately instead.
The practical rule: treat a string as bytes for storage and transport, and convert to []rune only when you genuinely need character positions — truncating a display name, counting characters, reversing text. Those conversions allocate, so do them once, not per request.
s := "café"
len(s)                      // 5 — bytes, not characters
utf8.RuneCountInString(s)   // 4 — characters
s[3]                        // 195 — one byte, mid-rune, almost never useful

for i, r := range s {       // range over a string yields runes …
	fmt.Printf("%d %c\n", i, r)   // … and i is the BYTE offset: 0 1 2 3
}

b := []byte(s)              // a copy
r := []rune(s)              // a copy — 4 runes of 4 bytes each

// the only right way to build a string in a loop; += is quadratic
var sb strings.Builder
sb.Grow(len(parts) * 8)     // one allocation if you know the size
for _, p := range parts {
	sb.WriteString(p)
}
return sb.String()          // no copy: Builder hands over its buffer
Go deep · 04

Structs, embedding, and tags

Go has composition and no inheritance. Embedding promotes the inner type's methods onto the outer type, which looks like inheritance right up to the moment you try to override something.

EMBEDDING PROMOTES METHODS type Server struct { *slog.Logger addr string } srv.Info("started") no delegation to write The field name is the type name, so srv.Logger still works. BUT IT IS NOT INHERITANCE func (b Base) Describe() string { return b.Name() } func (b Base) Name() string { return "base" } type Outer struct { Base } func (o Outer) Name() string { return "outer" } Outer{}.Describe() → "base", not "outer" There is no virtual dispatch. Describe has a Base receiver and can only ever see Base’s methods. If you want the outer behaviour, pass an interface in — composition, not a base class. TAGS — THE WIRE FORMAT ID string `json:"id"` Note *string `json:"note,omitempty"` secret string `json:"-"` unexported fields are never marshalled anyway The JSON detail that causes real bugs omitempty drops zero values, so false and 0 vanish from the payload. If the API must distinguish “absent” from “set to zero”, the field has to be a pointer — and on decode, an unknown JSON field is silently ignored unless you call DisallowUnknownFields.
Embed for reuse, take an interface for behaviour. Embedding a Logger or a sync.Mutex to get its methods is idiomatic and cheap. Embedding a "base type" expecting subclass overrides is the one thing Go genuinely will not do, and the resulting bug is silent — the code compiles and calls the wrong method.
type Audit struct {
	CreatedAt time.Time `json:"created_at"`
	CreatedBy string    `json:"created_by"`
}

type Order struct {
	Audit                            // embedded: o.CreatedAt works directly
	ID     uuid.UUID `json:"id"`
	Secret string    `json:"-"`      // never marshalled
	Note   string    `json:"note,omitempty"`
	Total  Money     `json:"total,string"`
}

var o Order
o.CreatedAt = time.Now()             // promoted field, not inheritance

// embedding promotes methods too — and a method on Order SHADOWS the
// embedded one rather than overriding it: Audit's own methods still call
// Audit's version. There is no virtual dispatch here.

// tags are just strings; the compiler does not check them. A typo in
// `json:"crated_at"` is a silent wrong field name at run time.
Part two

Values in memory

Go has no hidden references — assignment always copies the value. The catch is that some values contain pointers, so copying them shares what they point at. Knowing which is which prevents a whole class of bug.

Go deep · 05

A slice is three words over an array you cannot see

Pointer, length, capacity. Slicing makes a new header over the same array; append writes in place when capacity allows and reallocates when it does not. That conditional is where the bugs live.

a := []int{1,2,3,4,5}[:3] · b := a[:2] ptr len 3 cap 5 slice a ptr len 2 cap 5 slice b = a[:2] one backing array, five slots 1 2 3 4 5 a sees these three · b sees the first two · both point at slot 0 NOW: b = append(b, 99) 1 2 99 ← was 3, and a[2] still reads it cap(b) is 5, so append had room and wrote in place — no copy, no new array, no warning. The rule: append either mutates a shared array or copies to a new one, and you cannot tell from the call site. Cut with a three-index slice — b := a[0:2:2] — to force cap == len, so the next append must allocate and cannot reach the original.
Two consequences worth internalising. Passing a slice to a function that appends may or may not affect the caller — so functions that append should return the slice, and functions that must not mutate should copy. And a small slice of a huge array keeps the whole array alive: data[:10] of a 50MB read is a 50MB leak until you copy it out.
// Looks harmless. Hands two callers a view of the same memory.
func (s *Store) Recent() []Item { return s.items[:10] }   // caller can append into s.items[10]

// Two safe alternatives, depending on intent.
func (s *Store) Recent() []Item { return s.items[0:10:10] }        // share reads, forbid in-place append
func (s *Store) Recent() []Item { return slices.Clone(s.items[:10]) } // hand over an independent copy
Go deep · 06

What copying actually copies

Assignment, arguments and channel sends all copy the value. For a struct that means a deep-ish copy of its fields; for a slice, map or channel it means copying a small header that points at shared state.

A · FULLY COPIED [4]int, struct independent copy Mutating one cannot affect the other. Costs bytes proportional to size — a 400-byte struct passed by value in a hot loop is real work. B · HEADER COPIED, BACKING SHARED []T, string new header same backing bytes Strings are safe because they are immutable; slices are not, which is the whole of section 05. C · POINTER TO ONE RUNTIME OBJECT map, chan, func same pointer one hmap / hchan Passing a map “by value” still lets the callee mutate yours — and concurrent writes still panic. Review question: does this function take a copy it can safely modify, or a window onto someone else’s state? The type alone tells you.
Row C also explains the map panic. A map is one runtime object shared by every copy of the variable, and it has no internal lock — so two goroutines writing concurrently is a fatal error the runtime deliberately detects. sync.Map or a mutex is not optional; it is the only option.
type Big struct{ Data [1024]int }        // an array, not a slice: 8 KB

func byValue(b Big)    { b.Data[0] = 1 } // copies 8 KB, mutates nothing
func byPointer(b *Big) { b.Data[0] = 1 } // copies 8 bytes, mutates the caller's

// the range-copy trap — the single most common Go bug in review
for _, o := range orders {
	o.Status = "sent"                    // o is a COPY; this is discarded
}
for i := range orders {
	orders[i].Status = "sent"            // this one actually writes
}

// method sets are the same rule wearing a different hat
func (o Order) Cancel()  { o.Status = "cancelled" }   // silently useless
func (o *Order) Cancel() { o.Status = "cancelled" }   // what you meant

// and a struct containing a sync.Mutex must never be copied at all —
// `go vet` will tell you: passes lock by value
Go deep · 07

Stack or heap: escape analysis

Go decides at compile time where a value lives. Stack allocation is nearly free and reclaimed on return; heap allocation costs a little now and GC work later. You do not control it directly — you control the shapes that force it.

STACK — FREE, DIES WITH THE FRAME buf := make([]byte, 64) // size known, never escapes p := Point{X: 1, Y: 2} // used locally, by value n := len(s) * 2 // plain arithmetic Cost: a pointer bump. No GC involvement at all. HEAP — COSTS AN ALLOCATION AND GC WORK return &Point{} // outlives the frame var w io.Writer = myBuf // boxed into an interface go func() { use(p) }() // captured by a goroutine Also: anything passed to fmt.Println or stored in a map. Find out, don’t guess go build -gcflags='-m -m' ./... go test -bench=. -benchmem → allocs/op The compiler tells you exactly which line escapes and why. allocs/op is the number to move in a benchmark — it is far more stable than ns/op and usually the thing actually causing the ns. Do this only where a profile points, and never restructure readable code on a guess about the compiler.
Why this matters beyond micro-optimisation: allocation rate drives GC frequency, and GC frequency drives tail latency. A handler that allocates 40 times per request at 5,000 rps is 200k allocations a second — visible in p99 as periodic bumps, and best fixed by removing allocations rather than by tuning the collector.
// go build -gcflags='-m' ./...   prints every decision the compiler made

func stays() int {
	x := 42
	return x            // stack: freed by moving the stack pointer
}

func escapes() *int {
	x := 42
	return &x           // ./main.go:9:2: moved to heap: x
}

func alsoEscapes(w io.Writer) {
	buf := make([]byte, 64)
	w.Write(buf)        // escapes: it is passed to an interface method,
}                       // and the compiler cannot see the implementation

// the useful fix is usually reuse, not avoidance:
var bufs = sync.Pool{New: func() any { return new(bytes.Buffer) }}

b := bufs.Get().(*bytes.Buffer)
b.Reset()
defer bufs.Put(b)       // measure first — a pool can easily be slower
Part three

Concurrency in practice

Five patterns cover almost all production Go. What separates them from textbook examples is that each one has an owner, a bound, and an exit path.

Go deep · 08

The pattern catalogue

Worker pool, pipeline, fan-in, semaphore, single-flight. Recognising which one a diff is reaching for — and which one it should have reached for — is most of reviewing concurrent Go.

1 · WORKER POOL jobs ch N fixed workers pull from one channel. Concurrency is capped by construction. 2 · PIPELINE parse enrich write Each stage owns its output channel and closes it when done. The slowest stage sets the rate for everyone. 3 · FAN-IN merged A WaitGroup closes the merged channel only after the last producer has finished. 4 · SEMAPHORE cap 3 scarce resource A buffered channel used as tokens. Unlimited goroutines, limited concurrent access. 5 · SINGLE-FLIGHT — THE ONE MOST TEAMS ARE MISSING req A req B req C req D singleflight key = “user:7” one DB query All four callers get the same result. This is the cache-stampede fix from Pillar A, in four lines. golang.org/x/sync/singleflight
Patterns 1 and 4 solve the same problem from opposite ends: a pool bounds the workers, a semaphore bounds access while letting the goroutine count follow the request count. Use a pool when work arrives from a queue; use a semaphore when work arrives from inbound requests you cannot refuse to accept.
// Semaphore: 1,000 requests in flight, but only 5 touch the fragile API.
var sem = make(chan struct{}, 5)

func callVendor(ctx context.Context, id string) (*Quote, error) {
    select {
    case sem <- struct{}{}:            // acquire a token
        defer func() { <-sem }()      // release, always
    case <-ctx.Done():
        return nil, ctx.Err()         // give up rather than queue forever
    }
    return vendor.Quote(ctx, id)
}
// worker pool: N goroutines pull from one channel until it is closed
func workers(ctx context.Context, jobs <-chan Job, n int) <-chan Result {
    out := make(chan Result)
    var wg sync.WaitGroup
    wg.Add(n)
    for range n {
        go func() {
            defer wg.Done()
            for j := range jobs {          // ends when jobs is closed
                select {
                case out <- process(ctx, j):
                case <-ctx.Done():         // consumer gone — return, don't block forever
                    return
                }
            }
        }()
    }
    go func() { wg.Wait(); close(out) }()  // close out only after every worker is done
    return out
}

// pipeline + fan-in: each stage is its own goroutine, joined by unbuffered channels
// (in production every send below wants the same select-on-ctx.Done() as above)
func pipeline(ctx context.Context, ids <-chan string) <-chan Order {
    fetched := make(chan Raw)
    go func() { defer close(fetched); for id := range ids { fetched <- fetch(ctx, id) } }()

    parsed := make(chan Order)
    go func() { defer close(parsed); for r := range fetched { parsed <- parse(r) } }()
    return parsed                           // the caller ranges over this like any channel
}
// single-flight: 40 goroutines ask for the same key, the vendor sees ONE call
var g singleflight.Group   // golang.org/x/sync/singleflight

func GetQuote(ctx context.Context, id string) (*Quote, error) {
    v, err, shared := g.Do(id, func() (any, error) {
        return vendor.Quote(ctx, id)        // only the first caller actually runs this —
    })                                      // and with the FIRST caller's ctx: if that one
                                            // cancels, every waiter shares its error
    if shared {
        metrics.Inc("quote.dedup")          // the other 39 waited and got a copy of the same answer
    }
    if err != nil {
        return nil, err
    }
    return v.(*Quote), nil
}
Go deep · 09

Context is a tree, and cancellation flows down it

Every request builds a tree of contexts. A deadline is inherited and can only get tighter; a cancel signal propagates to every descendant at once. Getting this right is what makes a Go service shed work correctly under stress.

r.Context() + WithTimeout(300ms) middleware · cancels if client leaves WithTimeout(ctx, 50ms) cache lookup WithTimeout(ctx, 120ms) db query WithTimeout(ctx, 600ms) pricing call 600ms is a lie the parent expires at 300 rows.Scan loop client disconnects → ctx.Done() closes for every box above, at once Deadlines only tighten: a child’s effective deadline is min(its own, every ancestor’s).
Three rules that follow from the picture. Always defer cancel() — a context with a timer leaks it until the deadline otherwise. Never store a context in a struct; pass it as the first parameter so the tree matches the call graph. And use context.Value only for request-scoped metadata such as a trace ID, never for arguments the function genuinely needs — those belong in the signature where the compiler can see them.
// ctx is the first parameter, always named ctx, never stored in a struct
func (s *Service) Get(ctx context.Context, id string) (Order, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()          // always — skipping it leaks the timer until it fires

	select {
	case <-ctx.Done():
		return Order{}, ctx.Err()     // Canceled or DeadlineExceeded
	case r := <-s.results:
		return r, nil
	}
}

// values are for request-scoped data only, with an unexported key type
type ctxKey struct{}                  // unexported: nobody else can collide

ctx = context.WithValue(ctx, ctxKey{}, reqID)
id, _ := ctx.Value(ctxKey{}).(string) // always comma-ok; it can be missing

// a deadline is inherited, never extended: a child can only be stricter
Go deep · 10

Mutex or channel: the actual decision

"Share memory by communicating" is good advice about ownership, not a rule about performance. The honest version: channels move ownership, mutexes protect state. Choose by which one you are doing.

SituationReach forWhy
Handing work to another goroutinechanTransfers ownership and creates the happens-before edge in one operation.
Protecting a shared map or countersync.Mutex / sync/atomicSimpler, faster, and no extra goroutine to own the state.
Waiting for several things to finishsync.WaitGroup or errgroupExpresses "all of them", and errgroup adds first-error plus cancellation.
One-time initialisationsync.OnceCorrect under concurrency and cheaper than a mutex on every read.
Read-mostly shared stateRWMutex, measuredOnly wins with genuinely long read sections; otherwise the extra bookkeeping loses to a plain Mutex.
Signalling "stop" to many goroutinescontext or a closed channelA closed channel broadcasts — every receiver unblocks at once.
Reusing short-lived bufferssync.PoolCuts allocation pressure on hot paths. Not a cache — entries vanish at GC.

The review smell

A goroutine whose only job is to own a variable and serve it over two channels is usually a mutex written the long way — more code, more allocation, one more thing to shut down. Conversely, a mutex held across a network call is a channel-shaped problem being solved with a lock, and it will serialise your whole service behind the slowest dependency.

// shared state that several goroutines read: a mutex is simply clearer
type Cache struct {
	mu sync.RWMutex                // RW: many readers, occasional writer
	m  map[string]Entry
}

func (c *Cache) Get(k string) (Entry, bool) {
	c.mu.RLock()
	defer c.mu.RUnlock()           // defer here costs ~1 ns and never leaks
	e, ok := c.m[k]
	return e, ok
}

// a channel is right when OWNERSHIP moves, not when state is shared
jobs := make(chan Job, 64)         // the buffer size IS the queue depth
for range 8 {                      // 1.22+: range over an int
	go func() {
		for j := range jobs {      // ends when the channel is closed
			handle(j)
		}
	}()
}
close(jobs)                        // the SENDER closes; a receiver never does
Part four

Interfaces and generics

Both let one piece of code work with many types. They do it at opposite ends of the compile/run boundary, which is exactly how you choose between them.

Go deep · 11

What an interface value costs

An interface value is two words: a pointer to a type descriptor with a method table, and a pointer to the data. Calling through it means an indirect jump the compiler usually cannot inline — and storing a value in one usually means an allocation.

var w io.Writer = &bytes.Buffer{} itab data itab: *bytes.Buffer Write → 0x4a91c0 … the Buffer, on the heap w.Write(p) load itab, jump — no inlining buf.Write(p) direct call, often inlined away Two costs, one of which actually matters The indirect call is a few nanoseconds — irrelevant unless it sits in a loop running millions of times. The lost inlining usually costs more than the jump, because it blocks the optimisations inlining enables. The boxing allocation is the one to watch: putting a non-pointer value into an interface copies it to the heap. That is why fmt.Sprintf("%d", n) allocates twice and strconv.Itoa(n) allocates once.
Design freely, optimise narrowly. Interfaces at service boundaries cost nothing you can measure. Interfaces inside a per-element hot loop — a comparator, a codec, a matrix op — are where the two costs compound, and where generics or a concrete type earn their keep.
// an interface value is two words: (type, value). Both must be nil to be nil.
type MyErr struct{}

func (e *MyErr) Error() string { return "boom" }

func bad() error {
	var e *MyErr        // nil *MyErr
	return e            // interface holds (*MyErr, nil) — NOT nil
}

func good() error {
	return nil          // interface holds (nil, nil) — nil
}

if bad() != nil  { /* fires — the classic typed-nil bug */ }
if good() != nil { /* does not fire */ }

// so a function that can fail returns a bare nil, never a typed nil pointer:
func find(id string) (*Order, error) {
	o, err := lookup(id)
	if err != nil {
		return nil, err          // literal nil, not a nil *Order variable
	}
	return o, nil
}
Go deep · 12

Generics: the syntax you will read

Type parameters arrived in Go 1.18 and the syntax is small: square brackets, a constraint per parameter, and inference at the call site so you rarely write the types out.

ANATOMY OF A GENERIC FUNCTION func Map[T any, U any](in []T, f func(T) U) []U type parameters their constraints ordinary parameters names := Map(users, User.Name) Inference fills in T and U. You almost never write Map[User, string](...) by hand. THE FOUR KINDS OF CONSTRAINT any no operations allowed — you can only move it around comparable supports == and != so it can be a map key ~int | ~float64 a union of types; ~ also admits type Celsius float64 an interface any interface works as a constraint — needs methods
Two things the compiler will not let you do, and both surprise newcomers: a method cannot declare type parameters of its own — only the type or a top-level function can be generic — and you cannot switch on T to specialise behaviour. If you find yourself wanting either, the answer is an interface. The standard slices, maps and cmp packages are the reference examples of what generics are genuinely for.
func Map[T, U any](in []T, f func(T) U) []U {
	out := make([]U, 0, len(in))     // pre-sized: one allocation
	for _, v := range in {
		out = append(out, f(v))
	}
	return out
}

// a constraint is an interface used as a type set. ~ means "underlying type",
// so a `type Celsius float64` satisfies it too.
type Number interface{ ~int | ~int64 | ~float64 }

func Sum[T Number](xs []T) T {
	var total T                      // the zero value of whatever T is
	for _, x := range xs {
		total += x
	}
	return total
}

ids := Map(orders, func(o Order) uuid.UUID { return o.ID })  // T, U inferred
n   := Sum([]float64{1.5, 2.5})                              // 4.0
Go deep · 13

Generics or interfaces

An interface asks "what can this value do?" and resolves it at run time. A type parameter asks "what type is this?" and resolves it at compile time. They answer different questions, so the choice is rarely close once you name the question.

Does the logic change per type? NO — same code, different types → GENERICS Map, Filter, Min, a typed cache, a result wrapper Type-safe without any and without reflection. No boxing, no dynamic dispatch, errors at compile time. Cost: harder signatures, and the compiler shares one instantiation per pointer shape, so the win is real but smaller than C++-style monomorphisation. YES — different behaviour → INTERFACES Store, Notifier, io.Writer, a payment provider One call site, many implementations, swappable at run time and in tests. This is the seam that lets a fake stand in for Postgres. Rule of thumb: containers and algorithms take type parameters; collaborators take interfaces.
The failure mode is using each for the other's job. A generic Repository[T] with a type switch inside is an interface wearing a costume; an interface{ Less(any) bool } comparator is generics avoided at the price of boxing every element. Name the question first and the answer picks itself.
// good: one algorithm, many element types, no interface{} and no reflection
func Keys[K comparable, V any](m map[K]V) []K

// bad: a type parameter where an ordinary interface says it better
func Handle[T Handler](h T)      // just take the Handler interface

// bad: generics to avoid writing two clear functions once
func Get[T any](url string) (T, error)   // the caller must now annotate T

// and most of what you were about to write already exists:
slices.Sort(xs)
slices.Contains(xs, x)
slices.SortFunc(orders, func(a, b Order) int {
	return cmp.Compare(a.Total, b.Total)     // -1, 0, +1
})
idx, found := slices.BinarySearch(sorted, target)
keys := slices.Collect(maps.Keys(m))         // 1.23+: an iterator, materialised
Part five

Errors that carry decisions

An error is only useful if someone can act on it. The design question is not "what message" but "what will the caller do differently" — and the answer connects straight back to the retry, breaker and DLQ machinery from Pillar A.

Go deep · 14

The taxonomy that drives behaviour

Four categories, four different reactions. If your error types do not let a caller tell these apart, every failure gets the same treatment — usually a retry, which is the wrong answer three times out of four.

1 TRANSIENT timeout, 503, deadlock, connection reset retry w/ backoff + jitter only if idempotent 2 TERMINAL not found, forbidden, payment declined fail fast, do not retry retrying is pure load 3 BAD INPUT validation, malformed body, bad state change 4xx + a useful message never an alert 4 BUG nil deref, index range, impossible state panic, recover at the edge page someone Make the category machine-readable, not a guess at the call site type Error struct { Code Code; Op string; Err error } // Code: Transient | Terminal | Invalid func (e *Error) Retryable() bool { return e.Code == Transient } One place decides the category; every retry loop, breaker and consumer reads it instead of matching on strings.
This is the join between the two pillars. The retry policy, circuit breaker and dead-letter queue from the foundations page are all consumers of this classification. Get it wrong in the direction of "everything is transient" and you have built the retry storm; wrong in the other direction and a blip becomes a customer-visible failure.
// Wrap with context going up. Classify once, at the boundary that knows.
func (r *Repo) Get(ctx context.Context, id string) (*Order, error) {
    row := r.db.QueryRowContext(ctx, q, id)
    var o Order
    switch err := row.Scan(&o.ID, &o.Email, &o.Total); {
    case err == nil:
        return &o, nil
    case errors.Is(err, sql.ErrNoRows):
        return nil, &Error{Code: Terminal, Op: "repo.Get", Err: err}
    case errors.Is(err, context.DeadlineExceeded), isConnErr(err):
        return nil, &Error{Code: Transient, Op: "repo.Get", Err: err}
    default:
        return nil, fmt.Errorf("repo.Get %s: %w", id, err)   // unknown: don't guess
    }
}

// errors.Join reports several independent failures without picking a winner.
func (c *Config) Validate() error {
    var errs []error
    if c.Port == 0 { errs = append(errs, errors.New("port is required")) }
    if c.DSN == "" { errs = append(errs, errors.New("dsn is required")) }
    return errors.Join(errs...)   // nil when the slice is empty
}
// the four categories, made machine-readable -- Invalid was missing above
type Code int

const (
    Transient Code = iota   // timeout, 503, connection reset -- retry, if idempotent
    Terminal                // not found, forbidden -- fail fast, retrying is pure load
    Invalid                 // bad input -- a 4xx and a message, never an alert
)

func (r *Repo) validate(id string) error {
    if id == "" {
        return &Error{Code: Invalid, Op: "repo.Get", Err: errors.New("id is required")}
    }
    return nil
}
// the fourth category is not a Code at all -- a bug is not a value to return, it is a panic
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
    defer func() {
        if v := recover(); v != nil {
            log.Error("panic recovered", "route", r.URL.Path, "panic", v, "stack", string(debug.Stack()))
            http.Error(w, "internal error", http.StatusInternalServerError)
            metrics.Inc("panics")   // page someone -- this is a bug, not a retryable condition
        }
    }()
    s.routeFor(r)(w, r)
}
Part six

The service toolkit

What a Go service is actually built from. Go's answer to most framework features is a standard library package, which is why Go services have short dependency lists and why knowing the stdlib matters more here than elsewhere.

Go deep · 15

database/sql is a pool, not a connection

One *sql.DB is a connection pool with four knobs, and those knobs are the join between your Go process and the database limits from the cloud page. Getting them wrong is the most common cause of "the database is slow" that turns out not to be the database.

ONE *sql.DB PER PROCESS — SHARED BY EVERY GOROUTINE handlers 1000s of goroutines *sql.DB 3 in use · 2 idle Postgres max_connections = 450 THE SUM IS WHAT COUNTS pods × MaxOpenConns must stay under it 40 pods × 20 = 800 > 450 → refused and autoscaling makes it worse, not better THE FOUR KNOBS SetMaxOpenConns the real limit. Unset means unlimited — always set it. = λ × query time, + headroom SetMaxIdleConns defaults to 2, so a busy service reconnects constantly set equal to MaxOpenConns SetConnMaxLifetime retires connections so a failover is not sticky 5–30 min is the usual answer SetConnMaxIdleTime releases idle capacity back after a quiet period When the pool is full, QueryContext blocks waiting for a slot — and returns context deadline exceeded, which reads like a slow query but is not one.
This is Little's Law wearing a Go API. Concurrent connections needed = request rate × time spent in the database. Size MaxOpenConns from that number with headroom, multiply by your pod count, and check the total against the instance's real limit — then put RDS Proxy in front if the arithmetic does not fit.
db, err := sql.Open("pgx", dsn)   // does not connect yet — Ping() does
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(20)            // match, or you churn connections
db.SetConnMaxLifetime(30 * time.Minute)

// Always the ...Context variants, always close rows, always check rows.Err().
rows, err := db.QueryContext(ctx, `SELECT id, note FROM orders WHERE tenant = $1`, id)
if err != nil { return nil, err }
defer rows.Close()                // otherwise the connection never returns to the pool

for rows.Next() {
    var o Order
    var note sql.NullString       // a nullable column is not a plain string
    if err := rows.Scan(&o.ID, &note); err != nil { return nil, err }
    o.Note = note.String
    out = append(out, o)
}
return out, rows.Err()            // the loop can end because of an error, not just the data

The three-line bug

A missing defer rows.Close() leaks a connection per call. It does not fail immediately — it fails minutes later, everywhere at once, when the pool is exhausted and every handler is blocked waiting for a slot. If you take one habit from this section, make it that line.

Go deep · 16

The standard library you would otherwise install

Most of what a web framework provides is in here. Knowing the map is what keeps a Go service at five dependencies instead of fifty — and every dependency you skip is a CVE you never have to patch.

SERVE AND CALL net/http server, client, middleware, routing html/template · text/template rendering, auto-escaped net/url · net parsing, dialling, DNS crypto/* TLS, hashing, HMAC, random DATA AND ENCODING encoding/json struct tags, streaming decoder database/sql pooling and scanning io · bufio · bytes streams without loading it all slices · maps · cmp the generic helpers, built in RUN AND OBSERVE log/slog structured JSON logging context · sync · time deadlines, coordination, clocks os/signal · os · flag SIGTERM, env, config net/http/pprof · testing profiles and benchmarks What you will still reach outside for A database driver (pgx), a router only for middleware chaining and route groups (chi) — since Go 1.22 ServeMux itself matches "GET /orders/{id}" and hands you r.PathValue("id") — metrics and tracing clients, an AWS SDK, and golang.org/x/sync. On the horizon: encoding/json/v2, experimental in 1.25 — far faster decoding, and it marshals a nil slice as [], not null.
log/slog is the one to adopt deliberately. Structured logging landed in the standard library in Go 1.21, which means new services no longer need a logging dependency — and attaching the trace ID to a slog.Logger carried on the context is what connects your logs to the traces from the cloud page.
// Structured logging, standard library only. Key/value pairs, not a format string.
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger)

// Bind the request's identity once; every later line carries it.
log := logger.With("trace_id", traceID, "tenant", tenantID)
log.Info("order placed", "order_id", id, "amount_cents", 4200)
log.Error("charge failed", "err", err, "provider", "stripe")

// Bounded keys only — a user_id as a metric label is unbounded cardinality,
// but as a LOG field it is exactly right. Metrics and logs have opposite rules.
Go deep · 17

Time, timers, and the two clocks

A time.Time carries a wall clock and a monotonic reading. Using the wrong one, or leaking a timer in a loop, produces bugs that only appear under load or when NTP adjusts the clock.

ONE time.Time, TWO READINGS wall: 2026-08-19T09:41Z monotonic: +41s The wall clock can jump backwards when NTP corrects it. The monotonic reading only ever moves forward. time.Since(start) // uses monotonic ✓ t2.Sub(t1) // uses monotonic ✓ t.Round(0), JSON, DB round-trip all strip the monotonic part — durations then use the wall clock Measure elapsed time from a value you kept in memory, never from one that made a round trip through storage. THE FAMOUS time.After LEAK — HISTORY SINCE GO 1.23 for { select { case job := <-jobs: ... case <-time.After(5 * time.Second): // new timer EVERY loop } } Before Go 1.23 each abandoned timer lived until it fired — a real, slow leak. Since 1.23 the GC frees them; only allocation churn remains. t := time.NewTimer(5 * time.Second) defer t.Stop() for { select { case job := <-jobs: t.Reset(5 * time.Second) // one timer, reused Reusing a timer is now an optimisation, not a fix — and a Ticker you hold fires until you Stop() it.
And in tests, do not sleep. A test that waits two seconds for a timeout is slow and flaky; testing/synctest (experimental in Go 1.24, stable in 1.25) gives the test a fake clock so the same code runs instantly and deterministically. Before that, the idiom is to inject a clock interface — which is another case of "accept an interface" earning its keep.
// measuring: monotonic, and immune to NTP steps and DST
start := time.Now()
defer func() { metrics.Observe(time.Since(start)) }()

// wall-clock arithmetic: a different thing entirely
t := time.Now().UTC()        // store and compare in UTC, always
t.Add(24 * time.Hour)        // exactly 24h — NOT "tomorrow" across a DST change
t.AddDate(0, 0, 1)           // calendar-aware "tomorrow"

// durations are a type, so the units are checked at compile time
timeout := 500 * time.Millisecond   // not the integer 500

// a ticker holds a runtime timer until you stop it
tk := time.NewTicker(time.Second)
defer tk.Stop()

// t1 == t2 compares the monotonic reading too; use Equal for wall clocks
t1.Equal(t2)                 // and t.Round(0) strips the monotonic part
Go deep · 18

Modules, builds, and what actually ships

Go compiles to one static binary with no runtime to install. That fact shapes the whole deployment story — a scratch container, a fast cold start, and a dependency graph you can read.

go.mod + go.sum requirements and checksums minimal version selection oldest that satisfies all go build CGO_ENABLED=0 for a static binary one binary no interpreter, no libs scratch or distroless //go:embed assets/* templates, migrations and static files compiled into the binary //go:build integration build tags split slow tests and platform-specific files -ldflags "-X main.version=$(git rev-parse --short HEAD)" stamps the build so /healthz can report exactly which commit is running — the first question in every incident Minimal version selection, and why upgrades are boring Go picks the oldest version that satisfies every requirement, not the newest available. Builds are therefore reproducible without a lockfile resolver, and adding a dependency cannot silently upgrade an unrelated one. go get -u is a deliberate act, and go.sum makes tampering detectable.
The deployment consequence is large. A Go service image is typically 15–30MB with no base OS, which means fast pulls, a small attack surface, and a cold start measured in tens of milliseconds — the reason Go suits both the Kubernetes and the Lambda ends of the compute ladder.
// go.mod — the module path is also the import prefix
module github.com/acme/payments

go 1.25                          // language version AND the toolchain floor

require (
	github.com/jackc/pgx/v5 v5.6.0    // v2+ puts the major in the path
	golang.org/x/sync v0.8.0
)

// minimal version selection: you get the HIGHEST version any dependency
// asks for — not the newest published. Builds are reproducible by default.

//  $ go mod tidy            add what is used, drop what is not, rewrite go.sum
//  $ go get -u ./...        bump minors deliberately, in their own commit
//  $ go mod why -m <mod>    who actually pulled this in
//  $ go mod graph           the whole thing, when why is not enough
//  $ go mod tidy -diff      in CI (1.23+): fail if go.mod/go.sum would change
//  $ go get -tool golang.org/x/vuln/cmd/govulncheck@latest
//        the tool directive (1.24+): CLI tools version-pinned in go.mod,
//        run as `go tool govulncheck` — no more tools.go dance

// and the binary already knows its own commit: debug.ReadBuildInfo()
// carries vcs.revision — -ldflags stamping is for extra fields, not the SHA
Part seven

The service around the code

Two pieces of a Go service are almost always under-specified in review, and both are pure configuration: the timeouts on the HTTP server, and what the process does between SIGTERM and exit.

Go deep · 19

Five timeouts on one connection

A default http.Server has no timeouts at all. Each of the five below guards a different phase, and a slow client only needs one of them missing to hold a connection — and a goroutine — indefinitely.

ONE CONNECTION, LEFT TO RIGHT headers arrive ReadHeaderTimeout · 5s stops Slowloris body arrives ReadTimeout · 15s covers headers + body your handler runs no server setting covers this use a context deadline in middleware response written WriteTimeout · 30s measured from read end idle, keep-alive open IdleTimeout · 60s frees idle sockets And the same discipline on the client you forgot about http.DefaultClient has NO timeout One hung upstream call holds a goroutine, a connection and whatever it closed over — forever. This single default is behind a remarkable share of Go production incidents. own your client &http.Client{Timeout: 3 * time.Second, Transport: &http.Transport{MaxConnsPerHost: 100, MaxIdleConnsPerHost: 100}} MaxIdleConnsPerHost defaults to 2 — a busy service silently opens and closes connections at high rate until you raise it.
The handler phase is the gap people miss. No server field bounds it, so a handler that blocks forever blocks forever — WriteTimeout ends the connection, not the goroutine. Wrap handlers in middleware that sets a context deadline, and make every downstream call honour that context.
srv := &http.Server{
	Addr:              ":8080",
	Handler:           mux,
	ReadHeaderTimeout: 5 * time.Second,   // header trickle — the Slowloris one
	ReadTimeout:       15 * time.Second,  // headers + body
	WriteTimeout:      30 * time.Second,  // end of headers → end of response
	IdleTimeout:       60 * time.Second,  // keep-alive between requests
	MaxHeaderBytes:    1 << 20,
}

// one route legitimately needs longer than the rest — but WriteTimeout above
// still cuts the connection at 30s, so a route allowed 60s needs the server's
// WriteTimeout raised past it (or a dedicated server for slow routes):
mux.Handle("/report", http.TimeoutHandler(reportHandler, 60*time.Second, "timed out"))

// none of the above reaches your database call. Only this does:
func handler(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
	defer cancel()
	rows, err := db.QueryContext(ctx, q)   // cancelled when the client hangs up
	// …
}
Go deep · 20

Shutdown, in the right order

A deploy replaces every pod. Whether that is invisible or drops a slice of requests comes down to a sequence of about fifteen lines, and to pausing before you stop accepting.

SIGTERM 1 readiness → fail still serving traffic 2 sleep ~5s endpoints propagate 3 srv.Shutdown(ctx) no new conns, drain old 4 cancel workers wg.Wait(), flush, close 5 exit 0 step 2 is the one everyone omits Removing a pod from a load balancer is eventually consistent. Shut down immediately and traffic still arrives at a closed socket. The whole sequence must finish inside terminationGracePeriodSeconds, or SIGKILL ends it mid-drain. Budget: 5s pause + drain + margin.
Ordering is the entire trick. Fail readiness before you stop accepting, and keep serving during the gap. Reverse those two and you get exactly the connection-refused errors that a rolling update is supposed to prevent.
func main() {
    srv := &http.Server{Addr: ":8080", Handler: h,
        ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 60 * time.Second}

    go func() { _ = srv.ListenAndServe() }()

    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, os.Interrupt)
    defer stop()
    <-ctx.Done()

    ready.Store(false)              // 1. readiness probe starts failing
    time.Sleep(5 * time.Second)     // 2. let endpoints propagate

    sdCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
    defer cancel()
    _ = srv.Shutdown(sdCtx)         // 3. drain in-flight requests
    workers.Stop()                  // 4. cancel context, wg.Wait(), flush
}
Part eight

Five things you will actually be asked to write

Everything up to here was mechanism. This part is the code — five ordinary backend jobs written the way a Go reviewer would want them, with the reasoning attached. The L2 column of each says why it is written that way rather than the shorter way.

Go deep · 21

A service main(), start to finish

The job: wire up a service so it fails loudly at start-up and quietly on the way down. The shape is always the same — main does nothing but call run and exit, because a function that returns an error is testable and a function that calls os.Exit is not.

START-UP — EACH STEP NEEDS ONLY THE ONES TO ITS LEFT config.Load() or return an error slog handler set once, in run() sql.Open + Ping pool limits here http.Server all four timeouts NotifyContext SIGINT, SIGTERM Every one of these returns an error rather than calling log.Fatal — which is what makes the whole of run() testable. SHUTDOWN — THE SAME LIST, BACKWARDS SIGTERM ctx.Done() fires srv.Shutdown stop accepting drain in flight 25s grace db.Close after the last query return nil exit 0 Deferred calls unwind in exactly this order for free, which is why every resource is acquired with its defer written on the next line. Keep the grace period below the platform’s own kill deadline — on Kubernetes that is terminationGracePeriodSeconds, default 30s.
main exits; run does the work. One error return instead of a scattering of log.Fatal calls means start-up can be exercised in a test, and every defer in the function actually gets to run.
func main() {
	if err := run(context.Background()); err != nil {
		slog.Error("fatal", "err", err)
		os.Exit(1)                      // the only Exit in the programme
	}
}

func run(ctx context.Context) error {
	cfg, err := config.Load()               // fails now, not at 3am
	if err != nil {
		return fmt.Errorf("config: %w", err)
	}
	ctx, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
	defer stop()

	db, err := sql.Open("pgx", cfg.DatabaseURL)
	if err != nil {
		return fmt.Errorf("open db: %w", err)
	}
	defer db.Close()
	db.SetMaxOpenConns(cfg.DBMaxConns)      // default is unlimited
	db.SetConnMaxLifetime(30 * time.Minute) // survives a failover
	if err := db.PingContext(ctx); err != nil {
		return fmt.Errorf("ping db: %w", err)
	}
	srv := &http.Server{
		Addr:              cfg.Addr,
		Handler:           routes(db),
		ReadHeaderTimeout: 5 * time.Second,   // the anti-Slowloris one
		ReadTimeout:       15 * time.Second,
		WriteTimeout:      30 * time.Second,
		IdleTimeout:       60 * time.Second,
	}

	errc := make(chan error, 1)               // buffered: the goroutine never blocks
	go func() { errc <- srv.ListenAndServe() }()

	select {
	case err := <-errc:
		return fmt.Errorf("serve: %w", err)   // could not bind, or a real failure
	case <-ctx.Done():
		slog.Info("signal received, draining")
	}

	grace, cancel := context.WithTimeout(context.Background(), 25*time.Second)
	defer cancel()                            // NOT ctx — that one is cancelled
	return srv.Shutdown(grace)
}
L1 · Beginner

What the code does

  • run returns error; main prints it and exits. Everything else is ordinary code.
  • signal.NotifyContext gives you a context that is cancelled when SIGTERM arrives — no channel plumbing needed.
  • defer db.Close() immediately after a successful open, so there is no path that forgets it.
  • %w wraps: the caller can still match the underlying error with errors.Is.
  • ListenAndServe blocks, so it goes in a goroutine and reports back on a channel.
L2 · Intermediate

Why it is written this way

  • log.Fatal skips every defer. It is os.Exit with a print — connections stay open, buffers stay unflushed, and the function cannot be tested.
  • The shutdown context is a fresh one. Deriving it from the cancelled ctx makes Shutdown return instantly and kills in-flight requests, which is the exact bug this code is avoiding.
  • errc is buffered with capacity one so the serving goroutine can send and exit even after select has already taken the shutdown branch. Unbuffered, it leaks.
  • ReadHeaderTimeout is the one that matters for an exposed service; without it a client can hold a connection open with a slow trickle of header bytes.
  • SetMaxOpenConns defaults to unlimited, which means one traffic spike can exhaust the database’s own connection limit.
L3 · Advanced

What bites in production

  • Shutdown does not touch hijacked or long-lived connections — WebSockets and SSE streams need their own cancellation, usually a context stored per connection.
  • Readiness must flip before the drain starts. Fail the readiness probe, wait one probe interval so the load balancer stops sending traffic, and only then call Shutdown.
  • defer stop() restores default signal handling; a second SIGTERM after that kills the process immediately, which is usually what an impatient operator wants.
  • Background workers need the same treatment. Give them the same ctx and wait for them with an errgroup, or the process exits with work half-done.
  • sql.Open does not connect. Without the explicit PingContext, a bad password becomes a run-time error on the first request rather than a start-up failure.
Go deep · 22

Bounded fan-out with errgroup

The job: call a service once per item for a few hundred items. Unbounded goroutines are cheap for you and expensive for whoever you are calling — and errgroup gives you the bound, the error and the cancellation in one type.

ONE GROUP, ONE CONTEXT, ONE LIMIT 580 ids a plain slice g.SetLimit(20) g.Go blocks past 20 20 goroutines never 580 out[id] = r, under mu a map write is not atomic The limit is memory, sockets and downstream load all at once — and it is one line rather than a hand-rolled worker pool. WHEN ONE OF THEM FAILS worker returns err the first one only group cancels ctx the one from WithContext siblings observe it at their next I/O call g.Wait() → err after all have stopped Cancellation is cooperative: a sibling stuck in a call that ignores its context keeps running, and Wait keeps waiting for it. That is the whole reason every blocking call in the standard library has a …Context variant.
errgroup is a WaitGroup that also carries an error and a context. SetLimit is the bound, WithContext is the cancellation, and Wait is the join — three problems that used to need three hand-written pieces.
func enrichAll(ctx context.Context, ids []string) (map[string]Result, error) {
	g, ctx := errgroup.WithContext(ctx)   // shadowing ctx is idiomatic here
	g.SetLimit(20)                        // at most 20 in flight, ever

	var mu sync.Mutex
	out := make(map[string]Result, len(ids))

	for _, id := range ids {              // Go 1.22+: id is per-iteration
		g.Go(func() error {
			r, err := enrich(ctx, id)     // the cancellable ctx, not the outer one
			if err != nil {
				return fmt.Errorf("enrich %s: %w", id, err)
			}
			mu.Lock()
			defer mu.Unlock()
			out[id] = r
			return nil
		})
	}
	return out, g.Wait()                  // blocks until every goroutine is done
}
// when a partial answer is the right answer, collect instead of cancelling:
	var (
		mu   sync.Mutex
		errs []error
	)
	g := new(errgroup.Group)              // no WithContext — no sibling cancellation
	g.SetLimit(20)
	for _, id := range ids {
		g.Go(func() error {
			r, err := enrich(ctx, id)
			mu.Lock()
			defer mu.Unlock()
			if err != nil {
				errs = append(errs, fmt.Errorf("%s: %w", id, err))
				return nil                // nil: do not stop the others
			}
			out[id] = r
			return nil
		})
	}
	_ = g.Wait()
	return out, errors.Join(errs...)      // one error holding all of them
L1 · Beginner

What the code does

  • errgroup.WithContext returns a group and a derived context that the group cancels as soon as any goroutine returns a non-nil error.
  • g.Go(fn) starts a goroutine; g.Wait() blocks until they have all finished and returns the first error.
  • SetLimit(n) makes g.Go itself block once n are running — the backpressure is at the submission point.
  • A mutex around the map because concurrent map writes are a run-time crash in Go, not a race you get away with.
  • errors.Join combines several errors into one that errors.Is can still search.
L2 · Intermediate

Why it is written this way

  • Pass the group’s ctx to the work, not the original. Using the outer context means the cancellation you carefully set up reaches nobody.
  • Two shapes, chosen deliberately: WithContext for “all of it or none”, a bare Group plus errors.Join for “do as much as you can and tell me what failed”.
  • Pre-size the map with make(..., len(ids)) so it does not rehash while twenty goroutines contend for the lock.
  • Wrap with the id. enrich 8fa3: context deadline exceeded is actionable; context deadline exceeded alone is not.
  • The loop-variable capture bug is gone in Go 1.22+ — but on an older module version this exact code silently enriches the last id n times.
L3 · Advanced

What bites in production

  • SetLimit after Go panics, and a limit of zero deadlocks immediately. Set it once, before the loop.
  • Cancellation does not unblock everything. A CPU-bound loop, a call without a context, or a sync.Mutex wait ignores it entirely — Wait then hangs for as long as that call does.
  • The first error wins, the rest are dropped. If several failures are diagnostically different, collect them; the default hides everything after the first.
  • A panic in a g.Go goroutine takes down the process — errgroup does not recover for you. Recover at the top of the closure if the work is untrusted.
  • Bound by the downstream’s capacity divided by your replica count. A limit of 20 on 30 pods is 600 concurrent calls arriving at one service.
Go deep · 23

An outbound call that cannot hang

The job: call an internal API safely. Go gives you five separate deadlines and two easy ways to leak a connection — and the default http.Client, which has no timeout at all, is the one thing you must never use.

FIVE DEADLINES, THREE OF THEM ON THE TRANSPORT 5s total client.Timeout — the only one that also covers reading the body Dialer.Timeout TLSHandshakeTimeout ResponseHeaderTimeout reading the body — no transport knob at all 1s 1s 2s bounded only by client.Timeout and the request context AND THE TWO LINES EVERY REVIEWER LOOKS FOR defer resp.Body.Close() Closing an unread body abandons the connection. Every call pays a fresh TCP and TLS handshake. io.Copy(io.Discard, resp.Body); Close() Draining first returns the connection to the pool, so the next call reuses it and costs microseconds.
The zero-value http.Client waits forever. Not for a long time — forever. One unresponsive host will consume every goroutine that calls it, which is how a dependency’s bad minute becomes your outage.
// one client for the process; it is safe for concurrent use by design
var client = &http.Client{
	Timeout: 5 * time.Second,                 // dial + TLS + headers + body
	Transport: &http.Transport{
		DialContext:           (&net.Dialer{Timeout: time.Second}).DialContext,
		TLSHandshakeTimeout:   time.Second,
		ResponseHeaderTimeout: 2 * time.Second,
		MaxIdleConnsPerHost:   32,            // default is 2 — the silent bottleneck
		IdleConnTimeout:       90 * time.Second,
	},
}

var (
	ErrRetryable = errors.New("upstream unavailable")
	ErrRejected  = errors.New("upstream rejected the request")
)
func postEntry(ctx context.Context, e Entry, idem string) (Ref, error) {
	body, err := json.Marshal(e)
	if err != nil {
		return Ref{}, fmt.Errorf("marshal entry: %w", err)
	}
	req, err := http.NewRequestWithContext(ctx, http.MethodPost,
		baseURL+"/entries", bytes.NewReader(body))
	if err != nil {
		return Ref{}, fmt.Errorf("build request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", idem)   // same key on every retry

	resp, err := client.Do(req)
	if err != nil {
		return Ref{}, fmt.Errorf("post entry: %w", err)  // wraps ctx deadlines
	}
	defer func() {
		_, _ = io.Copy(io.Discard, resp.Body) // drain, then close, in that order
		_ = resp.Body.Close()
	}()

	switch {
	case resp.StatusCode >= 500:
		return Ref{}, fmt.Errorf("ledger %d: %w", resp.StatusCode, ErrRetryable)
	case resp.StatusCode >= 400:
		return Ref{}, fmt.Errorf("ledger %d: %w", resp.StatusCode, ErrRejected)
	}

	var ref Ref
	if err := json.NewDecoder(resp.Body).Decode(&ref); err != nil {
		return Ref{}, fmt.Errorf("decode ref: %w", err)
	}
	return ref, nil
}
L1 · Beginner

What the code does

  • One package-level client, reused. It holds the connection pool; a new one per call throws that pool away.
  • NewRequestWithContext, never http.Post — the context is how a cancelled request actually stops.
  • defer drain-then-close on the body, immediately after checking err.
  • Sentinel errors wrapped with %w so the caller writes errors.Is(err, ErrRetryable) instead of re-parsing a status code.
  • A non-2xx is not an err from Do. You have to check StatusCode yourself.
L2 · Intermediate

Why it is written this way

  • Timeouts are layered on purpose. client.Timeout is the backstop; the transport’s three let you fail fast on a dead host while still allowing a slow response.
  • The context deadline beats them all, because it also propagates: the callee learns how long the caller is still willing to wait.
  • MaxIdleConnsPerHost defaults to 2. On a service that talks mostly to one host, that is the single most common cause of unexplained latency.
  • Retryable versus rejected is decided here, at the boundary, not by every caller inspecting numbers.
  • Wrapping preserves context.DeadlineExceeded, so a timeout is still matchable several layers up.
L3 · Advanced

What bites in production

  • A leaked body leaks a goroutine and a socket. It shows up as a slow climb in goroutine count — pprof will point straight at net/http.
  • Draining a huge body is worse than dropping it. Cap it: io.CopyN(io.Discard, resp.Body, 64<<10), then close.
  • Retrying is only safe with a body you can rewind. A bytes.Reader can be re-read; an io.Pipe or a file stream cannot, so build the request inside the retry loop.
  • client.Timeout cannot be overridden per request — that is exactly what the request context is for.
  • Sharing a Transport across clients is good; creating a Transport per request is a connection-pool leak with a slow, confusing failure mode.
Go deep · 24

A repository over database/sql

The job: write two rows in one transaction and read one back. The interesting parts are the exits — a deferred rollback that is a no-op after a successful commit, and sql.ErrNoRows, which is the standard library asking you to decide what “missing” means in your domain.

ONE BeginTx, THREE WAYS OUT BeginTx(ctx) defer tx.Rollback() return tx.Commit() rollback then no-ops return err the defer rolls back panic the defer still runs Without that one deferred line, an early return leaves the transaction open until the connection is recycled — holding its locks the whole time. READING 200 ORDERS AND THEIR ITEMS the loop SELECT * FROM orders → 1 round trip for each: SELECT … WHERE id=$1 → 200 more 201 × 0.4 ms of network = 80 ms of waiting. one query SELECT * FROM orders → 1 round trip WHERE order_id = ANY($1) → 1 more Two round trips, then group them in Go. Neither query is slow. The difference is entirely the number of times you cross the network, which is why an ORM’s query log is worth reading.
The deferred rollback is unconditional on purpose. After Commit it returns sql.ErrTxDone and changes nothing — which is a far better trade than one early return that forgets to clean up.
func (r *Repo) Place(ctx context.Context, o Order) (uuid.UUID, error) {
	tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
	if err != nil {
		return uuid.Nil, fmt.Errorf("begin: %w", err)
	}
	defer tx.Rollback() //nolint:errcheck // no-op once Commit has run

	var id uuid.UUID
	err = tx.QueryRowContext(ctx,
		`INSERT INTO orders (email, total) VALUES ($1, $2) RETURNING id`,
		o.Email, o.Total,
	).Scan(&id)
	if err != nil {
		return uuid.Nil, fmt.Errorf("insert order: %w", err)
	}

	// one INSERT per item is one network round trip per item — fine for small
	// baskets; batch with a multi-row VALUES or pgx.CopyFrom when they grow
	for _, it := range o.Items {
		if _, err := tx.ExecContext(ctx,
			`INSERT INTO items (order_id, sku, qty) VALUES ($1, $2, $3)`,
			id, it.SKU, it.Qty,
		); err != nil {
			return uuid.Nil, fmt.Errorf("insert item %s: %w", it.SKU, err)
		}
	}
	return id, tx.Commit()
}
func (r *Repo) Get(ctx context.Context, id uuid.UUID) (Order, error) {
	var o Order
	err := r.db.QueryRowContext(ctx,
		`SELECT id, email, total FROM orders WHERE id = $1`, id,
	).Scan(&o.ID, &o.Email, &o.Total)

	switch {
	case errors.Is(err, sql.ErrNoRows):
		return Order{}, ErrNotFound       // a domain error, not a driver one
	case err != nil:
		return Order{}, fmt.Errorf("get order %s: %w", id, err)
	}
	return o, nil
}

// rows.Err() is not optional — a Rows loop that ends early because the
// connection dropped looks exactly like one that ran out of rows:
	for rows.Next() { /* … */ }
	if err := rows.Err(); err != nil {
		return nil, fmt.Errorf("iterate items: %w", err)
	}
L1 · Beginner

What the code does

  • Every call takes a ctx — QueryContext, ExecContext, BeginTx. The non-context versions exist only for compatibility.
  • Placeholders, always. $1 on Postgres, ? on MySQL — never string concatenation, which is how injection happens.
  • QueryRow(...).Scan(&x) is the one-row shape; it defers the error until Scan.
  • RETURNING id gets the generated key back in the same round trip.
  • sql.ErrNoRows is a normal outcome of a lookup, not a failure of the database.
L2 · Intermediate

Why it is written this way

  • The deferred rollback is written on the line after BeginTx, so no future early return can escape it.
  • Translate ErrNoRows at the repository boundary. The layer above should not import database/sql to know a thing was missing.
  • rows.Err() after every rows.Next() loop — and defer rows.Close(), or the connection never returns to the pool.
  • Wrap with identifying detail: which order, which SKU. The stack trace is not in the error, so the context has to be.
  • Keep transactions short and free of network calls. Every millisecond inside BeginTx is a millisecond of held locks.
L3 · Advanced

What bites in production

  • A Tx pins one connection for its whole life. Long transactions plus a small pool is a deadlock waiting for traffic.
  • Retry on serialisation failure if you use LevelSerializable: Postgres returns 40001 and expects the application to try again, which means the whole function must be safe to re-run.
  • Prepared statements are per-connection. db.Prepare re-prepares on whichever connection it lands on; under a pool that is rarely the win people expect.
  • context cancellation mid-transaction leaves the driver to roll back asynchronously — the row locks can outlive your handler by a moment.
  • SetConnMaxLifetime matters behind a proxy or a failover. Without it, connections pinned to a demoted primary survive the failover and keep failing.
Go deep · 25

Table-driven tests, fakes and httptest

The job: test the three things above without a network. Go’s answer is unusually consistent — a map of cases, a subtest per case, an interface for the seam, and a real HTTP server when the thing under test is the HTTP client itself.

ONE TABLE, ONE SUBTEST EACH, ALL IN PARALLEL tests := map[string]struct "happy path" "ledger down" "ledger rejects" "empty basket" t.Run("happy path", …) → PASS t.Run("ledger down", …) → PASS t.Run("ledger rejects", …) → PASS t.Run("empty basket", …) → FAIL The failure names the case, not a line number — and -run can re-run just that one by name. TWO SEAMS, AND WHAT EACH ONE ACTUALLY CATCHES an interface fake Microseconds. Tests your branching and your error handling. Cannot catch a wrong header. httptest.NewServer Milliseconds, real sockets. Catches the header, the JSON tag and the status-code mapping.
Name the cases and the failure output writes itself. --- FAIL: TestPlace/empty_basket tells you what broke before you have opened the file; a table indexed by number tells you it was case three.
func TestPlace(t *testing.T) {
	t.Parallel()

	tests := map[string]struct {
		order   Order
		ledger  fakeLedger        // the seam: an interface, not a mock library
		wantErr error
	}{
		"happy path":     {order: valid, ledger: fakeLedger{ref: "led_1"}},
		"ledger down":    {order: valid, ledger: fakeLedger{err: ErrRetryable}, wantErr: ErrRetryable},
		"ledger rejects": {order: valid, ledger: fakeLedger{err: ErrRejected}, wantErr: ErrRejected},
		"empty basket":   {order: Order{}, wantErr: ErrInvalid},
	}

	for name, tc := range tests {
		t.Run(name, func(t *testing.T) {
			t.Parallel()
			_, err := Place(t.Context(), tc.ledger, tc.order)   // 1.24+: t.Context()
			if !errors.Is(err, tc.wantErr) {
				t.Fatalf("Place() error = %v, want %v", err, tc.wantErr)
			}
		})
	}
}
// when the thing under test IS the HTTP client, use a real server
func TestPostEntry(t *testing.T) {
	srv := httptest.NewServer(http.HandlerFunc(
		func(w http.ResponseWriter, r *http.Request) {
			if got := r.Header.Get("Idempotency-Key"); got == "" {
				t.Errorf("missing idempotency key")   // Errorf: this is the server goroutine
			}
			w.Header().Set("Content-Type", "application/json")
			w.WriteHeader(http.StatusCreated)
			_, _ = io.WriteString(w, `{"ref":"led_123"}`)
		}))
	t.Cleanup(srv.Close)

	ref, err := postEntry(t.Context(), Entry{Amount: 100}, "idem-1")
	if err != nil {
		t.Fatalf("postEntry() = %v", err)
	}
	if ref.ID != "led_123" {
		t.Errorf("ref.ID = %q, want led_123", ref.ID)   // got, want — in that order
	}
}
L1 · Beginner

What the code does

  • A map from case name to inputs and expectations, then one t.Run per entry — the standard Go test shape.
  • t.Fatalf stops this subtest; t.Errorf records a failure and continues. Use Fatal when the rest of the test cannot run.
  • errors.Is, not ==, because the error you get back is wrapped.
  • httptest.NewServer starts a real HTTP server on a random port and hands you its URL.
  • No assertion library needed — if got != want { t.Errorf(...) } is the convention.
L2 · Intermediate

Why it is written this way

  • A map, not a slice, so cases are named, unordered, and impossible to reference by index.
  • t.Parallel() twice — once for the parent, once inside each subtest. Only then do the cases actually overlap.
  • The fake is a struct implementing your own interface. Go’s implicit interfaces make a five-line fake cheaper than any mocking framework.
  • t.Cleanup over defer in helpers: it runs at the end of the test even when registered several frames down.
  • Message order is got, want. Every Go codebase reads it that way, and reversing it costs a reader real time.
L3 · Advanced

What bites in production

  • t.Fatal from a non-test goroutine does nothing useful — it stops that goroutine, not the test. Inside an httptest handler, always t.Errorf.
  • Run the suite with -race in CI. The detector only reports races it actually observes, so parallel subtests make it meaningfully more likely to see one.
  • Interfaces belong to the consumer. Define the narrow one you need where you use it, not next to the implementation — that is what keeps fakes small.
  • testing/synctest (1.25) fakes time for concurrent code, so a test of a one-minute backoff no longer takes a minute.
  • Golden files plus -update for large outputs, and testcontainers or dockertest when only a real database will do — behind testing.Short() so the fast suite stays fast.
Part nine

Testing, profiling, reviewing

The last third of Go fluency is not language features. It is knowing which test proves what, which profile answers which question, and what to look for in someone else’s diff.

Go deep · 26

Tests that catch the things Go actually gets wrong

Table-driven tests are the idiom, but the leverage is in the three flags and one library that catch concurrency bugs no assertion will.

ToolCatchesWhere to run it
go test -raceUnsynchronised access to shared memory — the bugs that survive code review.CI, always. It costs ~5× runtime; worth it.
goleakGoroutines still alive at the end of a test: the leak, found at the moment it is introduced.TestMain of every package that starts goroutines.
-count=1Cached results hiding a flake.Any time you are chasing an intermittent failure.
-benchmemAllocation regressions, which are far more stable to compare than timings.Benchmarks on hot paths, tracked over time.
Fakes over mocksTests that assert on interactions instead of behaviour, and break on every refactor.An in-memory implementation of your own small interface.
testing/synctestFlaky time-dependent tests — a fake clock instead of time.Sleep.Anything with timeouts, retries or tickers (experimental in Go 1.24, stable in 1.25).

The test that pays for itself

For any component with goroutines, write one test that starts it, cancels the context, and asserts it returns — with goleak active. It is five lines, and it fails the day someone adds a select without a ctx.Done() case, which is the day the bug is cheap to fix rather than three months later at 3am.

func BenchmarkParse(b *testing.B) {
	data := load()
	b.ReportAllocs()
	for b.Loop() {               // 1.24+: replaces for i := 0; i < b.N; i++ —
		_, _ = Parse(data)       // and setup above is excluded automatically,
	}                            // so b.ResetTimer() is no longer needed
}

func FuzzParse(f *testing.F) {
	f.Add([]byte(`{"id":1}`))              // a seed corpus entry
	f.Fuzz(func(t *testing.T, in []byte) {
		_, _ = Parse(in)                   // the property: it must never panic
	})
}

//  $ go test -race -cover ./...              the two flags CI must always pass
//  $ go test -run TestPlace/empty_basket     one subtest, by name
//  $ go test -bench=. -benchmem -count=10 | benchstat -
//  $ go test -fuzz=FuzzParse -fuzztime=60s   findings land in testdata/
Go deep · 27

Which profile answers which question

Go ships production-grade profiling in the standard library. The skill is picking the right profile for the symptom instead of reading a CPU profile for a problem that is not CPU.

SYMPTOM PROFILE WHAT YOU ARE LOOKING FOR CPU pinned, throughput flat profile the widest frame in the flame graph memory grows and never returns heap · inuse_space what is retained, and by whom GC busy, latency spikes periodically heap · alloc_objects allocation rate, not live size goroutine count climbing forever goroutine?debug=2 thousands parked on the same line slow, but CPU is idle block · mutex where goroutines wait, and on what jitter with no obvious hot spot runtime/trace scheduler latency and GC pauses Set GOMEMLIMIT to about 90% of the container memory limit: the GC works harder as you approach it instead of the kernel OOM-killing you. And since Go 1.25 GOMAXPROCS respects the container's CPU limit by itself — the automaxprocs import is only for older toolchains.
Two of these rows are the same bug in disguise. A climbing goroutine count and growing memory are usually one leak — each parked goroutine retains its stack and everything it captured. Start with the goroutine profile: it names the exact line, which the heap profile only implies.
// register the handlers on a private listener — never on your public mux
import _ "net/http/pprof"

go func() {
	_ = http.ListenAndServe("localhost:6060", nil)   // localhost only
}()

//  heap, live objects — for a leak:
//  $ go tool pprof -http=:8080 localhost:6060/debug/pprof/heap
//
//  CPU, 30 seconds under load — for a hot path:
//  $ go tool pprof localhost:6060/debug/pprof/profile?seconds=30
//
//  goroutines — the count climbing is a leaked body or an unclosed channel:
//  $ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -40
//
//  and the only honest way to claim an improvement:
//  $ go tool pprof -base before.pb.gz after.pb.gz
Go deep · 28

The review checklist

Twelve things worth a second look in any Go diff. Most are five seconds to check and hours to debug later.

CONCURRENCY Every go statement has a stop condition someone can name. Every blocking select has a ctx.Done() case. Fan-out is bounded — a pool or a semaphore, not one goroutine per item. The channel’s sender closes it, and only once. ERRORS AND RESOURCES Errors wrapped with %w, not %v; logged once, at the top. Every defer Close() present — body, rows, file, and rows.Err() checked. No defer inside a loop that runs many times. Outbound calls take a context and a timeout — no DefaultClient. CORRECTNESS A returned slice or map is a copy, or documented as shared. No nil typed pointer returned as an error or interface. Pointer receivers used consistently across the type’s methods. Struct copied by value contains no mutex (go vet catches it). The two questions behind all twelve “Who owns this, and when does it end?” — for goroutines, channels, connections and memory alike. “What does this do when the thing it calls never answers?” — which is Pillar A, asked in Go.
Most of these are mechanical, and the mechanical ones should not be human work. go vet, staticcheck, errcheck and golangci-lint in CI catch about half the list, which frees review for the two questions in the box — the ones no linter can answer.
# the sixty seconds before you open the pull request
gofmt -l .                 # anything printed is unformatted
go vet ./...               # the compiler's own lint: printf args, lock copies
go test -race -count=1 ./...   # -count=1 defeats the test result cache
staticcheck ./...          # the one third-party linter worth the dependency
govulncheck ./...          # CVEs in the module graph, filtered by call graph

# then read your own diff — these catch what the tools do not:
git diff --cached | grep -nE 'context\.(TODO|Background)\(\)'
git diff --cached | grep -nE '_ = err|err != nil \{\s*\}'
git diff --cached | grep -nE 'go func'    # who waits for it, and what cancels it?
Part ten

A whole service, file by file

The cookbook was five jobs in isolation. This is the repository they add up to — one small service, built in the order you would actually build it, with every file that has to exist before anyone will let you deploy it. The domain is a link shortener, chosen because it is small enough to hold in your head and still needs every production concern there is: validation, a database and a migration, idempotency under a race, authentication, rate limiting, structured logs, two health checks, tests that run in CI, and an image with nothing in it but your binary. It is the same service the Python and Java pages build, so you can read all three and see what the language actually changes — and here it changes a lot, because most of what those two reach for a framework to do, Go does with the standard library.

Go deep · 29

Where the files go, and why

Go has one layout rule the compiler actually enforces, and it is the useful one: nothing outside this module may import anything under internal/. That single directory name does the job Java needs an architecture test for. What it does not do is police the layers inside the module, which is what the rest of this topic is about.

shortlink/
  go.mod                  the module path, the Go version, the direct deps
  go.sum                  the hashes. Committed, and verified on every build.
  Makefile                the four commands everyone needs, named the same way
  compose.yaml            Postgres and Redis for local work, nothing else
  Dockerfile              two stages; the second one contains a binary
  .golangci.yaml          the linters, and the import rule from the table below

  cmd/
    shortlinkd/
      main.go             twelve lines. Calls run, prints the error, exits 1.
      run.go              wiring: config, pool, redis, routes, signals

  internal/               the compiler's own boundary. Nothing outside imports it.
    config/
      config.go           every variable the service reads, parsed once
    shortlink/            the domain. Imports the standard library only.
      link.go             Link, Code, and the URL parse that is a security control
      code.go             crypto/rand over an alphabet with no confusable pairs
      service.go          the use cases. Knows no HTTP and no SQL.
      errors.go           the sentinels every layer above matches on
    postgres/
      pool.go             one pool per process, sized on purpose
      store.go            the only file in the repository that contains SQL
      migrate.go          goose, over migrations embedded in the binary
      migrations/
        0001_create_links.sql
    ratelimit/
      redis.go            one INCR and one PEXPIRE, atomically
    httpapi/
      server.go           routes, middleware, and the five timeouts
      links.go            the management handlers
      redirect.go         GET /{code}: the only public route
      auth.go             API keys, hashed, compared in constant time
      problem.go          one error shape out, and the map that produces it
      middleware.go       request id, logging, recovery

  internal/shortlink/code_test.go       pure, fast, no container
  internal/httpapi/problem_test.go      every sentinel has a status
  internal/postgres/store_test.go       a real Postgres, and the race
  internal/httpapi/api_test.go          through the real mux, over httptest

  .github/workflows/ci.yaml

Two decisions in that tree are worth defending in a review. There is no pkg/ — it is a convention some repositories carry and it means nothing to the toolchain, whereas internal/ means something exact. And the packages are named for what they are rather than for what they contain: postgres, not repository; httpapi, not handlers. A Go package name is a prefix on every identifier you export from it, so postgres.Store reads and repository.LinkRepository stutters.

PackageMay importMust never import
internal/shortlinkThe standard library, and nothing elsepgx, net/http, redis. It is the part whose tests need no container.
internal/postgresshortlink, pgxnet/http. A store that writes a status code cannot be called from a worker.
internal/httpapiEverythingNothing — but it should hold no branching that is not about HTTP.
internal/configThe standard libraryEverything else. It is imported by cmd, not by the layers.
cmd/shortlinkdAll of the aboveNothing. It is the only place that knows the whole graph.

The compiler will not check those rows for you, so a linter does. depguard is the direct equivalent of the rule Java needs ArchUnit for, and it costs eight lines:

# .golangci.yaml
linters:
  enable:
    - depguard        # the import rule from the table above
    - errcheck        # an ignored error is a decision; make it an explicit one
    - errorlint       # err == ErrX instead of errors.Is is a wrapping bug
    - bodyclose       # a leaked response body is a leaked connection
    - contextcheck    # a handler that calls context.Background() has lost the deadline
    - sqlclosecheck
    - gosec
    - revive

linters-settings:
  depguard:
    rules:
      domain:
        files: ["**/internal/shortlink/**"]
        deny:
          - pkg: github.com/jackc/pgx
            desc: the domain does not know a database exists
          - pkg: net/http
            desc: the domain does not know it is being served over HTTP
      store:
        files: ["**/internal/postgres/**"]
        deny:
          - pkg: net/http
            desc: a store that writes status codes cannot be used by a worker
// go.mod -- seven direct dependencies, and four of those lines are two
// libraries: jackc for Postgres and testcontainers for the tests.
module github.com/jaspreet/shortlink

go 1.24

require (
	github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438
	github.com/jackc/pgx/v5 v5.7.1
	github.com/pressly/goose/v3 v3.22.1
	github.com/redis/go-redis/v9 v9.7.0
	github.com/testcontainers/testcontainers-go v0.34.0
	github.com/testcontainers/testcontainers-go/modules/postgres v0.34.0
	golang.org/x/sync v0.10.0
)

// What is absent is the point. There is no web framework, because Go 1.22's
// ServeMux routes by method and path parameter and that was the only reason
// left to import one. There is no logging library, because log/slog is in the
// standard library and emits JSON. There is no configuration library, because
// os.Getenv and a struct are less code than the library's tutorial. There is
// no dependency injection container, because run() is one.
//
// Every dependency is a thing you will be reading the source of at 2am one
// day. Seven is a number you can hold. Seventy is a supply chain.
# Makefile -- four verbs, the same four in every repository you own.
# CI runs these exact targets, so a red build reproduces locally in one line.
.PHONY: up check test run image

up:                     ## Postgres and Redis, nothing else
	docker compose up -d

check:                  ## what a reviewer would comment on, before they have to
	go build ./...
	go vet ./...
	golangci-lint run

test:                   ## -race is not optional in a language with goroutines
	go test -race -count=1 ./...

run: up                 ## against the compose services
	go run ./cmd/shortlinkd

image:                  ## exactly what CI builds
	docker build -t shortlink:$(shell git rev-parse --short HEAD) .

-race is the single highest-value flag in the language, and it is off by default

The race detector instruments every memory access and reports the two goroutines, with stacks, the moment one writes what another reads without synchronisation. It costs perhaps five times the CPU and ten times the memory, which is why it is not on in production — and it is why go test -race belongs in CI on every package, every run. A data race in Go is not a hypothetical: an unsynchronised map write is a runtime crash that takes the whole process down, and it will happen for the first time under load, in production, in the middle of the night. The detector only reports races it actually observes, so it is exactly as good as your test coverage of concurrent paths — which is one more reason the idempotency test later in this part starts sixteen goroutines rather than two.

Go deep · 30

Configuration, read once, and every error at once

A service reads its environment exactly once, on the way up, into a struct. The failure you are designing against is the mistyped variable discovered on the tenth request instead of in the first second — and Go lets you go one better than the other two languages here, because errors.Join means one restart tells you everything that is wrong rather than the first thing.

// internal/config/config.go
package config

import (
	"errors"
	"fmt"
	"net/url"
	"os"
	"strconv"
	"time"
)

// Config is every setting this service has. It is a plain struct with no
// tags and no library behind it: the whole parser is sixty lines below and
// you can read all of it, which is more than can be said for the tutorial
// of any configuration package you might have imported instead.
type Config struct {
	Addr          string
	DatabaseURL   string
	RedisURL      string
	PublicBaseURL *url.URL
	CodeLength    int
	DefaultTTL    time.Duration
	RateLimit     int
	RateWindow    time.Duration
}

func Load() (Config, error) {
	var errs []error

	cfg := Config{
		Addr:        optional("SHORTLINK_ADDR", ":8080"),
		DatabaseURL: required(&errs, "DATABASE_URL"),
		RedisURL:    required(&errs, "REDIS_URL"),
		CodeLength:  integer(&errs, "SHORTLINK_CODE_LENGTH", 7),
		DefaultTTL:  duration(&errs, "SHORTLINK_DEFAULT_TTL", 90*24*time.Hour),
		RateLimit:   integer(&errs, "SHORTLINK_RATE_LIMIT", 60),
		RateWindow:  duration(&errs, "SHORTLINK_RATE_WINDOW", time.Minute),
	}

	if raw := required(&errs, "SHORTLINK_PUBLIC_BASE_URL"); raw != "" {
		u, err := url.Parse(raw)
		switch {
		case err != nil:
			errs = append(errs, fmt.Errorf("SHORTLINK_PUBLIC_BASE_URL: %w", err))
		case u.Scheme == "" || u.Host == "":
			errs = append(errs, errors.New("SHORTLINK_PUBLIC_BASE_URL: want an absolute URL"))
		default:
			cfg.PublicBaseURL = u
		}
	}
	if cfg.CodeLength < 4 || cfg.CodeLength > 32 {
		errs = append(errs, fmt.Errorf("SHORTLINK_CODE_LENGTH: want 4..32, got %d", cfg.CodeLength))
	}

	// One error listing every problem, not the first one. A deploy that fails
	// four times because four variables are missing is four deploys; this is
	// one, and the log line names all four.
	return cfg, errors.Join(errs...)
}

func optional(key, fallback string) string {
	if v := os.Getenv(key); v != "" {
		return v
	}
	return fallback
}

func required(errs *[]error, key string) string {
	v := os.Getenv(key)
	if v == "" {
		*errs = append(*errs, fmt.Errorf("%s is required", key))
	}
	return v
}

func integer(errs *[]error, key string, fallback int) int {
	raw := os.Getenv(key)
	if raw == "" {
		return fallback
	}
	n, err := strconv.Atoi(raw)
	if err != nil {
		*errs = append(*errs, fmt.Errorf("%s: %w", key, err))
		return fallback
	}
	return n
}

func duration(errs *[]error, key string, fallback time.Duration) time.Duration {
	raw := os.Getenv(key)
	if raw == "" {
		return fallback
	}
	d, err := time.ParseDuration(raw)   // "90m", "24h", "1500ms"
	if err != nil {
		*errs = append(*errs, fmt.Errorf("%s: %w", key, err))
		return fallback
	}
	return d
}

Note which settings have a fallback and which do not. A port has a sensible default and a database connection string does not, because a default DSN is how a service quietly connects to something that happens to be listening. The rule generalises: anything that is a secret, or that names another system, has no default. Everything else may have one, and the default should be the value you would pick in production.

$ DATABASE_URL= REDIS_URL= go run ./cmd/shortlinkd
shortlinkd: load config: DATABASE_URL is required
REDIS_URL is required
SHORTLINK_PUBLIC_BASE_URL is required
$ echo $?
1
# Three seconds after the deploy started, and all three named at once.
# errors.Join prints its members one per line, which is exactly what you
# want a start-up failure to look like in a log aggregator.
# compose.yaml -- the two services this needs, and nothing else. Not a
# production topology; a way for a new engineer to be running in one command.
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: shortlink
      POSTGRES_PASSWORD: local-only-not-a-secret
      POSTGRES_DB: shortlink
    ports: ["5432:5432"]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U shortlink"]
      interval: 2s
      timeout: 2s
      retries: 15

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 2s
      timeout: 2s
      retries: 15
Go deep · 31

The core, which imports only the standard library

This package knows nothing about HTTP, SQL or Redis, which is why its tests run in milliseconds and why the rules survive the day somebody swaps the database. It is also where Go differs most honestly from the other two languages in this series: Go cannot make an invalid value unconstructable, so the discipline is one parse function at the edge, a re-check in the domain, and a constraint in the database.

// internal/shortlink/link.go
package shortlink

import (
	"fmt"
	"net/url"
	"regexp"
	"strings"
	"time"
)

// Code is the code in the short URL. A named string type, not a bare
// string: it costs nothing at run time and it means the compiler rejects
// ParseCode(ownerID) even though both are strings underneath.
//
// Be honest about the limit. Code("") and Code("NOT A CODE") both compile,
// because a conversion between a named type and its underlying type is
// always legal in Go. There is no equivalent of a private constructor. So
// the invariant is held by convention -- ParseCode is the only way values
// enter -- backed by a re-check wherever it matters and a unique, checked
// column in Postgres. That is weaker than a Java record and it is worth
// knowing that it is weaker.
type Code string

func (c Code) String() string { return string(c) }

var codeShape = regexp.MustCompile(`^[a-z0-9]{4,32}$`)

func ParseCode(raw string) (Code, error) {
	c := strings.ToLower(strings.TrimSpace(raw))
	if !codeShape.MatchString(c) {
		return "", fmt.Errorf("%w: %q is not a short code", ErrInvalid, raw)
	}
	return Code(c), nil
}

// Link is the thing itself. No struct tags: this type is not the wire
// format and it is not the table either, and the day somebody adds
// `json:"..."` here is the day the API and the schema became the same
// object and can no longer change independently.
type Link struct {
	Code      Code
	Target    *url.URL
	OwnerID   string
	CreatedAt time.Time
	ExpiresAt time.Time
}

func (l Link) Expired(now time.Time) bool {
	return !l.ExpiresAt.IsZero() && !now.Before(l.ExpiresAt)
}

const maxTargetLen = 2048

// ParseTarget is a security control wearing the clothes of a format check.
// url.Parse is deliberately permissive: it will parse "javascript:alert(1)"
// without complaint and hand you a URL whose Scheme is "javascript". If you
// then put that in a Location header, you have built an XSS vector on your
// own domain and handed out short links to it.
func ParseTarget(raw string) (*url.URL, error) {
	raw = strings.TrimSpace(raw)
	switch {
	case raw == "":
		return nil, fmt.Errorf("%w: url is required", ErrInvalid)
	case len(raw) > maxTargetLen:
		return nil, fmt.Errorf("%w: url longer than %d bytes", ErrInvalid, maxTargetLen)
	}

	u, err := url.Parse(raw)
	if err != nil {
		return nil, fmt.Errorf("%w: %s", ErrInvalid, err)
	}
	switch strings.ToLower(u.Scheme) {
	case "http", "https":
	default:
		return nil, fmt.Errorf("%w: only http and https can be shortened", ErrInvalid)
	}
	if u.Host == "" {
		return nil, fmt.Errorf("%w: url has no host", ErrInvalid)
	}
	if u.User != nil {
		// http://user:pass@host is a credential in a database column.
		return nil, fmt.Errorf("%w: credentials in a url are not stored", ErrInvalid)
	}
	return u, nil
}
// internal/shortlink/code.go
package shortlink

import (
	"crypto/rand"
	"fmt"
)

// Thirty-two characters, not thirty-six: 0/o, 1/l and their friends are
// gone so a code read off a slide or down a phone survives the trip.
//
// Thirty-two is also a power of two, and that is not a coincidence. Folding
// a random byte into an alphabet with `b % len(alphabet)` is only uniform
// when the length divides 256. With 36 characters the first four would come
// up about 12% more often than the rest -- a bias that is invisible in
// testing, real in aggregate, and entirely avoidable by choosing 32.
const alphabet = "23456789abcdefghijkmnpqrstuvwxyz"

func NewCode(n int) (Code, error) {
	b := make([]byte, n)
	if _, err := rand.Read(b); err != nil {   // crypto/rand, never math/rand
		return "", fmt.Errorf("read random bytes: %w", err)
	}
	for i := range b {
		b[i] = alphabet[int(b[i])%len(alphabet)]
	}
	return Code(b), nil
}

// math/rand would be a real vulnerability rather than a style mistake: its
// output is predictable from a handful of observations, so anybody who
// created three links could enumerate everybody else's. Recent Go has
// crypto/rand.Text() for the case where you do not care which alphabet you
// get, which is most cases -- this one cares, so it does the fold itself.
//
// 32^7 is about 34 billion codes. With ten million links already stored, a
// fresh code collides roughly once in 3,400 inserts. Rare, but not never,
// and "rare" is the failure mode that reaches production untested. The
// unique index catches it and the service retries; see two topics down.
// The generator does not have to be perfect, it has to be unpredictable.
// internal/shortlink/errors.go
package shortlink

import "errors"

// Every failure this service raises on purpose. Sentinels, matched with
// errors.Is, so a caller can decide without string-matching a message and
// without importing the package that produced it.
var (
	ErrInvalid          = errors.New("invalid request")
	ErrNotFound         = errors.New("link not found")
	ErrExpired          = errors.New("link expired")
	ErrCodeTaken        = errors.New("code already in use")
	ErrIdempotencyClash = errors.New("concurrent request with the same idempotency key")
	ErrNoCode           = errors.New("no free code")
)

// Go has no sealed types, so nothing stops somebody adding a seventh
// sentinel and forgetting to map it to a status code -- which is the one
// guarantee the Java version gets from the compiler. This slice buys it
// back at the cost of one line per error: the HTTP layer's test walks it
// and fails if any member has no status. It is a worse mechanism than
// exhaustiveness checking and it catches the same bug.
var All = []error{
	ErrInvalid, ErrNotFound, ErrExpired,
	ErrCodeTaken, ErrIdempotencyClash, ErrNoCode,
}

Validating the host is not the same as blocking a private address

It is tempting to add a check here that refuses 127.0.0.1, 10.0.0.0/8 and 169.254.169.254, and for a service that ever fetches the target — to unfurl a preview, to check the link is alive — you must, or your shortener is a proxy into your own VPC with the cloud metadata endpoint one request away. But do it at fetch time, against the resolved IP, not here against the hostname: a name that resolves to a public address when the link is created can resolve to a private one when it is followed, and no check written at creation time can see that. In Go the tool for it is a net.Dialer with a Control function that inspects the address the resolver actually returned and refuses to connect. This service only ever hands the URL back to a browser, so the parse above is the whole control — and the day somebody adds a preview fetcher is the day this note becomes code.

Go deep · 32

The schema, the pool, and migrations inside the binary

Three things with three different lifetimes: the schema, which the migrations own and the code is only allowed to assume; the pool, which is created once per process and sized on purpose; and the connection, which belongs to one query and is handed back. Go makes the third one easy to get wrong in exactly one way, and the linter in the last topic exists to catch it.

-- internal/postgres/migrations/0001_create_links.sql
-- goose runs this once, in order, and records that it did. Once it has run
-- anywhere but your laptop it is immutable: a fix is 0002, always.

-- +goose Up
CREATE TABLE links (
    id              BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    code            TEXT        NOT NULL,
    target_url      TEXT        NOT NULL,
    owner_id        TEXT        NOT NULL,
    idempotency_key TEXT,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at      TIMESTAMPTZ,

    -- Named, not left to Postgres. The store matches on this name to tell
    -- "that code is taken" apart from "that request already happened", and
    -- a generated name is a name somebody will change by accident.
    CONSTRAINT links_code_key UNIQUE (code)
);

-- Idempotency as a constraint rather than a check in application code.
-- Partial, because most links carry no key and NULLs are not equal to one
-- another -- without the WHERE clause this index would do nothing at all.
CREATE UNIQUE INDEX links_owner_idempotency_key
    ON links (owner_id, idempotency_key)
    WHERE idempotency_key IS NOT NULL;

CREATE INDEX links_owner_created_at ON links (owner_id, created_at DESC);

CREATE TABLE api_keys (
    id         BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    owner_id   TEXT NOT NULL,
    label      TEXT NOT NULL,
    key_hash   TEXT NOT NULL,   -- sha-256 hex. Never the key itself.
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    revoked_at TIMESTAMPTZ,

    CONSTRAINT api_keys_key_hash_key UNIQUE (key_hash)
);

-- +goose Down
DROP TABLE api_keys;
DROP TABLE links;
// internal/postgres/migrate.go
package postgres

import (
	"context"
	"database/sql"
	"embed"
	"fmt"

	_ "github.com/jackc/pgx/v5/stdlib"   // registers the "pgx" database/sql driver
	"github.com/pressly/goose/v3"
)

// The migrations are compiled into the binary. That is not a convenience:
// it means the image is one file, the version of the schema is the version
// of the binary, and there is no way to deploy a container whose SQL and
// whose code disagree because somebody forgot to copy a directory.
//
//go:embed migrations/*.sql
var migrations embed.FS

func Migrate(ctx context.Context, dsn string) error {
	// goose wants a database/sql handle; pgx ships one. This is the only
	// place in the service that opens one, and it is closed before the
	// pool below is created -- migrating and serving are separate jobs
	// even when they happen in the same process.
	db, err := sql.Open("pgx", dsn)
	if err != nil {
		return fmt.Errorf("open for migrate: %w", err)
	}
	defer db.Close()

	goose.SetBaseFS(migrations)
	if err := goose.SetDialect("postgres"); err != nil {
		return fmt.Errorf("goose dialect: %w", err)
	}
	if err := goose.UpContext(ctx, db, "migrations"); err != nil {
		return fmt.Errorf("migrate: %w", err)
	}
	return nil
}
// internal/postgres/pool.go
package postgres

import (
	"context"
	"fmt"
	"time"

	"github.com/jackc/pgx/v5/pgxpool"
)

// One pool for the process, and it is the real concurrency limit of this
// service. Goroutines are free; Postgres connections are not, and a
// thousand goroutines behind a pool of ten means nine hundred and ninety
// of them are waiting -- which is the correct design, because a database
// has a ceiling and a pool is how you respect it rather than discover it.
func NewPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
	cfg, err := pgxpool.ParseConfig(dsn)
	if err != nil {
		return nil, fmt.Errorf("parse dsn: %w", err)
	}

	cfg.MaxConns = 10
	cfg.MinConns = 2                          // survive a burst without a handshake
	cfg.MaxConnLifetime = 25 * time.Minute    // under any proxy's idle timeout
	cfg.MaxConnIdleTime = 5 * time.Minute
	cfg.HealthCheckPeriod = 30 * time.Second

	pool, err := pgxpool.NewWithConfig(ctx, cfg)
	if err != nil {
		return nil, fmt.Errorf("create pool: %w", err)
	}

	// Fail the start-up, not the first request. A service that comes up
	// green and then 500s for ninety seconds while DNS settles is a
	// service whose readiness probe is lying.
	pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()
	if err := pool.Ping(pingCtx); err != nil {
		pool.Close()
		return nil, fmt.Errorf("ping: %w", err)
	}
	return pool, nil
}

// Why pgxpool rather than database/sql, given the toolkit part used the
// latter: pgx speaks the Postgres binary protocol directly, so timestamps
// and arrays arrive as themselves instead of through a text round trip,
// and errors arrive as *pgconn.PgError with the constraint name on them --
// which the next topic depends on entirely. database/sql is the right
// choice when you need one interface over several databases, or a library
// that expects *sql.DB. This service needs neither.
Go deep · 33

The store, and the race you cannot check for

Two requests arrive in the same millisecond with the same Idempotency-Key. Both look for an existing link, both find none, both insert. Reading before writing does not fix that and no amount of application code can, because the gap between the read and the write is where the other request lives. The database closes it. What Go contributes is that closing it costs one errors.As and no transaction gymnastics at all.

// internal/postgres/store.go
package postgres

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"time"

	"github.com/jackc/pgerrcode"
	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgconn"
	"github.com/jackc/pgx/v5/pgxpool"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// The constraint names from 0001. They are part of this file's contract
// with that migration, which is why they are constants with a comment
// rather than string literals three functions down.
const (
	codeUnique        = "links_code_key"
	idempotencyUnique = "links_owner_idempotency_key"
)

type Store struct{ pool *pgxpool.Pool }

func NewStore(pool *pgxpool.Pool) *Store { return &Store{pool: pool} }

func (s *Store) Insert(ctx context.Context, l shortlink.Link, idempotencyKey string) error {
	const q = `
		INSERT INTO links (code, target_url, owner_id, idempotency_key, created_at, expires_at)
		VALUES ($1, $2, $3, $4, $5, $6)`

	_, err := s.pool.Exec(ctx, q,
		string(l.Code), l.Target.String(), l.OwnerID,
		nullable(idempotencyKey), l.CreatedAt, l.ExpiresAt)
	if err == nil {
		return nil
	}

	// Translate infrastructure into domain here, so that nothing above this
	// package has to know pgx exists. errors.As unwraps until it finds a
	// *pgconn.PgError -- which is why the sentinel comparison in the caller
	// keeps working however many times this error gets wrapped on the way up.
	var pgErr *pgconn.PgError
	if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UniqueViolation {
		switch pgErr.ConstraintName {
		case codeUnique:
			return shortlink.ErrCodeTaken
		case idempotencyUnique:
			return shortlink.ErrIdempotencyClash
		}
	}
	return fmt.Errorf("insert link: %w", err)
}

func (s *Store) ByCode(ctx context.Context, code shortlink.Code) (shortlink.Link, error) {
	const q = `
		SELECT code, target_url, owner_id, created_at, expires_at
		FROM links WHERE code = $1`
	return s.one(ctx, q, string(code))
}

func (s *Store) ByIdempotencyKey(ctx context.Context, ownerID, key string) (shortlink.Link, error) {
	const q = `
		SELECT code, target_url, owner_id, created_at, expires_at
		FROM links WHERE owner_id = $1 AND idempotency_key = $2`
	return s.one(ctx, q, ownerID, key)
}

func (s *Store) DeleteOwned(ctx context.Context, ownerID string, code shortlink.Code) error {
	const q = `DELETE FROM links WHERE code = $1 AND owner_id = $2`
	tag, err := s.pool.Exec(ctx, q, string(code), ownerID)
	if err != nil {
		return fmt.Errorf("delete link: %w", err)
	}
	if tag.RowsAffected() == 0 {
		// Nought rows means it does not exist or it is not yours, and the
		// API says the same thing for both. Telling an unauthorised caller
		// that a code exists is how a database gets enumerated.
		return shortlink.ErrNotFound
	}
	return nil
}

func (s *Store) one(ctx context.Context, q string, args ...any) (shortlink.Link, error) {
	var (
		l    shortlink.Link
		code string
		raw  string
		exp  *time.Time
	)
	err := s.pool.QueryRow(ctx, q, args...).
		Scan(&code, &raw, &l.OwnerID, &l.CreatedAt, &exp)
	switch {
	case errors.Is(err, pgx.ErrNoRows):
		return shortlink.Link{}, shortlink.ErrNotFound
	case err != nil:
		return shortlink.Link{}, fmt.Errorf("query link: %w", err)
	}

	target, err := url.Parse(raw)
	if err != nil {
		// A row that cannot be parsed is corruption, not a 404. Say so.
		return shortlink.Link{}, fmt.Errorf("stored url for %s: %w", code, err)
	}
	l.Code = shortlink.Code(code)
	l.Target = target
	if exp != nil {
		l.ExpiresAt = *exp
	}
	return l, nil
}

func nullable(s string) *string {
	if s == "" {
		return nil   // NULL, so the partial index ignores this row
	}
	return &s
}

Go had nothing to undo here, and that is the whole difference

The Java version of this file needs a separate bean, annotated REQUIRES_NEW, purely so that the failed insert does not poison a transaction the caller was in the middle of. Go has no ambient transaction: pool.Exec outside a Begin is its own implicit transaction, so a unique violation rolls back exactly one statement and the caller simply tries a different code. The trap has not gone away, it has become visible — the moment this Exec is inside a pgx.Tx, Postgres marks that transaction aborted and every later statement in it fails with current transaction is aborted until you roll back. The fix there is a savepoint (tx.Begin nested inside a transaction is one) or doing the insert outside the transaction, which is what this code does by default rather than by accident.

// internal/shortlink/service.go
package shortlink

import (
	"context"
	"errors"
	"fmt"
	"net/url"
	"time"
)

// Store is declared here, in the package that uses it, and not in the
// package that implements it. That is the Go convention and it is not a
// stylistic one: it means the domain names the four methods it needs
// rather than depending on the thirty a database library offers, a test
// can satisfy it with a fifteen-line fake, and internal/postgres does not
// import this interface at all -- it simply happens to satisfy it.
type Store interface {
	Insert(ctx context.Context, l Link, idempotencyKey string) error
	ByCode(ctx context.Context, code Code) (Link, error)
	ByIdempotencyKey(ctx context.Context, ownerID, key string) (Link, error)
	DeleteOwned(ctx context.Context, ownerID string, code Code) error
}

const maxCodeAttempts = 5

type Service struct {
	store      Store
	now        func() time.Time   // injected, so a test can be about expiry
	codeLength int
	defaultTTL time.Duration
}

func NewService(store Store, now func() time.Time, codeLength int, ttl time.Duration) *Service {
	return &Service{store: store, now: now, codeLength: codeLength, defaultTTL: ttl}
}

// NewLink is everything one create needs, so the call cannot be made with
// the arguments the wrong way round -- four of them are strings.
type NewLink struct {
	OwnerID        string
	Target         *url.URL
	Alias          Code           // optional: a caller's chosen code
	TTL            time.Duration  // zero means the default
	IdempotencyKey string
}

func (s *Service) Shorten(ctx context.Context, req NewLink) (Link, error) {
	// The fast path: a client retrying after a timeout, seconds later. It
	// is an optimisation and not the guarantee -- see the note above.
	if req.IdempotencyKey != "" {
		switch existing, err := s.store.ByIdempotencyKey(ctx, req.OwnerID, req.IdempotencyKey); {
		case err == nil:
			return existing, nil
		case !errors.Is(err, ErrNotFound):
			return Link{}, err
		}
	}

	now := s.now()
	ttl := req.TTL
	if ttl == 0 {
		ttl = s.defaultTTL
	}

	for attempt := 0; attempt < maxCodeAttempts; attempt++ {
		code := req.Alias
		if code == "" {
			generated, err := NewCode(s.codeLength)
			if err != nil {
				return Link{}, err
			}
			code = generated
		}

		link := Link{
			Code: code, Target: req.Target, OwnerID: req.OwnerID,
			CreatedAt: now, ExpiresAt: now.Add(ttl),
		}
		err := s.store.Insert(ctx, link, req.IdempotencyKey)
		switch {
		case err == nil:
			return link, nil

		case errors.Is(err, ErrIdempotencyClash):
			// The slow path: a genuine race. The other request won, and
			// its link is the correct answer to both of them.
			return s.store.ByIdempotencyKey(ctx, req.OwnerID, req.IdempotencyKey)

		case errors.Is(err, ErrCodeTaken) && req.Alias != "":
			// A caller's chosen alias is a 409 they can act on. A
			// generated code that collided is our problem, not theirs.
			return Link{}, fmt.Errorf("%w: %s", ErrCodeTaken, code)

		case errors.Is(err, ErrCodeTaken):
			continue   // try another code

		default:
			return Link{}, err
		}
	}
	return Link{}, ErrNoCode
}

func (s *Service) Resolve(ctx context.Context, code Code) (Link, error) {
	link, err := s.store.ByCode(ctx, code)
	if err != nil {
		return Link{}, err
	}
	if link.Expired(s.now()) {
		// Deliberately not ErrNotFound: a client that is told "gone" can
		// stop retrying, and an operator reading logs can tell them apart.
		return Link{}, ErrExpired
	}
	return link, nil
}

func (s *Service) Delete(ctx context.Context, ownerID string, code Code) error {
	return s.store.DeleteOwned(ctx, ownerID, code)
}
Go deep · 34

The HTTP edge, and the twelve lines of main

Since Go 1.22 the standard ServeMux routes on method and on path parameters, which removed the last honest reason to import a router. What is left is a handler set that does only HTTP — decode, call, encode, choose a status — and a run function that owns every lifetime in the process.

// internal/httpapi/server.go
package httpapi

import (
	"context"
	"log/slog"
	"net"
	"net/http"
	"net/url"
	"time"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

type Server struct {
	links   *shortlink.Service
	keys    KeyStore
	limiter Limiter
	base    *url.URL
	log     *slog.Logger
	ready   func(ctx context.Context) error
}

// Six dependencies is where a positional constructor stops being readable
// and starts being a place to swap two arguments of the same type. A
// struct makes every call site name what it is passing, and adding a
// seventh dependency later does not break the four existing callers.
type Deps struct {
	Links   *shortlink.Service
	Keys    KeyStore
	Limiter Limiter
	BaseURL *url.URL
	Log     *slog.Logger

	// Ready is passed in as a function rather than as a pool and a Redis
	// client, which is the only reason this package imports neither pgx
	// nor go-redis. The layering rule from the first topic, held by one
	// field.
	Ready func(ctx context.Context) error
}

func New(d Deps) *Server {
	return &Server{
		links: d.Links, keys: d.Keys, limiter: d.Limiter,
		base: d.BaseURL, log: d.Log, ready: d.Ready,
	}
}

func (s *Server) Handler() http.Handler {
	mux := http.NewServeMux()

	// Method and path parameter in the pattern, matched by the standard
	// library. The specific pattern wins over the general one, so
	// "GET /healthz" is not swallowed by "GET /{code}" -- ServeMux picks
	// the most specific match rather than the first registered, which is
	// the behaviour that made third-party routers necessary before 1.22.
	mux.Handle("POST /v1/links", s.authenticate(http.HandlerFunc(s.createLink)))
	mux.Handle("GET /v1/links/{code}", s.authenticate(http.HandlerFunc(s.readLink)))
	mux.Handle("DELETE /v1/links/{code}", s.authenticate(http.HandlerFunc(s.deleteLink)))

	mux.HandleFunc("GET /healthz", s.healthz)
	mux.HandleFunc("GET /readyz", s.readyz)

	// The only unauthenticated route, and the only one with real traffic.
	mux.HandleFunc("GET /{code}", s.redirect)

	// Outermost first: a panic in the logger should still be recovered,
	// and the request id has to exist before anything logs.
	return requestID(logging(s.log, recovery(s.log, mux)))
}

// NewHTTPServer is the five timeouts from the service part, applied. The
// only one with a default is none of them: an http.Server{} left alone
// will hold a connection open until the client goes away, which is how a
// single slow-loris client takes a service down with no traffic at all.
func NewHTTPServer(ctx context.Context, addr string, h http.Handler, log *slog.Logger) *http.Server {
	return &http.Server{
		Addr:              addr,
		Handler:           h,
		ReadHeaderTimeout: 5 * time.Second,
		ReadTimeout:       10 * time.Second,
		WriteTimeout:      15 * time.Second,
		IdleTimeout:       60 * time.Second,
		MaxHeaderBytes:    1 << 16,
		ErrorLog:          slog.NewLogLogger(log.Handler(), slog.LevelError),
		BaseContext:       func(net.Listener) context.Context { return ctx },
	}
}
// internal/httpapi/links.go
package httpapi

import (
	"context"
	"encoding/json"
	"net/http"
	"time"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// The wire contract. It exists separately from shortlink.Link because the
// two change for different reasons: this one when a consumer needs
// something, that one when the rules do.
type createRequest struct {
	URL     string `json:"url"`
	Alias   string `json:"alias,omitempty"`
	TTLDays int    `json:"ttl_days,omitempty"`
}

type linkResponse struct {
	Code      string     `json:"code"`
	ShortURL  string     `json:"short_url"`
	URL       string     `json:"url"`
	CreatedAt time.Time  `json:"created_at"`
	ExpiresAt *time.Time `json:"expires_at,omitempty"`
}

func (s *Server) createLink(w http.ResponseWriter, r *http.Request) {
	caller, _ := callerFrom(r.Context())

	var body createRequest
	dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
	dec.DisallowUnknownFields()   // a typo in a field name is a 400, not a shrug
	if err := dec.Decode(&body); err != nil {
		s.problem(w, r, invalid(err))
		return
	}

	target, err := shortlink.ParseTarget(body.URL)
	if err != nil {
		s.problem(w, r, err)
		return
	}
	req := shortlink.NewLink{
		OwnerID:        caller.OwnerID,
		Target:         target,
		TTL:            time.Duration(body.TTLDays) * 24 * time.Hour,
		IdempotencyKey: r.Header.Get("Idempotency-Key"),
	}
	if body.Alias != "" {
		alias, err := shortlink.ParseCode(body.Alias)
		if err != nil {
			s.problem(w, r, err)
			return
		}
		req.Alias = alias
	}

	// The write gets its own deadline rather than inheriting the client's
	// patience. r.Context() is cancelled the moment the client hangs up,
	// and a write cancelled halfway is a request nobody can answer
	// afterwards; WithoutCancel keeps the values -- the request id, the
	// trace -- and drops only the cancellation.
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Second)
	defer cancel()

	link, err := s.links.Shorten(ctx, req)
	if err != nil {
		s.problem(w, r, err)
		return
	}

	short := s.base.JoinPath(link.Code.String())
	w.Header().Set("Location", short.String())
	s.writeJSON(w, http.StatusCreated, s.response(link))
}

func (s *Server) readLink(w http.ResponseWriter, r *http.Request) {
	caller, _ := callerFrom(r.Context())
	code, err := shortlink.ParseCode(r.PathValue("code"))   // Go 1.22
	if err != nil {
		s.problem(w, r, err)
		return
	}
	link, err := s.links.Resolve(r.Context(), code)
	if err != nil || link.OwnerID != caller.OwnerID {
		// Not yours reads as not found, on purpose.
		s.problem(w, r, orNotFound(err))
		return
	}
	s.writeJSON(w, http.StatusOK, s.response(link))
}

func (s *Server) deleteLink(w http.ResponseWriter, r *http.Request) {
	caller, _ := callerFrom(r.Context())
	code, err := shortlink.ParseCode(r.PathValue("code"))
	if err != nil {
		s.problem(w, r, err)
		return
	}
	ctx, cancel := context.WithTimeout(context.WithoutCancel(r.Context()), 3*time.Second)
	defer cancel()

	if err := s.links.Delete(ctx, caller.OwnerID, code); err != nil {
		s.problem(w, r, err)
		return
	}
	// 204: it is gone, there is nothing to say about it, and a body on a
	// delete is something clients start depending on.
	w.WriteHeader(http.StatusNoContent)
}

func (s *Server) response(l shortlink.Link) linkResponse {
	out := linkResponse{
		Code:      l.Code.String(),
		ShortURL:  s.base.JoinPath(l.Code.String()).String(),
		URL:       l.Target.String(),
		CreatedAt: l.CreatedAt,
	}
	if !l.ExpiresAt.IsZero() {
		out.ExpiresAt = &l.ExpiresAt
	}
	return out
}

func (s *Server) writeJSON(w http.ResponseWriter, status int, v any) {
	// Marshal before writing the header. json.NewEncoder(w).Encode writes
	// the 200 first and then discovers the value will not marshal, which
	// leaves the client a truncated body under a success status.
	buf, err := json.Marshal(v)
	if err != nil {
		s.log.Error("marshal response", "error", err)
		http.Error(w, `{"title":"Internal server error"}`, http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	_, _ = w.Write(buf)
}
// internal/httpapi/redirect.go
package httpapi

import (
	"net/http"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

func (s *Server) redirect(w http.ResponseWriter, r *http.Request) {
	code, err := shortlink.ParseCode(r.PathValue("code"))
	if err != nil {
		s.problem(w, r, shortlink.ErrNotFound)   // a malformed code is simply not a link
		return
	}
	link, err := s.links.Resolve(r.Context(), code)
	if err != nil {
		s.problem(w, r, err)
		return
	}

	// 302 and no-store, not 301, and this is not a style preference. A 301
	// is permanent and browsers treat it that way: Chrome caches it, often
	// for the life of the profile, and stops asking. Ship a 301 and you
	// have given away the three things a shortener exists to have -- you
	// can never re-point the link, never expire or delete it, and your
	// click metrics fall off a cliff because every repeat visit redirects
	// locally and you never see it. The 301 buys the user one round trip
	// and costs you the product. Buy the latency back at a CDN, where you
	// can still purge.
	w.Header().Set("Cache-Control", "no-store")
	http.Redirect(w, r, link.Target.String(), http.StatusFound)
}
// cmd/shortlinkd/main.go -- the whole file.
package main

import (
	"context"
	"fmt"
	"os"
	"os/signal"
	"syscall"
)

func main() {
	// NotifyContext gives the whole program one cancellation to hang off,
	// and the deferred stop restores the default handler -- so a second
	// SIGTERM during a slow shutdown kills the process, which is what an
	// operator pressing Ctrl-C twice is asking for.
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	// main does nothing but call run and exit. A function returning an
	// error is testable; a function calling os.Exit is not, because
	// os.Exit skips every deferred call in the process including the
	// test framework's.
	if err := run(ctx); err != nil {
		fmt.Fprintf(os.Stderr, "shortlinkd: %v\n", err)
		os.Exit(1)
	}
}
// cmd/shortlinkd/run.go -- every lifetime in the process, in one function.
package main

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"os"
	"time"

	"github.com/redis/go-redis/v9"

	"github.com/jaspreet/shortlink/internal/config"
	"github.com/jaspreet/shortlink/internal/httpapi"
	"github.com/jaspreet/shortlink/internal/postgres"
	"github.com/jaspreet/shortlink/internal/ratelimit"
	"github.com/jaspreet/shortlink/internal/shortlink"
)

func run(ctx context.Context) error {
	cfg, err := config.Load()
	if err != nil {
		return fmt.Errorf("load config: %w", err)
	}

	log := slog.New(httpapi.ContextHandler{
		Handler: slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}),
	}).With("service", "shortlink")
	slog.SetDefault(log)

	if err := postgres.Migrate(ctx, cfg.DatabaseURL); err != nil {
		return err
	}

	pool, err := postgres.NewPool(ctx, cfg.DatabaseURL)
	if err != nil {
		return err
	}
	defer pool.Close()

	redisOpts, err := redis.ParseURL(cfg.RedisURL)
	if err != nil {
		return fmt.Errorf("parse redis url: %w", err)
	}
	rdb := redis.NewClient(redisOpts)
	defer rdb.Close()

	store := postgres.NewStore(pool)
	svc := shortlink.NewService(store, time.Now, cfg.CodeLength, cfg.DefaultTTL)

	api := httpapi.New(httpapi.Deps{
		Links:   svc,
		Keys:    postgres.NewKeyStore(pool),
		Limiter: ratelimit.NewRedis(rdb, cfg.RateLimit, cfg.RateWindow),
		BaseURL: cfg.PublicBaseURL,
		Log:     log,
		Ready: func(ctx context.Context) error {
			if err := pool.Ping(ctx); err != nil {
				return fmt.Errorf("postgres: %w", err)
			}
			if err := rdb.Ping(ctx).Err(); err != nil {
				return fmt.Errorf("redis: %w", err)
			}
			return nil
		},
	})

	srv := httpapi.NewHTTPServer(ctx, cfg.Addr, api.Handler(), log)

	// The listener goes on its own goroutine so that this one can wait on
	// whichever happens first: the server failing, or the signal arriving.
	// The channel is buffered so that goroutine can exit either way and
	// does not leak when we return through the other branch.
	serverFailed := make(chan error, 1)
	go func() {
		log.Info("listening", "addr", cfg.Addr)
		serverFailed <- srv.ListenAndServe()
	}()

	select {
	case err := <-serverFailed:
		if !errors.Is(err, http.ErrServerClosed) {
			return fmt.Errorf("serve: %w", err)
		}
	case <-ctx.Done():
		log.Info("signal received, draining")
	}

	// WithoutCancel is load-bearing and this is the bug it prevents: ctx is
	// already cancelled by the time we get here, so context.WithTimeout(ctx,
	// 20s) would hand back a context that is cancelled at birth, Shutdown
	// would return immediately, and every in-flight request would be cut
	// off -- a graceful shutdown that drains nothing, on every deploy,
	// silently.
	shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 20*time.Second)
	defer cancel()

	if err := srv.Shutdown(shutdownCtx); err != nil {
		return fmt.Errorf("shutdown: %w", err)
	}
	log.Info("stopped cleanly")
	return nil
}

The order of the deferred closes is the order of the dependencies, backwards

defer pool.Close() runs after defer rdb.Close(), which runs after srv.Shutdown returns, because deferred calls run last-in first-out. That is exactly right and it is free: the pool was created before Redis, Redis before the server, so the teardown unwinds in the reverse order without anybody maintaining a list. Get it wrong — close the pool before the server drains — and every request still in flight gets use of closed connection instead of an answer, on every single deploy. This is the one place where Go’s least fashionable feature quietly does a job that other languages need a lifecycle framework and an annotation for.

Go deep · 35

Failure is part of the contract

A service has two APIs and only one of them is documented. The other is what happens when things go wrong, and it is the one clients actually integrate against: which statuses mean retry, which mean stop, and whether the body parses when it is not a 200. The taxonomy part said an error should carry a decision; this is that idea with a status code on the end of it.

// internal/httpapi/problem.go
package httpapi

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// RFC 9457 problem+json: one shape for every failure that leaves the
// service, so a client writes one parser instead of one per endpoint.
type problemDetail struct {
	Type   string `json:"type"`
	Title  string `json:"title"`
	Status int    `json:"status"`
	Detail string `json:"detail,omitempty"`
}

const problemBase = "https://study.jaspreet.info/problems/"

// One table, walked by errors.Is in order. Go has no exhaustive switch
// over a sealed set, so the guarantee the Java version gets from its
// compiler is bought here by a test: problem_test.go walks shortlink.All
// and fails if any sentinel is missing from this slice.
var catalogue = []struct {
	err    error
	status int
	slug   string
	title  string
}{
	{shortlink.ErrInvalid, http.StatusBadRequest, "invalid-request", "Invalid request"},
	{shortlink.ErrNotFound, http.StatusNotFound, "link-not-found", "Link not found"},
	{shortlink.ErrExpired, http.StatusGone, "link-expired", "Link expired"},
	{shortlink.ErrCodeTaken, http.StatusConflict, "code-taken", "Code taken"},
	{shortlink.ErrIdempotencyClash, http.StatusConflict, "in-flight", "Request in flight"},
	{shortlink.ErrNoCode, http.StatusServiceUnavailable, "no-code", "Try again"},
}

func (s *Server) problem(w http.ResponseWriter, r *http.Request, err error) {
	for _, entry := range catalogue {
		if errors.Is(err, entry.err) {
			write(w, problemDetail{
				Type:   problemBase + entry.slug,
				Title:  entry.title,
				Status: entry.status,
				Detail: err.Error(),
			})
			return
		}
	}

	// Anything unmatched. Log everything, tell the client nothing: a Go
	// error string routinely contains a DSN, a hostname or a driver
	// version, and none of that belongs in a response body.
	s.log.ErrorContext(r.Context(), "unhandled error",
		"error", err, "method", r.Method, "path", r.URL.Path)
	write(w, problemDetail{
		Type:   problemBase + "internal",
		Title:  "Internal server error",
		Status: http.StatusInternalServerError,
	})
}

func write(w http.ResponseWriter, p problemDetail) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(p.Status)
	_ = json.NewEncoder(w).Encode(p)
}

func invalid(err error) error {
	return fmt.Errorf("%w: %s", shortlink.ErrInvalid, err)
}

func orNotFound(err error) error {
	if err == nil {
		return shortlink.ErrNotFound
	}
	return err
}
$ curl -sS -i -X POST localhost:8080/v1/links \
    -H 'Authorization: Bearer sl_live_notarealkey' \
    -H 'Content-Type: application/json' \
    -d '{"url":"javascript:alert(1)"}'
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json

{"type":"https://study.jaspreet.info/problems/invalid-request",
 "title":"Invalid request","status":400,
 "detail":"invalid request: only http and https can be shortened"}

# a typo in a field name, because DisallowUnknownFields is on:
$ curl -sS -o /dev/null -w '%{http_code}\n' -X POST localhost:8080/v1/links \
    -H 'Authorization: Bearer sl_live_notarealkey' \
    -d '{"ur1":"https://example.com"}'
400

# and the one every client actually codes against:
$ curl -sS -o /dev/null -w '%{http_code}\n' localhost:8080/gone4evr
410

errors.Is, never ==, and the linter that knows the difference

Every error in this service is wrapped with %w on the way up, which is what makes the message useful — insert link: invalid request: url has no host tells you where as well as what. It also means err == shortlink.ErrInvalid is false for every one of them, because the value you are holding is a *fmt.wrapError with the sentinel somewhere inside it. errors.Is unwraps until it finds a match, errors.As does the same for a concrete type, and errorlint in the golangci config fails the build on a bare comparison to an error value. This is the single most common Go bug that gets through review, because the broken version works right up until somebody adds context to an error three layers down.

Go deep · 36

Who is calling, and how often

Two controls that fail in opposite directions. Authentication decides whether a request happens at all, so it must be impossible to skip — which is why it wraps the handler rather than being a call at the top of one. Rate limiting decides how much of you a caller gets, so it must be shared across every replica — which is why it is in Redis and not in a map behind a mutex.

// internal/httpapi/auth.go
package httpapi

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/hex"
	"errors"
	"net/http"
	"strings"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// APIKey is who is calling. KeyStore and Limiter are declared here, in the
// package that uses them, so internal/postgres and internal/ratelimit do
// not import this package at all -- they just happen to satisfy it.
type APIKey struct {
	OwnerID string
	Label   string
	Digest  string
}

type KeyStore interface {
	ByDigest(ctx context.Context, digest string) (APIKey, error)
}

type Limiter interface {
	Allow(ctx context.Context, ownerID string) (bool, error)
}

// The context key is an unexported empty struct type, not a string. A
// string key can collide with one set by any other package in the process
// -- including a library you did not write -- and the collision is silent.
// An unexported type cannot be named from outside this package, so it
// cannot collide, and it costs nothing at run time.
type callerKey struct{}

func withCaller(ctx context.Context, k APIKey) context.Context {
	return context.WithValue(ctx, callerKey{}, k)
}

func callerFrom(ctx context.Context) (APIKey, bool) {
	k, ok := ctx.Value(callerKey{}).(APIKey)
	return k, ok
}

func (s *Server) authenticate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
		if !ok || presented == "" {
			s.unauthorized(w)
			return
		}

		sum := sha256.Sum256([]byte(presented))
		digest := hex.EncodeToString(sum[:])

		// The lookup is by digest, so a database that leaks tells an
		// attacker nothing usable: they get hashes, and a 256-bit random
		// key does not come back out of one.
		key, err := s.keys.ByDigest(r.Context(), digest)
		if err != nil {
			if !errors.Is(err, shortlink.ErrNotFound) {
				s.log.ErrorContext(r.Context(), "key lookup failed", "error", err)
			}
			s.unauthorized(w)
			return
		}
		// Redundant on its face -- the row was found by this digest -- and
		// kept anyway, because it stays correct the day the lookup becomes
		// a cache or a prefix scan. Constant time, so no attacker learns
		// anything from how long a miss took.
		if subtle.ConstantTimeCompare([]byte(key.Digest), []byte(digest)) != 1 {
			s.unauthorized(w)
			return
		}

		allowed, err := s.limiter.Allow(r.Context(), key.OwnerID)
		if err != nil {
			// Fail open, loudly. See the note under the limiter.
			s.log.WarnContext(r.Context(), "rate limiter unavailable", "error", err)
		} else if !allowed {
			s.tooManyRequests(w)
			return
		}

		next.ServeHTTP(w, r.WithContext(withCaller(r.Context(), key)))
	})
}

func (s *Server) unauthorized(w http.ResponseWriter) {
	// The header is not optional: without it a client cannot tell "you
	// need a key" from "your key is wrong from the wrong scheme".
	w.Header().Set("WWW-Authenticate", "Bearer")
	write(w, problemDetail{
		Type: problemBase + "unauthorized", Title: "Unauthorized",
		Status: http.StatusUnauthorized, Detail: "a valid API key is required",
	})
}

func (s *Server) tooManyRequests(w http.ResponseWriter) {
	w.Header().Set("Retry-After", "60")   // seconds, and mean it
	write(w, problemDetail{
		Type: problemBase + "rate-limited", Title: "Too many requests",
		Status: http.StatusTooManyRequests, Detail: "the per-minute quota for this key is spent",
	})
}

postgres.KeyStore is four lines of the same shape as the store above — one SELECT owner_id, label, key_hash FROM api_keys WHERE key_hash = $1 AND revoked_at IS NULL, one Scan, pgx.ErrNoRows mapped to shortlink.ErrNotFound. Worth noticing what is not there: no cache. One indexed lookup per request is a fraction of a millisecond, and a cache is a place for a revoked key to keep working. Add one when a profile says to, with a short TTL, and know that you have chosen a revocation delay.

// internal/ratelimit/redis.go
package ratelimit

import (
	"context"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

// Two commands as one round trip and one atomic step. INCR and PEXPIRE
// sent separately have a window in which the process dies between them,
// which leaves a key with no TTL -- and that bans the caller permanently.
var increment = redis.NewScript(`
	local hits = redis.call('INCR', KEYS[1])
	if hits == 1 then
		redis.call('PEXPIRE', KEYS[1], ARGV[1])
	end
	return hits
`)

type Redis struct {
	client    *redis.Client
	permitted int
	window    time.Duration
}

func NewRedis(client *redis.Client, permitted int, window time.Duration) *Redis {
	return &Redis{client: client, permitted: permitted, window: window}
}

func (r *Redis) Allow(ctx context.Context, ownerID string) (bool, error) {
	// The key carries the window, so the previous window's key expires on
	// its own and there is nothing to sweep up.
	bucket := time.Now().UnixMilli() / r.window.Milliseconds()
	key := fmt.Sprintf("rl:%s:%d", ownerID, bucket)

	// A limiter that is slow is worse than no limiter: it adds its latency
	// to every request it permits. This deadline is separate from the
	// request's and much shorter.
	ctx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
	defer cancel()

	hits, err := increment.Run(ctx, r.client, []string{key}, r.window.Milliseconds()).Int64()
	if err != nil {
		return false, fmt.Errorf("rate limit: %w", err)
	}
	return hits <= int64(r.permitted), nil
}

// A map behind a sync.Mutex is not a rate limiter, and it is worth being
// able to say exactly why in an interview. Four replicas each with their
// own map give one caller four times the quota. A deploy resets everyone's
// counter to zero, which is precisely when you least want it. And the map
// grows forever unless somebody writes the sweeper, which nobody does.
//
// A fixed window lets a caller spend twice the quota across a boundary --
// sixty at 11:59:59 and sixty more at 12:00:00. For an API key that is
// fine and the simplicity is worth it. When it is not, a sliding window
// log or a token bucket is the same INCR-in-Lua shape with more
// arithmetic, and both still live in Redis for the same reason.

SHA-256 for an API key, bcrypt for a password — and swapping them is a bug both ways

A password is a low-entropy secret a human chose, so the defence against a stolen table is to make each guess expensive: bcrypt, scrypt or argon2, deliberately slow, salted per row. An API key is 256 bits out of crypto/rand; there is no dictionary to run against it and no amount of GPU that gets through the space, so a plain SHA-256 is enough. It is also necessary: put bcrypt on this path and you have added a hundred milliseconds of CPU to every authenticated request, which at any real rate is a fleet of pods spent hashing. Fast hash for high-entropy secrets, slow hash for human-chosen ones, and be able to say why in one sentence — this gets asked constantly.

The other half is the key’s life: generate it with crypto/rand, show the plaintext exactly once at creation, store only the digest, and give every key a revoked_at so revocation is an update you can audit rather than a delete you cannot. A prefix like sl_live_ costs nothing and lets a secret scanner find it in a public repository before an attacker does.

Failing open is a decision, and it has to be made on purpose

The middleware above logs and continues when Redis is unreachable, which means a Redis outage becomes unlimited traffic rather than a total outage. That is the right trade for this service and the wrong one for a limiter protecting something that genuinely cannot absorb the load — a payment endpoint, a third-party quota you are billed for, a login path being credential-stuffed. Whichever you choose, choose it in the code with a comment saying so, because the version that gets chosen by accident is always fail-open, and it is discovered during the incident.

Go deep · 37

Logs, and the two health checks

The question you are building for is “a customer says their request failed at 14:32, what happened”, answerable from a laptop in under a minute. That needs every log line from one request to carry the same id — and Go’s answer, unusually, is better than the thread-local one the other two languages use, because it is the context you were already passing.

// internal/httpapi/middleware.go
package httpapi

import (
	"context"
	"crypto/rand"
	"encoding/hex"
	"log/slog"
	"net/http"
	"regexp"
	"time"
)

type requestIDKey struct{}

// ContextHandler is the whole request-id mechanism. slog hands every
// Handler the context, so a handler can pull values out of it -- which
// means no thread-local, no MDC, nothing to clear in a defer, and nothing
// that can leak one request's id onto another's log line.
//
// The catch, and it is a real one: this only fires for the *Context
// variants. log.Info("x") and log.InfoContext(ctx, "x") produce different
// lines, and the first is missing the id. Make the reviewer's rule "no
// bare slog calls inside a handler" and it stays honest.
type ContextHandler struct{ slog.Handler }

func (h ContextHandler) Handle(ctx context.Context, r slog.Record) error {
	if id, ok := ctx.Value(requestIDKey{}).(string); ok {
		r.AddAttrs(slog.String("request_id", id))
	}
	return h.Handler.Handle(ctx, r)
}

// An id from outside ends up in every log line, so it is validated before
// it is trusted: a newline in that header would otherwise let a caller
// write a convincing fake log entry.
var safeID = regexp.MustCompile(`^[A-Za-z0-9._-]{1,64}$`)

func requestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-Id")
		if !safeID.MatchString(id) {
			var b [16]byte
			_, _ = rand.Read(b[:])
			id = hex.EncodeToString(b[:])
		}
		// Echoed, so support can paste the header out of a customer's HAR
		// file straight into the log search.
		w.Header().Set("X-Request-Id", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), requestIDKey{}, id)))
	})
}

// statusRecorder exists because ResponseWriter will not tell you what was
// written. Note it embeds the interface rather than reimplementing it, so
// it stays a valid ResponseWriter -- but embedding also hides Flush and
// Hijack, which matters the day somebody adds server-sent events here.
type statusRecorder struct {
	http.ResponseWriter
	status int
	bytes  int
}

func (s *statusRecorder) WriteHeader(code int) {
	s.status = code
	s.ResponseWriter.WriteHeader(code)
}

func (s *statusRecorder) Write(b []byte) (int, error) {
	n, err := s.ResponseWriter.Write(b)
	s.bytes += n
	return n, err
}

func logging(log *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
		next.ServeHTTP(rec, r)

		// Attributes, never a formatted sentence. "link %s expired" is a
		// string you can grep; separate keys are fields you can filter,
		// group and alert on.
		log.InfoContext(r.Context(), "request",
			"method", r.Method,
			"path", r.URL.Path,
			"status", rec.status,
			"bytes", rec.bytes,
			"duration_ms", time.Since(start).Milliseconds(),
		)
	})
}

func recovery(log *slog.Logger, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		defer func() {
			v := recover()
			switch {
			case v == nil:
				return
			case v == http.ErrAbortHandler:
				// The standard library's own signal that a handler is
				// deliberately abandoning the connection. Re-panic, or
				// net/http cannot do what it was asked.
				panic(v)
			default:
				log.ErrorContext(r.Context(), "panic in handler", "value", v, "path", r.URL.Path)
				write(w, problemDetail{
					Type: problemBase + "internal", Title: "Internal server error",
					Status: http.StatusInternalServerError,
				})
			}
		}()
		next.ServeHTTP(w, r)
	})
}
// internal/httpapi/health.go -- two endpoints, and they are not the same
// question. Getting this wrong is the single most common way to turn a
// dependency's bad thirty seconds into your own outage.
package httpapi

import (
	"context"
	"net/http"
	"time"
)

// Liveness: is this process broken beyond recovery? Only a restart fixes
// that, and a restart is what a failed liveness probe causes -- so it
// checks nothing external. Put the database in here and one Postgres
// failover restarts every replica you own, simultaneously.
func (s *Server) healthz(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"status":"ok"}`))
}

// Readiness: should traffic come here right now? A replica that cannot
// reach Postgres cannot serve, but it will recover on its own, so the
// right answer is to stop routing to it -- not to kill it.
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
	defer cancel()

	if err := s.ready(ctx); err != nil {
		s.log.WarnContext(ctx, "not ready", "error", err)
		w.WriteHeader(http.StatusServiceUnavailable)
		_, _ = w.Write([]byte(`{"status":"unavailable"}`))
		return
	}
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte(`{"status":"ready"}`))
}
{
  "time": "2026-08-31T14:32:07.418Z",
  "level": "WARN",
  "msg": "rate limiter unavailable",
  "service": "shortlink",
  "error": "rate limit: dial tcp 10.0.3.9:6379: i/o timeout",
  "request_id": "6f1c2a943d554c1e9f0a2b7e6d4a1c33"
}

That is slog.NewJSONHandler wrapped in the twelve-line ContextHandler above, and nothing else — no logging library, no encoder configuration, no init function. request_id is there because the middleware put it in the context and the handler read it back out; service is there because run called .With("service", "shortlink") once at start-up, which is the cheap way to tag every line a process will ever emit.

Go deep · 38

Tests that would actually have caught it

Three kinds, and each buys something different. The table-driven ones run in microseconds and cover the parsing that is really a security control. The catalogue test buys back, at the cost of one slice, the exhaustiveness a sealed type would have given you. And one test starts a real Postgres in a container, because the single guarantee this service makes is a guarantee about a unique index, and no fake has one.

// internal/shortlink/code_test.go
package shortlink_test

import (
	"errors"
	"testing"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// Package shortlink_test, not package shortlink. An external test package
// can only touch the exported API, which means the test is a second
// opinion on whether that API is usable rather than a friend with access
// to the internals.
func TestParseTarget(t *testing.T) {
	t.Parallel()

	tests := []struct {
		name string
		in   string
		want error   // nil means it should be accepted
	}{
		{"plain https", "https://example.com/a/b?c=d", nil},
		{"plain http", "http://example.com", nil},
		{"trims", "  https://example.com  ", nil},

		{"empty", "", shortlink.ErrInvalid},
		{"javascript", "javascript:alert(1)", shortlink.ErrInvalid},
		{"data uri", "data:text/html,<script>x</script>", shortlink.ErrInvalid},
		{"file", "file:///etc/passwd", shortlink.ErrInvalid},
		{"relative", "/just/a/path", shortlink.ErrInvalid},
		{"no host", "https://", shortlink.ErrInvalid},
		{"credentials", "https://user:pass@example.com", shortlink.ErrInvalid},
	}

	for _, tc := range tests {
		t.Run(tc.name, func(t *testing.T) {
			t.Parallel()
			_, err := shortlink.ParseTarget(tc.in)
			if !errors.Is(err, tc.want) {
				t.Fatalf("ParseTarget(%q) error = %v, want %v", tc.in, err, tc.want)
			}
		})
	}
}

// Since Go 1.22 the loop variable is per-iteration, so tc is not shared
// between the parallel subtests above. Before that this exact shape was
// the single most common bug in Go test files -- every subtest saw the
// last case. If you read an older codebase and see `tc := tc` on the line
// after the range, that is what it was for.
// internal/httpapi/problem_test.go
package httpapi

import (
	"errors"
	"testing"

	"github.com/jaspreet/shortlink/internal/shortlink"
)

// The compiler cannot tell you that a new sentinel has no status code,
// because Go has no sealed types and no exhaustive switch. This is what
// buys that guarantee instead: eleven lines, run in microseconds, and it
// fails the moment somebody adds an error to shortlink.All without adding
// it to the catalogue -- which is exactly when it is cheap to fix.
func TestEverySentinelHasAStatus(t *testing.T) {
	for _, sentinel := range shortlink.All {
		found := false
		for _, entry := range catalogue {
			if errors.Is(sentinel, entry.err) {
				found = true
				break
			}
		}
		if !found {
			t.Errorf("%v has no entry in the problem catalogue: it would reach a client as a 500", sentinel)
		}
	}
}
// internal/postgres/store_test.go
package postgres_test

import (
	"context"
	"os"
	"sync"
	"testing"
	"time"

	"github.com/testcontainers/testcontainers-go"
	tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
	"github.com/testcontainers/testcontainers-go/wait"
	"golang.org/x/sync/errgroup"

	"github.com/jaspreet/shortlink/internal/postgres"
	"github.com/jaspreet/shortlink/internal/shortlink"
)

// TestMain starts one container for the whole package rather than one per
// test. A container per test turns a forty-second suite into four minutes,
// and a suite nobody waits for is a suite nobody runs.
var dsn string

func TestMain(m *testing.M) {
	ctx := context.Background()
	container, err := tcpostgres.Run(ctx, "postgres:16-alpine",
		tcpostgres.WithDatabase("shortlink"),
		tcpostgres.WithUsername("test"),
		tcpostgres.WithPassword("test"),
		// Postgres logs "ready to accept connections" once while it
		// initialises the data directory and again when it is actually
		// listening. Waiting for the first one is a flaky test that
		// passes on your laptop and fails in CI; WithOccurrence(2) is
		// the whole fix.
		testcontainers.WithWaitStrategy(
			wait.ForLog("database system is ready to accept connections").
				WithOccurrence(2).
				WithStartupTimeout(60*time.Second)),
	)
	if err != nil {
		panic(err)
	}
	if dsn, err = container.ConnectionString(ctx, "sslmode=disable"); err != nil {
		panic(err)
	}
	if err := postgres.Migrate(ctx, dsn); err != nil {
		panic(err)
	}

	code := m.Run()

	// Not defer: os.Exit does not run deferred calls, so the container
	// would outlive the test binary and leak until the reaper noticed.
	_ = testcontainers.TerminateContainer(container)
	os.Exit(code)
}

// The test a fake store cannot express. Sixteen goroutines released at the
// same instant with one idempotency key between them: the answer has to be
// one link, and it has to be the same link for all sixteen.
//
// Delete the partial unique index from 0001 and this fails with sixteen
// rows. Keep the index but delete the ErrIdempotencyClash branch from the
// service and it fails with fifteen errors. It tells those two apart,
// which is what makes the eight seconds a container costs worth paying.
func TestSameIdempotencyKeyCreatesOneLink(t *testing.T) {
	ctx := context.Background()
	svc := newService(t)
	target, err := shortlink.ParseTarget("https://example.com/one")
	if err != nil {
		t.Fatal(err)
	}
	req := shortlink.NewLink{
		OwnerID: "owner-1", Target: target, IdempotencyKey: "key-42",
	}

	var (
		mu    sync.Mutex
		codes = map[shortlink.Code]int{}
	)
	release := make(chan struct{})
	var g errgroup.Group

	for range 16 {   // Go 1.22: range over an int
		g.Go(func() error {
			<-release   // every goroutine parked until they can all go at once
			link, err := svc.Shorten(ctx, req)
			if err != nil {
				return err
			}
			mu.Lock()
			defer mu.Unlock()
			codes[link.Code]++
			return nil
		})
	}
	close(release)

	if err := g.Wait(); err != nil {
		t.Fatalf("a caller failed instead of sharing the link: %v", err)
	}
	if len(codes) != 1 {
		t.Fatalf("want every caller to get one code, got %d distinct: %v", len(codes), codes)
	}
	if got := countRows(t, ctx); got != 1 {
		t.Fatalf("want 1 row in links, got %d", got)
	}
}

// newService and countRows are ten lines each at the bottom of this file:
// one opens a pool against dsn, truncates links, and wires a Service over
// a real Store; the other runs SELECT count(*). Truncating per test rather
// than wrapping each in a transaction is deliberate -- the thing under
// test is what happens when concurrent statements actually commit, and a
// transaction the test owns would hide exactly that.

// Run this one with -race and it also proves the map above is guarded.
// Without the mutex the detector reports a write/write race and the test
// fails for a reason that has nothing to do with Postgres -- which is the
// detector doing its job on the test rather than on the code.

The fourth test file in the tree, httpapi/api_test.go, is the one that ties the rest together: httptest.NewServer(api.Handler()), a real http.Client, and assertions about status codes and Content-Type. It is worth having separately from the store tests because it is the only place the middleware chain, the routing patterns and the problem shape are exercised together — and because httptest makes it cost nothing: no port to pick, no server to shut down, and it runs in the same process so a failure gives you the handler’s stack rather than a connection reset.

The fake and the container are answering different questions, so keep both

shortlink.Store is a four-method interface, so a fake is fifteen lines and the service’s retry logic can be tested against it in microseconds — including the paths that are awkward to provoke for real, like “the generator collided four times running”. What the fake cannot do is have a unique index, so it will happily let sixteen concurrent inserts through and tell you the code is correct. Use the fake for the branches and the container for the guarantee, and be suspicious of any test suite that has only one of the two: all fakes means the thing you actually promised is untested, all containers means a four-minute suite that nobody runs before pushing.

Go deep · 39

The image, the pipeline, and two runtime settings

Go’s deployment story is the best of the three languages in this series and it is almost entirely down to one property: the output is a single static binary with no runtime to install, so the runtime image can contain that binary and nothing else. What is left to get right is two environment settings that Go historically gets wrong inside a container, and both are one line.

# Dockerfile -- the runtime stage holds one file. There is no interpreter,
# no JVM, no package manager and no shell, which is a smaller attack
# surface than any amount of hardening buys you on a full base image.
FROM golang:1.24-bookworm AS build
WORKDIR /src

# The manifests first, alone. This layer is invalidated only when a
# dependency changes, so the module download is cached across every
# ordinary commit.
COPY go.mod go.sum ./
RUN go mod download

COPY . .
ARG VERSION=dev
# CGO_ENABLED=0 gives a genuinely static binary and the pure-Go DNS
# resolver, which is what you want in a container -- the cgo resolver
# needs libc and an /etc/nsswitch.conf that the runtime image will not
# have. -trimpath strips the build machine's paths out of the binary, so
# two machines building the same commit produce the same bytes.
RUN CGO_ENABLED=0 go build -trimpath \
      -ldflags="-s -w -X main.version=${VERSION}" \
      -o /out/shortlinkd ./cmd/shortlinkd

FROM gcr.io/distroless/static-debian12:nonroot
# The distroless static image is a CA bundle, timezone data, /etc/passwd
# and nothing else -- no shell, so a remote code execution has no /bin/sh
# to reach for. nonroot is uid 65532, and naming the number rather than
# the user lets Kubernetes' runAsNonRoot verify it from the manifest.
COPY --from=build /out/shortlinkd /shortlinkd
USER 65532:65532
EXPOSE 8080

# Exec form, so the binary is PID 1 and SIGTERM reaches it. The shell form
# runs /bin/sh -c, the shell does not forward signals -- and here there is
# no shell at all, so the shell form would simply fail to start.
ENTRYPOINT ["/shortlinkd"]

Two settings the Go runtime does not read from the container, and both cost you real money

GOMAXPROCS. For most of Go’s life the runtime sized its scheduler from the number of CPUs it could see on the host, not from the cgroup CPU quota the container was given. On a 64-core node with a 0.5-CPU limit that means 64 scheduler threads and 64 garbage-collector workers fighting for half a core, and the symptom is latency that gets worse when you scale the node up. go.uber.org/automaxprocs is the standard fix — a blank import that reads the cgroup and sets the value — and recent Go versions have started doing this in the runtime itself, so check what your version does rather than assuming either way. Whichever it is, log the value at start-up; it is one line and it settles the argument.

GOMEMLIMIT. Go’s collector runs when the heap has doubled, which means a service that is idle for ten minutes and then allocates hard can sail past the container’s memory limit before a collection was due — and the kernel’s OOM killer does not negotiate. GOMEMLIMIT is a soft limit: set it to about 90% of the container limit and the collector works harder as you approach it instead of the process being killed with no stack trace and no log line. It is one environment variable and it converts a hard crash into a CPU cost.

# .github/workflows/ci.yaml -- the same commands the Makefile runs, so a
# red build reproduces locally in one line and no guessing.
name: ci

on:
  push:
    branches: [main]
  pull_request:

permissions:
  contents: read          # the default is write. It should not be.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-go@v5
        with:
          go-version: '1.24'
          cache: true     # the module and build caches, keyed on go.sum

      - run: go build ./...
      - run: go vet ./...

      - uses: golangci/golangci-lint-action@v6
        with:
          version: v1.62

      # -race, always. It is the flag that finds the bugs that only appear
      # under load in production, and testcontainers uses the runner's own
      # Docker daemon so there is nothing extra to install.
      - run: go test -race -count=1 ./...

      # The supply chain is part of a passing build now. govulncheck is
      # better than a generic scanner here because it uses call-graph
      # analysis: it reports a CVE only if your code can actually reach
      # the vulnerable function, so the output is short enough to act on.
      - run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...

      - name: Build the image
        run: docker build -t shortlink:${{ github.sha }} --build-arg VERSION=${{ github.sha }} .
Before it is “done”Why it is on this list
Migrations run as their own step, not on every startrun calls Migrate above, which is right for one replica and wrong for four. goose takes an advisory lock so the other three wait rather than corrupt anything — but they wait through their readiness deadline. Move it to a job before the rollout.
/readyz on the readiness probe, /healthz on the liveness probeThey answer different questions. Pointing both at the one that checks Postgres restarts every replica the next time Postgres fails over.
GOMEMLIMIT set, and the memory request equal to the limitSee the note above. A Go process killed by the OOM killer leaves no log line to find later.
pprof served on a second listener, not the public oneThe profiling part covered what each profile answers. net/http/pprof registers on http.DefaultServeMux the moment it is imported, so importing it and serving DefaultServeMux publishes a heap dump of your process to the internet.
Logs to stdout, and nowhere elseThe platform collects stdout. A log file inside a container is a disk that fills and an incident nobody can explain.
govulncheck on every build, not on a calendarIt is call-graph aware, so it is one of the few security gates whose output is short enough that people keep reading it.
Alerts on the 500 rate and on p99, not just dashboardsA dashboard is for after somebody noticed. An alert is for before.
A rollback that is one command, and that somebody has runEvery deploy plan has one. The ones that work were rehearsed on a Tuesday afternoon rather than discovered at 2am.

Read this part beside the Python and Java ones and the differences are smaller than the languages suggest. All three put the rules in a package that imports nothing, make idempotency a database constraint rather than a check, keep one error shape, hash the API key, share the rate limit, and fail the boot rather than the tenth request. What Go changes is the amount of machinery in between. There is no container, no proxy, no annotation and no reflection: run is the dependency graph, defer is the lifecycle, context carries the deadline and the request id that the other two need a framework and a thread-local for, and the deployable artefact is one file. What that costs is the guarantees a richer type system was giving you — Code("") compiles, the error switch is not exhaustive, and the layering is a linter rather than a compiler — so each of those is bought back here with a test. That trade, and being able to name both halves of it, is the answer to give when somebody asks why you would choose Go for a service.