Builder Pattern — Find the Bug¶
A collection of realistic, broken builder snippets. Each one looks correct at a glance and has shipped to production somewhere. For each: the symptom, the cause traced to a rule from junior.md or middle.md, and the fix. Read them in order — by the end you should spot every shape in a PR before it lands.
Bug 1: The naked return¶
package server
import "time"
type Server struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
}
type Builder struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
err error
}
func NewServerBuilder() *Builder { return &Builder{} }
func (b *Builder) Addr(a string) *Builder {
if b.err != nil {
return b
}
if a == "" {
b.err = errors.New("Addr: empty")
return
}
b.addr = a
return b
}
Question. This file doesn't compile. What's the bug, and what is the dangerous "fix" a hurried developer reaches for first?
Answer
**Bug.** The error branch in `Addr` writes `return` with no value, but the function signature says `*Builder`. Compile error: `not enough return values`. The dangerous fix: changing the error branch to `return nil`. Now the file compiles, but every chained call after a bad `Addr` panics with a nil pointer dereference: **Why it's there.** Muscle memory from non-builder code where `return` after setting an error field is normal. In a builder, every step must return `b` — even on the error path. That's how the deferred-error pattern works (`junior.md` §7.1): the *next* step sees `b.err != nil` and short-circuits. **How to spot in review.** Search for `return` (without a value) inside any method that declares a `*Builder` return type. Better: any method on a builder with a body longer than one `return b` line, scan every branch for a `return` keyword and confirm each one returns `b`. **Fix.** **Why it's common.** The deferred-error pattern is unintuitive on first read. Newcomers expect a step that "failed" to do something different from a step that "succeeded". In this pattern, both look identical from the outside — only `b.err` differs. The discipline is: every path returns `b`, every method.Bug 2: Pointer receiver, value return¶
type Builder struct {
addr string
readTimeout time.Duration
err error
}
func NewBuilder() *Builder { return &Builder{} }
func (b *Builder) Addr(a string) Builder {
b.addr = a
return *b
}
func (b *Builder) ReadTimeout(d time.Duration) Builder {
b.readTimeout = d
return *b
}
func (b *Builder) Build() (*Server, error) {
return &Server{addr: b.addr, readTimeout: b.readTimeout}, nil
}
Question. A caller writes the chain below. What does the assembled Server see?
Answer
**Bug.** The chain returns *values* (`Builder`, not `*Builder`), but `Build()` is defined on `*Builder`. The chained value is *addressable* in this exact call expression, so Go silently auto-addresses it for the `Build()` call — but `Build()` reads the temporary's fields, not the original `&Builder{}`'s. Specifically: `Addr` mutates `*b` (the original heap builder), then returns a *copy* (`*b`). `ReadTimeout` is called on the copy's address — a *new* `*Builder` pointing at the copy's storage. The copy now has both `addr` and `readTimeout` set; `Build()` reads them and produces a correct-looking `*Server`. So far so harmless. The bug detonates when the caller stores an intermediate:b := NewBuilder()
b1 := b.Addr(":8080") // b1 is a Builder VALUE; b's addr was set, but b1 is a separate copy
b2 := b1.ReadTimeout(5*time.Second) // operates on &b1 (a local), mutates it
srv, _ := b2.Build() // sees both fields by luck
fmt.Println(b.addr) // ":8080" — got set
fmt.Println(b.readTimeout) // 0 — never got set! ReadTimeout went to b1.
Bug 3: The shallow Clone()¶
type Builder struct {
table string
columns []string
wheres []string
args []any
err error
}
func (b *Builder) Clone() *Builder {
c := *b
return &c
}
func (b *Builder) Where(cond string, args ...any) *Builder {
if b.err != nil { return b }
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
Question. A test writes:
base := Select("id").From("users").Where("active = ?", true)
prod := base.Clone().Where("region = ?", "us")
test := base.Clone().Where("region = ?", "eu")
prodSQL, _, _ := prod.Build()
testSQL, _, _ := test.Build()
What are prodSQL and testSQL? (Assume the underlying slice had enough capacity.)
Answer
**Bug.** Both queries end with the *same* second condition — whichever was appended last. If `prod` was built first, both queries look like `... WHERE active = ? AND region = ? AND region = ?` with the last region value being `"eu"`. If `test` was built first, same shape but with `"us"`. **Why it's there.** `c := *b` is a shallow copy. `c.wheres` and `b.wheres` share the same backing array. If the array had spare capacity, `append` writes into the *same* memory cell for both clones. The second `Where` overwrites the first. If the array had no spare capacity, the first append allocates a new backing array for one clone, and the second append modifies a different array for the other — and you don't see the bug. Some test runs pass; some fail. The behaviour depends on `cap(b.wheres)`, which is invisible to the reader. From `middle.md` §4.2 and §13.1: shallow copy of a struct copies slice *headers*, not the underlying arrays. Two clones own pointers to the same memory. **How to spot in review.** Any `Clone()` method whose body is `c := *b; return &c` is wrong if the struct has any slice, map, or pointer-to-mutable field. Audit each field; for every reference-typed one, copy explicitly. **Fix.** `append([]string(nil), src...)` produces a fresh slice with its own backing array, regardless of `cap(src)`. Now mutations on either clone are isolated. **Why it's common.** "Shallow copy is enough for most types" is a half-truth that works for primitives and pointers-you-mean-to-share, and explodes for slices/maps. The discipline is: every `Clone()` enumerates the struct's reference-typed fields and copies them. No exceptions.Bug 4: Receiver shadowing¶
type Builder struct {
addr string
err error
}
func (b *Builder) Addr(a string) *Builder {
if b.err != nil { return b }
b := *b // (!)
b.addr = a
return &b
}
func (b *Builder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
return &Server{addr: b.addr}, nil
}
Question. A caller writes the obvious chain. Does it work?
Answer
**Bug.** This particular chain happens to print `":8080"`. The bug bites when the caller saves an intermediate or chains across multiple steps:b := &Builder{}
b1 := b.Addr(":8080")
fmt.Println(b.addr) // "" — original is untouched
fmt.Println(b1.addr) // ":8080" — went to the local copy
Bug 5: Build() returns the builder's map¶
type Server struct {
addr string
headers map[string]string
}
type Builder struct {
addr string
headers map[string]string
err error
}
func NewBuilder() *Builder {
return &Builder{headers: map[string]string{}}
}
func (b *Builder) Addr(a string) *Builder { b.addr = a; return b }
func (b *Builder) Header(k, v string) *Builder {
b.headers[k] = v
return b
}
func (b *Builder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
return &Server{
addr: b.addr,
headers: b.headers,
}, nil
}
Question. A caller writes:
b := NewBuilder().Addr(":8080").Header("X-Trace-Id", "abc")
srv, _ := b.Build()
b.Header("X-Trace-Id", "def") // intends to build a second server
srv2, _ := b.Build()
fmt.Println(srv.headers["X-Trace-Id"]) // ?
Answer
**Bug.** `"def"`. Both servers share the same backing map. Mutating the builder after the first `Build()` mutates `srv` too. The first server's header silently changes after it was supposedly "built". From `junior.md` §10.2 and §8: `Build()` copies the *fields* of the builder into the Server, but a map field is a *header* pointing at a backing hash table. Copying the header copies the pointer. Both Server and Builder now point at the same map. The same trap applies to slices, channels, pointers, and any struct field containing those. `Build()` must deep-copy reference-typed fields, or the Server escapes the builder's control. **How to spot in review.** In `Build()`, every field assignment of the form `field: b.fieldOfReferenceType` is suspect. Slices, maps, channels, `*T`, `[]T`, `chan T` all need a copy or an explicit decision to share. **Fix.** Alternative: forbid reusing the builder after `Build()`. Set `b.headers = nil` at the end of `Build()` so the next call sees a clean slate (or errors out). This is uglier in API terms — most callers don't expect a method to "consume" the receiver. The cleanest discipline is: build is single-use (`junior.md` §8), and `Build()` deep-copies reference-typed fields to insulate the produced object from the builder's later state. **Why it's common.** Map and slice copies look like noise — three or four extra lines of "obvious" copying. Skipping them looks like a small optimisation. It isn't: it's a correctness bug that ships and bites in production.Bug 6: The error step that doesn't short-circuit¶
type Builder struct {
table string
wheres []string
args []any
err error
}
func Select(cols ...string) *Builder {
if len(cols) == 0 {
return &Builder{err: errors.New("Select: no columns")}
}
return &Builder{}
}
func (b *Builder) From(t string) *Builder {
if t == "" {
b.err = errors.New("From: empty table")
}
b.table = t
return b
}
func (b *Builder) Where(cond string, args ...any) *Builder {
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
func (b *Builder) Build() (string, []any, error) {
if b.err != nil { return "", nil, b.err }
return "SELECT ... FROM " + b.table + " WHERE " + strings.Join(b.wheres, " AND "), b.args, nil
}
Question. A caller writes:
What's wrong with the assembled SQL, and what's the deeper bug?
Answer
**Bug — surface.** `Build()` returns the error from `From("")` (good), but along the way the builder still ran `Where`, allocated slice memory, and stored args. The cost is small here; in a heavier builder (e.g., one that opens a file or starts a goroutine per step), the work done after the first error is real. **Bug — deep.** `From` and `Where` don't check `b.err`. The pattern from `junior.md` §7.1 requires *every* step to short-circuit on a non-nil `b.err`. Without that check, later steps continue to execute and may *overwrite* the captured error. Watch this:sql, _, err := Select().From("").Where("x = ?", 1).Build()
// ^^^^^^^^ Select returned a builder with err set
// ^^^^^^^ From overwrites b.err with its own "From: empty table"
// — original "Select: no columns" is lost
func (b *Builder) From(t string) *Builder {
if b.err != nil { return b }
if t == "" {
b.err = errors.New("From: empty table")
return b
}
b.table = t
return b
}
func (b *Builder) Where(cond string, args ...any) *Builder {
if b.err != nil { return b }
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
Bug 7: The step that returns nil on error¶
type Builder struct {
addr string
err error
}
func (b *Builder) Addr(a string) *Builder {
if a == "" {
return nil
}
b.addr = a
return b
}
func (b *Builder) ReadTimeout(d time.Duration) *Builder {
b.readTimeout = d
return b
}
Question. A caller writes:
What happens?
Answer
**Bug.** `Addr("")` returns `nil`. The chained `.ReadTimeout(...)` is then called on a nil `*Builder`. Inside `ReadTimeout`, the body writes `b.readTimeout = d` — that's `(*b).readTimeout = d` with `b == nil`, which is a nil pointer dereference. Panic. The caller sees a panic from line 1 of `ReadTimeout`. They blame `ReadTimeout`. The actual bug is in `Addr`, two methods upstream. **Why it's there.** The author wanted to "report" an error without an error field. Returning `nil` looked like "skip the rest of the chain". It isn't — it's a time bomb in every later step. From `junior.md` §10.1: returning `nil` from a builder step is one of the canonical disasters. The pattern requires every step to return `b`, including (especially) the error path. **How to spot in review.** Any step method with `return nil` in its body. There is no legitimate use of `return nil` from a builder step. The error path returns `b` after setting `b.err`; the success path returns `b`. There is no third option. **Fix.** Use the deferred-error pattern (`junior.md` §7.1): If the codebase doesn't have an `err` field on the builder and adding one is a big change, panic loudly *at this step* instead of returning `nil`: The panic blames the right line. The nil return blames the wrong one. **Why it's common.** Developers reach for `nil` as a "no result" sentinel out of habit. In builders, there is no "no result" — the builder itself is the carrier of state, and discarding it mid-chain leaves the chain holding nothing.Bug 8: Reusing a builder after Build()¶
type Builder struct {
addr string
routes []string
err error
}
func NewBuilder() *Builder { return &Builder{} }
func (b *Builder) Addr(a string) *Builder {
if b.err != nil { return b }
b.addr = a
return b
}
func (b *Builder) Route(r string) *Builder {
if b.err != nil { return b }
b.routes = append(b.routes, r)
return b
}
func (b *Builder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
if b.addr == "" {
b.err = errors.New("Build: Addr required")
return nil, b.err
}
return &Server{addr: b.addr, routes: b.routes}, nil
}
Question. A test writes:
b := NewBuilder()
s1, _ := b.Addr(":8080").Route("/health").Build()
s2, _ := b.Addr(":9090").Route("/metrics").Build()
How is s2 different from what the caller expected? List two distinct problems.
Answer
**Bug — problem 1: leaked routes.** `s2.routes` is `["/health", "/metrics"]`, not `["/metrics"]`. `Route` appends; the second `Build()` sees the union of both calls' routes. The builder's `routes` slice was never cleared. **Bug — problem 2: shared slice.** `s1.routes` and `s2.routes` both point at the same underlying array (the builder's `b.routes`). When the second `Route(":metrics")` appends, the append *may* mutate `s1.routes[1]` in-place if the slice had spare capacity. `s1.routes` becomes `["/health", "/metrics"]` after the second `Build()` — even though `s1` was "finished" first. Bug 5 with a twist: the corruption happens *after* `Build()` returns, while `s1` is supposedly in the caller's hands. **Why it's there.** From `junior.md` §8: the builder is single-use. Calling `Build()` twice is undefined; the pattern doesn't say "reset between builds" because there is no defined "reset". The author here didn't *think* of `Build()` as terminal — they treated the builder like a reusable factory. **How to spot in review.** Any code path that calls `.Build()` on a builder, then continues to call step methods on the same builder. Test code is the most frequent offender: "let me build a few variants from this base builder". That's exactly the smell. **Fix.** Either build a factory function and call it twice:func newCommonBuilder() *Builder {
return NewBuilder().Route("/health")
}
s1, _ := newCommonBuilder().Addr(":8080").Build()
s2, _ := newCommonBuilder().Addr(":9090").Route("/metrics").Build()
Bug 9: Embedded builder breaks the chain type¶
type BaseBuilder struct {
name string
err error
}
func (b *BaseBuilder) Name(n string) *BaseBuilder {
if b.err != nil { return b }
b.name = n
return b
}
type ServerBuilder struct {
*BaseBuilder
addr string
}
func NewServerBuilder() *ServerBuilder {
return &ServerBuilder{BaseBuilder: &BaseBuilder{}}
}
func (b *ServerBuilder) Addr(a string) *ServerBuilder {
if b.err != nil { return b }
b.addr = a
return b
}
func (b *ServerBuilder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
return &Server{name: b.name, addr: b.addr}, nil
}
Question. What happens at the marked line?
Answer
**Bug.** Compile error: `b.Addr undefined (type *BaseBuilder has no field or method Addr)`. The chain after `Name(...)` is operating on a `*BaseBuilder`, not the `*ServerBuilder` the caller started with. **Why it's there.** From `junior.md` §11.3: when a method on an embedded type returns `*EmbeddedType`, the chain's static type degrades. `Name` is defined on `*BaseBuilder` and returns `*BaseBuilder`. Even though the receiver was a `*ServerBuilder` via embedding promotion, the *return type* is fixed by the method's declaration. The next method call (`Addr`) looks for an `Addr` method on `*BaseBuilder` and doesn't find one. This is one of the canonical traps of trying to share builder code via embedding. Go's method promotion does not extend to return types. **How to spot in review.** Any builder that embeds another builder type, and the embedded methods return the *base* type. The smell is a chain that compiles fine for the first few calls (the ones on the base type), then fails as soon as the caller tries to chain a derived-type method. The compile error is loud but confusing — the reader's instinct is "why isn't Addr a method?" rather than "the chain's type degraded". **Fix — Option A: don't chain across types.** Promote `Name` to a non-chain setter that returns nothing: Callers write `b := NewServerBuilder(); b.SetName("api"); b.Addr(":8080").Build()` — multi-step instead of fluent. Loses the chained style. **Fix — Option B: override on the derived type.** `ServerBuilder.Name` shadows the promoted `BaseBuilder.Name`. Now `NewServerBuilder().Name(...)` returns `*ServerBuilder` (the override). The chain stays in the derived type. This is repetitive — every base method needs an override on every derived type. It's the price of fluent chains plus inheritance. **Fix — Option C: don't embed. Compose.** The "base" is data, not behaviour. The `ServerBuilder` owns all its own step methods. Most idiomatic Go. **Why it's common.** Developers see Go's struct embedding and think "Java inheritance, finally". Embedding promotes methods, but it doesn't rewrite return types. The fluent builder is exactly the pattern that exposes this gap.Bug 10: Stage builder, wrong stage¶
type AddrStage struct {
err error
}
type ConfigStage struct {
addr string
err error
}
type FinalStage struct {
addr string
timeout time.Duration
err error
}
func NewServerBuilder() *AddrStage { return &AddrStage{} }
func (s *AddrStage) Addr(a string) *ConfigStage {
if a == "" {
return &ConfigStage{err: errors.New("Addr: empty")}
}
return &ConfigStage{addr: a}
}
func (s *ConfigStage) ReadTimeout(d time.Duration) *FinalStage {
return &FinalStage{addr: s.addr, timeout: d, err: s.err}
}
// (!)
func (s *AddrStage) UseTLS(cert, key string) *FinalStage {
return &FinalStage{err: s.err}
}
func (s *FinalStage) Build() (*Server, error) {
if s.err != nil { return nil, s.err }
return &Server{addr: s.addr, timeout: s.timeout}, nil
}
Question. This compiles. A caller writes the chain below. What goes wrong, and what should the type system have prevented?
Answer
**Bug.** Compiles and runs. Produces a `*Server` with `addr == ""` and `timeout == 0`. `Build()` doesn't check `addr`, so it returns "successfully" — but the Server is unusable. The deeper bug: `UseTLS` is defined on `*AddrStage`, which means the compiler lets the caller skip the `Addr` stage entirely. The whole point of stage typing (`junior.md` §5.3) is that the compiler enforces the ordering — `NewServerBuilder().ReadTimeout(...)` should be a compile error because `ReadTimeout` isn't defined on `*AddrStage`. Here, defining `UseTLS` on the *wrong* stage opens an escape hatch around the compile-time enforcement. The user takes it (or trips into it) and produces an unconfigured Server. **Why it's there.** Someone copy-pasted a method definition and forgot to update the receiver. Or someone "fixed" a compile error by changing `*ConfigStage` to `*AddrStage`. The change makes the test pass; it also undoes the type-level protection. From `junior.md` §5.3: stage typing is "powerful but verbose. Three types per builder. Each new step is a refactor." When the refactor is sloppy, the protection silently breaks. **How to spot in review.** When stage types are in play, every step method's receiver type must match its *position* in the intended sequence. There should be one method per stage transition, no more. If a stage has two methods returning the same next stage, ask why — usually one of them is misplaced. A useful invariant: each stage type should appear as a method receiver *exactly* in the methods that consume that stage. If you grep `*AddrStage` and find more methods than transitions out of `AddrStage`, something is off. **Fix.** Move `UseTLS` to the right stage — between `Addr` and the final build: If `UseTLS` is optional (the user can `Build` without TLS), the design is more complex: you need either two paths out of `ConfigStage` (both pointing to a stage from which `Build` is reachable), or a single `FinalStage` that supports both `UseTLS(...).Build()` and `Build()` directly. The simplest fix when stage typing gets this awkward is to abandon it in favour of the runtime guard from `middle.md` §11.3 (a `phase` int field with panic on order violations). **Why it's common.** Stage typing's appeal is "the compiler enforces order". Its cost is that every refactor must update three types. When the refactor cost is paid lazily, the type-level protection erodes; the API still looks stage-typed but no longer enforces what it claims to.Bug 11: Validate() and Build() disagree¶
type Builder struct {
addr string
routes []string
pingPath string
err error
}
func (b *Builder) Addr(a string) *Builder { /* ... */ b.addr = a; return b }
func (b *Builder) Route(r string) *Builder { /* ... */ b.routes = append(b.routes, r); return b }
func (b *Builder) PingPath(p string) *Builder { /* ... */ b.pingPath = p; return b }
func (b *Builder) Validate() error {
if b.err != nil { return b.err }
if b.addr == "" { return errors.New("Addr required") }
return nil
}
func (b *Builder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
if b.addr == "" {
return nil, errors.New("Build: Addr required")
}
if b.pingPath != "" {
found := false
for _, r := range b.routes {
if r == b.pingPath { found = true; break }
}
if !found {
return nil, fmt.Errorf("Build: PingPath %q is not in Route list", b.pingPath)
}
}
return &Server{ /* ... */ }, nil
}
Question. A caller writes:
b := NewBuilder().Addr(":8080").PingPath("/health")
if err := b.Validate(); err != nil { return err }
srv, err := b.Build()
What happens?
Answer
**Bug.** `Validate()` returns nil. The caller proceeds to `Build()`, which returns an error: "PingPath /health is not in Route list". The caller's pre-flight validation passed; the actual build failed. If this code is behind a UI that says "configuration is valid" after `Validate()` and *then* tries to build, the user sees a contradictory experience: "valid" followed by "invalid". The error message also blames `Build`, even though the problem was *visible* at `Validate` time. **Why it's there.** From `middle.md` §10.4: `Validate()` is a convenience, not a contract. If it returns nil, the caller expects `Build()` to succeed (modulo I/O errors, like loading a TLS cert). When `Build()` has validation logic that `Validate()` doesn't run, the two terminals disagree. The deeper rule: validation logic must live in *one* place. Either both terminals call the same helper, or one of them is a thin wrapper around the other. **How to spot in review.** Two terminals (`Validate`, `Build`, `Plan`, etc.) with overlapping validation logic. If you can grep for the same error message string in two methods, you've found it. If you can construct an input where `Validate` and `Build` produce different verdicts, the design is wrong. **Fix.** Factor validation into a helper called by both terminals:func (b *Builder) validate() error {
if b.err != nil { return b.err }
if b.addr == "" { return errors.New("Addr required") }
if b.pingPath != "" {
found := false
for _, r := range b.routes {
if r == b.pingPath { found = true; break }
}
if !found {
return fmt.Errorf("PingPath %q is not in Route list", b.pingPath)
}
}
return nil
}
func (b *Builder) Validate() error { return b.validate() }
func (b *Builder) Build() (*Server, error) {
if err := b.validate(); err != nil {
return nil, err
}
return &Server{ /* ... */ }, nil
}
Bug 12: Concurrent step calls¶
type Builder struct {
routes []string
err error
}
func (b *Builder) Route(r string) *Builder {
if b.err != nil { return b }
b.routes = append(b.routes, r)
return b
}
func (b *Builder) Build() (*Server, error) {
if b.err != nil { return nil, b.err }
return &Server{routes: b.routes}, nil
}
// in registerRoutes:
func registerRoutes(b *Builder, all []string) {
var wg sync.WaitGroup
for _, r := range all {
wg.Add(1)
go func(route string) {
defer wg.Done()
b.Route(route)
}(r)
}
wg.Wait()
}
Question. A caller passes 100 routes to registerRoutes. How many routes are on the resulting Server?
Answer
**Bug.** Some number between 1 and 100, non-deterministically. The race detector (`go test -race`) flags it immediately: `DATA RACE on field routes`. Multiple goroutines call `b.Route` concurrently. `Route` does `b.routes = append(b.routes, r)`. `append` reads `len(b.routes)`, allocates (maybe), writes the new element, writes the new slice header. None of that is atomic. Concurrent appends produce: - Lost updates (two goroutines read the same length, both write to index N, one wins). - Slice header tearing (one goroutine reads the header mid-write from another). - Heap corruption in pathological cases (the runtime panics or worse). **Why it's there.** From `junior.md` §11.2: builders are single-threaded by design. The pattern's invariant is "one goroutine accumulates, one goroutine calls Build". The author here treated the builder like a thread-safe collection. **How to spot in review.** Any `go` statement that calls a method on a builder. The pattern is "build serially, then act in parallel" — never "build in parallel". If concurrent assembly is genuinely required (say, scraping routes from N microservices in parallel), the fix is to gather results into a slice or channel, then call the builder serially with the gathered data: The builder sees serial calls. The parallelism lives in the producers, not the consumer. **Fix.** Don't synchronise the builder. Move the parallelism out of the chain. See `middle.md` §13.5: adding a mutex to step methods is fighting the pattern, not honouring it. **Why it's common.** "I have a slice of things to register; goroutines are how Go does parallel work" — the chain of thought is intuitive and ignores the builder's contract. The race detector finds it on the first test run *if* the developer remembers to enable it.Bug 13: With(opts...) panics on a nil option¶
type Builder struct {
addr string
retries int
}
type Option func(*Builder)
func WithRetries(n int) Option {
return func(b *Builder) { b.retries = n }
}
func (b *Builder) With(opts ...Option) *Builder {
for _, o := range opts {
o(b)
}
return b
}
// elsewhere:
func defaultOpts(prod bool) []Option {
var opts []Option
if prod {
opts = append(opts, WithRetries(5))
}
opts = append(opts, nil) // (!) accidentally
return opts
}
Question. What happens?
Answer
**Bug.** `panic: runtime error: invalid memory address or nil pointer dereference`. The loop in `With` calls `o(b)` without checking `o == nil`. A nil function value panics on invocation. The `nil` may have been appended deliberately as a placeholder, or generated by a conditional expression that returns `nil` on the negative branch: Either way, one `nil` in a slice of options crashes the entire chain. **Why it's there.** From `middle.md` §15.4: any `for _, o := range opts` over options must skip nils. The pattern shows up in functional options *and* in builders that accept options via `With(...)`. **How to spot in review.** Any `for _, X := range Xs` where `X` is then *called* as a function. If the slice can contain nils (which it almost always can — slices are nil-safe at the slot level), the call must check. **Fix.** One-line guard. Costs nothing in the hot path. Saves a panic in production. A stronger version: at option *construction*, reject nil inputs: But this doesn't help when a nil is appended *outside* the option constructors (the `withTracingIf` case above). The defensive skip in `With` is the right line of defence. **Why it's common.** Slices and nil-safety in Go is one of the trickier intersections. A slice can be non-nil but contain nil elements. Iterating without a nil check feels redundant until you ship the first panic.Bug 14: SQL injection via Where() concatenation¶
type Builder struct {
table string
wheres []string
args []any
err error
}
func (b *Builder) From(t string) *Builder { b.table = t; return b }
func (b *Builder) Where(cond string) *Builder { b.wheres = append(b.wheres, cond); return b }
func (b *Builder) Build() (string, []any, error) {
var sb strings.Builder
sb.WriteString("SELECT * FROM ")
sb.WriteString(b.table)
if len(b.wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(b.wheres, " AND "))
}
return sb.String(), b.args, nil
}
// caller:
func findUser(db *sql.DB, name string) (*User, error) {
sql, args, _ := NewBuilder().
From("users").
Where("name = '" + name + "'").
Build()
row := db.QueryRow(sql, args...)
// ...
}
Question. A caller passes name = "admin' OR '1'='1". What does the resulting SQL look like, and why is the builder's API partly to blame?
Answer
**Bug.** The SQL becomes: Classic SQL injection. The `OR '1'='1'` defeats the filter; the query returns every user. The caller wrote the injection by concatenating `name` into the `Where` argument. They were the proximate cause — but the builder's API *invited* the bug. `Where(string)` takes a raw SQL fragment with no parameterisation. A user who has internalised "concatenate into the query" everywhere they touch SQL gets no help from the type system. From `junior.md` §9: the canonical SQL builder takes `Where(cond string, args ...any)` and treats the cond as a *template* with `?` placeholders. The args go through the driver's parameterisation, which escapes them safely. **How to spot in review.** Any `Where`, `Filter`, `Match`-style method on a query builder that doesn't accept `args ...any`. The smell is `WhereCond(string)` with no companion arg path. Even worse: `Where(string)` *and* `args` on the builder elsewhere — the API has the right idea but the wrong call shape. In review: grep for `Where(` followed by string concatenation (`"+`, `fmt.Sprintf`, `bytes.Buffer.WriteString` of a user-controlled value). Each match is potentially exploitable. **Fix.** Take args at the call site, defer parameterisation to the driver:func (b *Builder) Where(cond string, args ...any) *Builder {
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
// caller:
func findUser(db *sql.DB, name string) (*User, error) {
sql, args, _ := NewBuilder().
From("users").
Where("name = ?", name). // placeholder + arg
Build()
row := db.QueryRow(sql, args...)
// ...
}
Bug 15: If(cond, fn) evaluates fn always¶
type Builder struct {
addr string
cert string
cache *Cache
err error
}
func (b *Builder) Addr(a string) *Builder { b.addr = a; return b }
func (b *Builder) UseTLS(c string) *Builder { b.cert = c; return b }
func (b *Builder) UseCache(c *Cache) *Builder { b.cache = c; return b }
func (b *Builder) If(cond bool, fn *Builder) *Builder {
if cond {
return fn
}
return b
}
// caller:
func main() {
srv, _ := NewBuilder().
Addr(":8080").
If(useCache, (&Builder{}).UseCache(expensiveCacheInit())). // (!)
Build()
_ = srv
}
Question. A caller passes useCache = false. What happens, and what was the author trying to write?
Answer
**Bug — surface.** `expensiveCacheInit()` runs even when `useCache` is false. Go evaluates *all* arguments to a function before the function is called. The `If` method receives the already-computed result; the `cond` check happens *after* the cache was built. For a cheap init, the bug is invisible. For an expensive one (loading 100MB from disk, connecting to Redis, allocating a 1GB buffer), the builder pays the cost of every "conditional" option *regardless* of the condition. **Bug — design.** Even with eager evaluation aside, the signature `If(cond bool, fn *Builder) *Builder` is wrong. The pattern from `middle.md` §11.2 is `If(cond bool, fn func(*Builder)) *Builder` — the *function* is deferred, not a pre-built builder. The author conflated "another builder" with "a step to apply". **Why it's there.** From `middle.md` §11.2: the conditional step is one of the trickier patterns. The reader instinct is "If returns one branch or the other", and the author wrote it that way. Go's strict left-to-right argument evaluation breaks the lazy intuition. **How to spot in review.** Any builder method with a `bool` argument and a second argument that's a *value* (not a `func`). If the second argument involves any computation, that computation happens unconditionally. Also look for patterns like `If(cond, b.UseCache(c))` at call sites — chaining `UseCache` *inside* the `If` argument list, where the chain runs whether or not `cond` is true. **Fix.** Take a closure; defer the steps inside it: Now `expensiveCacheInit()` runs only when `useCache` is true. The closure is the thunk; calling it is what evaluates the expensive expression. If the conditional step is genuinely just one method call with cheap arguments, the `If` helper is overhead. The explicit form is just as clear: Many Go developers prefer the explicit `if` — see `middle.md` §11.2's note. Adopt `If` only when chains with mid-stream conditions are common enough that the explicit form fragments the code. **Why it's common.** The fluent style invites "everything must chain". Conditional logic doesn't fit naturally, so authors invent helpers. The helpers are easy to get wrong — especially around evaluation order, which Go enforces strictly. The right reflex: when a chain wants conditional behaviour, either close around it (`If(cond, func(*Builder){...})`) or step outside the chain (`if cond { b.X(...) }`).Summary¶
The bugs cluster around five themes.
- The chain's mechanics (Bugs 1, 2, 4, 7) —
returnwithoutb, mismatched receiver/return types, shadowing the receiver, returningnilmid-chain. Each breaks the chain in a different invisible way. - Shared mutable state (Bugs 3, 5, 8) — shallow
Clone(), leaking the builder's slice/map into the produced object, reusing a builder afterBuild(). All three trace back to "the builder's fields are not just data; they're shared references". - The deferred-error contract (Bugs 6, 7) — every step must guard on
b.errat entry; no step may returnnil; later steps may not overwrite an earlier error. - Compile-time vs runtime enforcement (Bugs 9, 10, 11) — embedded builders that break the chain's type, stage typing that has an escape hatch,
Validate()andBuild()that disagree. The pattern works only when the enforcement matches the design's intent. - The surrounding API (Bugs 12, 13, 14, 15) — concurrent assembly, nil options, unparameterised SQL, eager evaluation of "conditional" arguments. Each violation is in the use of the builder, but the builder's API design either invited it or could have prevented it.
The defences are the same across all of them: every step returns b, every step guards on b.err, every Build() deep-copies reference-typed fields, the builder is single-use unless explicitly cloned, the type system enforces what it claims to, and the API design refuses to invite injection or eager-evaluation traps.
A unit test that constructs a target with various chains and asserts the resulting object's state catches most of these before they ship. go vet catches none of them. The race detector catches Bug 12. Code review catches the rest — and now you know what to look for.
Further reading¶
junior.md§7 (deferred-error pattern)junior.md§8 (builder is single-use)junior.md§10 (common junior mistakes)junior.md§11 (tricky points: shadowing, goroutines, embedding)middle.md§4 (reusable / forkable builders)middle.md§10 (validation strategies)middle.md§13 (common middle-level mistakes)middle.md§15 (tricky points: chain compilation, With nil-skip)- Go 1.22 loopvar change: https://go.dev/blog/loopvar-preview
squirrelquery builder: https://github.com/Masterminds/squirrelgoququery builder: https://github.com/doug-martin/goqu