Builder Pattern — Hands-on Tasks¶
Work through these in order. The first five drill the basic pointer-receiver chain from junior.md. The middle tasks force you to choose between the variants in middle.md — value-receiver forking, generics, multi-terminal, stage-typing. The last few are open-ended mini-projects that build something you would actually ship.
Run every solution with go vet ./... and go test ./... before moving on. Each task is self-contained — copy the solution into a fresh directory, go mod init scratch, then iterate.
You need Go 1.21 or later. Tasks 14, 15, and 20 use Go 1.18+ generics. Task 11 uses net/http/httptest. Task 17 uses database/sql semantics but does not require a real database.
Task 1: Pizza builder (warm-up)¶
A pizzeria takes orders shaped like size → crust → toppings. Implement a PizzaBuilder whose chain reads like the order:
p, err := NewPizzaBuilder().
Size("large").
Crust("thin").
Topping("mushrooms").
Topping("olives").
Build()
Acceptance criteria
-
Pizzastruct withsize string,crust string,toppings []string. -
PizzaBuilderwith deferred-error field. -
Sizeaccepts only"small","medium","large"; anything else setsb.err. -
Crustaccepts only"thin","thick","stuffed"; anything else setsb.err. -
Toppingis additive — each call appends one topping. Max 10 toppings. -
Build()requiressizeandcrustto be set; otherwise returnserror. - The returned
*Pizzadoes not share the toppings slice with the builder. - A
main()demo prints two pizzas, one valid and one with an invalid size.
Hints
- Validate per-step (`Size`, `Crust`, `Topping`) for intrinsic errors. Validate cross-field (required fields) in `Build()`. See §10 of `middle.md`. - For the slice copy, use `append([]string(nil), b.toppings...)` inside `Build()`. - Don't panic on an 11th topping — set `b.err` and let the chain unwind.Solution
package main
import (
"errors"
"fmt"
)
type Pizza struct {
size string
crust string
toppings []string
}
type PizzaBuilder struct {
size string
crust string
toppings []string
err error
}
func NewPizzaBuilder() *PizzaBuilder { return &PizzaBuilder{} }
var validSizes = map[string]bool{"small": true, "medium": true, "large": true}
var validCrusts = map[string]bool{"thin": true, "thick": true, "stuffed": true}
func (b *PizzaBuilder) Size(s string) *PizzaBuilder {
if b.err != nil {
return b
}
if !validSizes[s] {
b.err = fmt.Errorf("Size: invalid %q", s)
return b
}
b.size = s
return b
}
func (b *PizzaBuilder) Crust(c string) *PizzaBuilder {
if b.err != nil {
return b
}
if !validCrusts[c] {
b.err = fmt.Errorf("Crust: invalid %q", c)
return b
}
b.crust = c
return b
}
func (b *PizzaBuilder) Topping(t string) *PizzaBuilder {
if b.err != nil {
return b
}
if len(b.toppings) >= 10 {
b.err = errors.New("Topping: max 10 toppings")
return b
}
b.toppings = append(b.toppings, t)
return b
}
func (b *PizzaBuilder) Build() (*Pizza, error) {
if b.err != nil {
return nil, b.err
}
if b.size == "" {
return nil, errors.New("Build: Size required")
}
if b.crust == "" {
return nil, errors.New("Build: Crust required")
}
tops := append([]string(nil), b.toppings...)
return &Pizza{size: b.size, crust: b.crust, toppings: tops}, nil
}
func main() {
p, err := NewPizzaBuilder().
Size("large").
Crust("thin").
Topping("mushrooms").
Topping("olives").
Build()
fmt.Printf("%+v err=%v\n", p, err)
bad, err := NewPizzaBuilder().
Size("enormous").
Crust("thin").
Build()
fmt.Printf("%+v err=%v\n", bad, err)
}
Discussion. This is the smallest possible "real" builder. The shape — deferred error, per-step intrinsic validation, build-time cross-field validation, defensive slice copy — recurs in every later task. If anything feels surprising here, re-read junior.md §4 and §7 before continuing.
Task 2: SQL SELECT builder¶
Implement a query builder producing parametrised SELECT statements. The chain is:
sql, args, err := Select("id", "name", "email").
From("users").
Where("active = ?", true).
Where("created_at > ?", since).
OrderBy("created_at DESC").
Limit(100).
Build()
Acceptance criteria
-
Select(cols ...string) *Builderis the entry point —NewBuilderis not exposed. -
From,Where,OrderBy,Limiteach return*Builder. - Multiple
Wherecalls AND-join their conditions. -
OrderByoverwrites; calling it twice keeps only the last value. -
Limitrejects negative values via deferred error. -
Build()returns(sqlString string, args []any, err error). - If
Selectis called with zero columns, defer the error. - If
Fromwas never called,Build()returns an error. - A unit test asserts the exact SQL string for the chain above.
Hints
- Use `strings.Builder` for the SQL assembly. - `args` accumulates as you go — append each `Where`'s args in the order they were passed. - Test the exact string with `==`, not regexp. Whitespace-sensitive.Solution
package query
import (
"errors"
"fmt"
"strings"
)
type Builder struct {
columns []string
table string
wheres []string
args []any
orderBy string
limit int
err error
}
func Select(cols ...string) *Builder {
if len(cols) == 0 {
return &Builder{err: errors.New("Select: no columns")}
}
return &Builder{columns: cols}
}
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
}
func (b *Builder) OrderBy(col string) *Builder {
if b.err != nil {
return b
}
b.orderBy = col
return b
}
func (b *Builder) Limit(n int) *Builder {
if b.err != nil {
return b
}
if n < 0 {
b.err = fmt.Errorf("Limit: negative (%d)", n)
return b
}
b.limit = n
return b
}
func (b *Builder) Build() (string, []any, error) {
if b.err != nil {
return "", nil, b.err
}
if b.table == "" {
return "", nil, errors.New("Build: From required")
}
var sb strings.Builder
sb.WriteString("SELECT ")
sb.WriteString(strings.Join(b.columns, ", "))
sb.WriteString(" FROM ")
sb.WriteString(b.table)
if len(b.wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(b.wheres, " AND "))
}
if b.orderBy != "" {
sb.WriteString(" ORDER BY ")
sb.WriteString(b.orderBy)
}
if b.limit > 0 {
fmt.Fprintf(&sb, " LIMIT %d", b.limit)
}
args := append([]any(nil), b.args...)
return sb.String(), args, nil
}
Discussion. Two design choices to think about. First, why Select(...) instead of NewBuilder().Select(...)? Because the SQL itself starts with SELECT; the API mirrors the verb. Second, why does Where append but OrderBy overwrites? Because SQL allows many WHERE clauses (AND-joined) but only one ORDER BY clause. The builder's semantics should match the target domain's semantics — don't invent new rules.
Task 3: SQL INSERT, UPDATE, DELETE builders¶
Extend Task 2 with three more entry points: InsertInto(table), Update(table), DeleteFrom(table). Each produces a different builder type, so its methods differ.
Acceptance criteria
-
InsertBuilderexposesColumns(cols ...string),Values(vals ...any),Build(). -
Valuescan be called multiple times for multi-row inserts. Column count must match. -
UpdateBuilderexposesSet(col string, val any),Where(cond, args...),Build(). -
DeleteBuilderexposesWhere(cond, args...),Build(). - All three
Build()methods return(string, []any, error). - A
DELETEwithout anyWhereclause is an error (refuse to nuke the whole table). - Tests assert the exact SQL for each shape.
Hints
- Don't try to unify these into one struct. Three structs, three entry points. The methods overlap (`Where`) but the targets are different. - For `INSERT` with multiple `Values` calls, store `[][]any` and join with `, ` in `Build`. - For `UPDATE`, store `[]string` for the `col = ?` fragments and append to `args`.Solution
package query
import (
"errors"
"fmt"
"strings"
)
// INSERT
type InsertBuilder struct {
table string
cols []string
rows [][]any
err error
}
func InsertInto(t string) *InsertBuilder {
if t == "" {
return &InsertBuilder{err: errors.New("InsertInto: empty table")}
}
return &InsertBuilder{table: t}
}
func (b *InsertBuilder) Columns(c ...string) *InsertBuilder {
if b.err != nil {
return b
}
if len(c) == 0 {
b.err = errors.New("Columns: empty")
return b
}
b.cols = c
return b
}
func (b *InsertBuilder) Values(v ...any) *InsertBuilder {
if b.err != nil {
return b
}
if len(b.cols) == 0 {
b.err = errors.New("Values: call Columns first")
return b
}
if len(v) != len(b.cols) {
b.err = fmt.Errorf("Values: want %d, got %d", len(b.cols), len(v))
return b
}
b.rows = append(b.rows, v)
return b
}
func (b *InsertBuilder) Build() (string, []any, error) {
if b.err != nil {
return "", nil, b.err
}
if len(b.rows) == 0 {
return "", nil, errors.New("Build: no Values")
}
var sb strings.Builder
fmt.Fprintf(&sb, "INSERT INTO %s (%s) VALUES ", b.table, strings.Join(b.cols, ", "))
placeholderRow := "(" + strings.Repeat("?, ", len(b.cols)-1) + "?)"
rowParts := make([]string, len(b.rows))
args := make([]any, 0, len(b.rows)*len(b.cols))
for i, row := range b.rows {
rowParts[i] = placeholderRow
args = append(args, row...)
}
sb.WriteString(strings.Join(rowParts, ", "))
return sb.String(), args, nil
}
// UPDATE
type UpdateBuilder struct {
table string
sets []string
wheres []string
args []any
err error
}
func Update(t string) *UpdateBuilder {
if t == "" {
return &UpdateBuilder{err: errors.New("Update: empty table")}
}
return &UpdateBuilder{table: t}
}
func (b *UpdateBuilder) Set(col string, v any) *UpdateBuilder {
if b.err != nil {
return b
}
b.sets = append(b.sets, col+" = ?")
b.args = append(b.args, v)
return b
}
func (b *UpdateBuilder) Where(cond string, args ...any) *UpdateBuilder {
if b.err != nil {
return b
}
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
func (b *UpdateBuilder) Build() (string, []any, error) {
if b.err != nil {
return "", nil, b.err
}
if len(b.sets) == 0 {
return "", nil, errors.New("Build: no Set")
}
var sb strings.Builder
fmt.Fprintf(&sb, "UPDATE %s SET %s", b.table, strings.Join(b.sets, ", "))
if len(b.wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(b.wheres, " AND "))
}
return sb.String(), append([]any(nil), b.args...), nil
}
// DELETE
type DeleteBuilder struct {
table string
wheres []string
args []any
err error
}
func DeleteFrom(t string) *DeleteBuilder {
if t == "" {
return &DeleteBuilder{err: errors.New("DeleteFrom: empty table")}
}
return &DeleteBuilder{table: t}
}
func (b *DeleteBuilder) Where(cond string, args ...any) *DeleteBuilder {
if b.err != nil {
return b
}
b.wheres = append(b.wheres, cond)
b.args = append(b.args, args...)
return b
}
func (b *DeleteBuilder) Build() (string, []any, error) {
if b.err != nil {
return "", nil, b.err
}
if len(b.wheres) == 0 {
return "", nil, errors.New("Build: DELETE without WHERE refused")
}
var sb strings.Builder
fmt.Fprintf(&sb, "DELETE FROM %s WHERE %s", b.table, strings.Join(b.wheres, " AND "))
return sb.String(), append([]any(nil), b.args...), nil
}
Discussion. The refusal to build an unbounded DELETE is the interesting part. Builders aren't just structural — they encode policy. You could add a WhereAll() escape hatch for the rare case ("yes, I really mean it"), or leave the safety in. squirrel and goqu make this distinction differently; pick a side and document it.
Task 4: HTTP request builder¶
Build an *http.Request via a builder that mirrors the real net/http API but with method-chaining ergonomics.
req, err := NewRequestBuilder("POST", "https://api.example.com/users").
Header("Authorization", "Bearer "+token).
Header("Content-Type", "application/json").
Query("verbose", "1").
JSONBody(payload).
Build()
Acceptance criteria
-
NewRequestBuilder(method, url string) *RequestBuildervalidates the method and URL. -
Header(k, v)is additive (multiple headers, possibly same key — append, don't overwrite). -
Query(k, v)adds a URL query parameter. MultipleQuerycalls combine. -
Body(io.Reader)sets a raw body.JSONBody(v any)JSON-encodes and setsContent-Typeautomatically (unless already set). - Calling both
BodyandJSONBodyis an error. -
Build()returns(*http.Request, error). - A test using
httptest.NewServerround-trips a built request and asserts the server saw the right method, headers, body, and query.
Hints
- For multi-value headers, use `http.Header.Add` not `Set`. - Build the URL with `net/url.Parse` then `u.Query()` and `u.RawQuery`. - `JSONBody` should marshal in `Build()`, not in the option — that's where errors live.Solution
package httpbuild
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
)
type RequestBuilder struct {
method string
rawURL string
headers http.Header
query url.Values
body io.Reader
jsonBody any
err error
}
var validMethods = map[string]bool{
"GET": true, "POST": true, "PUT": true, "PATCH": true,
"DELETE": true, "HEAD": true, "OPTIONS": true,
}
func NewRequestBuilder(method, rawURL string) *RequestBuilder {
b := &RequestBuilder{
method: method,
rawURL: rawURL,
headers: http.Header{},
query: url.Values{},
}
if !validMethods[method] {
b.err = fmt.Errorf("NewRequestBuilder: invalid method %q", method)
}
if _, err := url.Parse(rawURL); err != nil {
b.err = fmt.Errorf("NewRequestBuilder: bad URL %q", rawURL)
}
return b
}
func (b *RequestBuilder) Header(k, v string) *RequestBuilder {
if b.err != nil {
return b
}
b.headers.Add(k, v)
return b
}
func (b *RequestBuilder) Query(k, v string) *RequestBuilder {
if b.err != nil {
return b
}
b.query.Add(k, v)
return b
}
func (b *RequestBuilder) Body(r io.Reader) *RequestBuilder {
if b.err != nil {
return b
}
if b.jsonBody != nil {
b.err = errors.New("Body: JSONBody already set")
return b
}
b.body = r
return b
}
func (b *RequestBuilder) JSONBody(v any) *RequestBuilder {
if b.err != nil {
return b
}
if b.body != nil {
b.err = errors.New("JSONBody: Body already set")
return b
}
b.jsonBody = v
return b
}
func (b *RequestBuilder) Build() (*http.Request, error) {
if b.err != nil {
return nil, b.err
}
u, err := url.Parse(b.rawURL)
if err != nil {
return nil, fmt.Errorf("Build: parse URL: %w", err)
}
if len(b.query) > 0 {
existing := u.Query()
for k, vs := range b.query {
for _, v := range vs {
existing.Add(k, v)
}
}
u.RawQuery = existing.Encode()
}
var body io.Reader = b.body
if b.jsonBody != nil {
raw, err := json.Marshal(b.jsonBody)
if err != nil {
return nil, fmt.Errorf("Build: marshal JSON: %w", err)
}
body = bytes.NewReader(raw)
if b.headers.Get("Content-Type") == "" {
b.headers.Set("Content-Type", "application/json")
}
}
req, err := http.NewRequest(b.method, u.String(), body)
if err != nil {
return nil, err
}
for k, vs := range b.headers {
for _, v := range vs {
req.Header.Add(k, v)
}
}
return req, nil
}
Discussion. Notice how the mutually-exclusive pair Body / JSONBody is enforced by the deferred-error pattern. A stage-typed builder (junior §5.3) could enforce it at compile time, but for two methods that's overkill. The builder shines exactly when ergonomics > type safety — when the cost of a runtime error is small (a clear message) and the cost of compile-time enforcement is large (extra types).
Task 5: Email builder¶
A MailMessage has many fields, several of which are lists (To, Cc, Bcc, Attachments). Build it via a fluent chain.
msg, err := NewMail().
From("alice@example.com").
To("bob@example.com", "carol@example.com").
Cc("dan@example.com").
Subject("Q3 review").
BodyText("Hi all, ...").
Attachment("report.pdf", pdfBytes).
Build()
Acceptance criteria
- All address fields validate "looks like an email" — at minimum
strings.Contains(s, "@"). -
To,Cc,Bccare additive across calls and within a call (variadic). -
BodyTextandBodyHTMLcan both be set (multipart message). -
Attachment(filename string, data []byte)is additive. -
Build()requiresFromand at least one ofTo/Cc/BccandSubject. - The returned
*MailMessagehas its slices and[]bytecopied — mutating builder state afterBuild()must not affect the message. - A unit test mutates the builder after
Build()and asserts the built message is unchanged.
Hints
- A cheap email check is enough for this exercise. Don't try to implement RFC 5322. - For the deep-copy test, change `b.attachments[0].Data[0] = 0xFF` after `Build` and assert the message's attachment is still intact.Solution
package mail
import (
"errors"
"fmt"
"strings"
)
type Attachment struct {
Filename string
Data []byte
}
type MailMessage struct {
from string
to, cc, bcc []string
subject string
bodyText string
bodyHTML string
attachments []Attachment
}
type Builder struct {
msg MailMessage
err error
}
func NewMail() *Builder { return &Builder{} }
func validEmail(s string) bool { return strings.Contains(s, "@") && !strings.Contains(s, " ") }
func (b *Builder) From(addr string) *Builder {
if b.err != nil {
return b
}
if !validEmail(addr) {
b.err = fmt.Errorf("From: invalid %q", addr)
return b
}
b.msg.from = addr
return b
}
func (b *Builder) addList(name string, dst *[]string, addrs []string) *Builder {
if b.err != nil {
return b
}
for _, a := range addrs {
if !validEmail(a) {
b.err = fmt.Errorf("%s: invalid %q", name, a)
return b
}
}
*dst = append(*dst, addrs...)
return b
}
func (b *Builder) To(addrs ...string) *Builder { return b.addList("To", &b.msg.to, addrs) }
func (b *Builder) Cc(addrs ...string) *Builder { return b.addList("Cc", &b.msg.cc, addrs) }
func (b *Builder) Bcc(addrs ...string) *Builder { return b.addList("Bcc", &b.msg.bcc, addrs) }
func (b *Builder) Subject(s string) *Builder {
if b.err != nil {
return b
}
b.msg.subject = s
return b
}
func (b *Builder) BodyText(s string) *Builder {
if b.err != nil {
return b
}
b.msg.bodyText = s
return b
}
func (b *Builder) BodyHTML(s string) *Builder {
if b.err != nil {
return b
}
b.msg.bodyHTML = s
return b
}
func (b *Builder) Attachment(filename string, data []byte) *Builder {
if b.err != nil {
return b
}
cp := append([]byte(nil), data...)
b.msg.attachments = append(b.msg.attachments, Attachment{Filename: filename, Data: cp})
return b
}
func (b *Builder) Build() (*MailMessage, error) {
if b.err != nil {
return nil, b.err
}
if b.msg.from == "" {
return nil, errors.New("Build: From required")
}
if len(b.msg.to)+len(b.msg.cc)+len(b.msg.bcc) == 0 {
return nil, errors.New("Build: at least one recipient required")
}
if b.msg.subject == "" {
return nil, errors.New("Build: Subject required")
}
m := MailMessage{
from: b.msg.from,
subject: b.msg.subject,
bodyText: b.msg.bodyText,
bodyHTML: b.msg.bodyHTML,
to: append([]string(nil), b.msg.to...),
cc: append([]string(nil), b.msg.cc...),
bcc: append([]string(nil), b.msg.bcc...),
}
m.attachments = make([]Attachment, len(b.msg.attachments))
for i, a := range b.msg.attachments {
m.attachments[i] = Attachment{
Filename: a.Filename,
Data: append([]byte(nil), a.Data...),
}
}
return &m, nil
}
Discussion. The two interesting bits: addList factors the additive-with-validation pattern shared by To/Cc/Bcc (apply DRY where it pays — three nearly-identical methods would otherwise drift apart), and Attachment copies the byte slice at the step rather than at Build(). Either is defensible; copying at the step protects against callers mutating the slice they passed in between the chain and Build.
Task 6: Test user fixture builder¶
A common test helper: produce a *User with sensible defaults but the ability to override anything.
u := NewUserFixture(t).Name("Alice").Role("admin").MustBuild()
admin := NewUserFixture(t).Role("admin").MustBuild()
guest := NewUserFixture(t).Inactive().MustBuild()
Acceptance criteria
-
NewUserFixture(t *testing.T)returns a*UserFixture. Thetis held soMustBuildcan callt.Fatalon error. - Default user: random unique
Name, random uniqueEmail,Role="user",Subscription="active". -
Name(s),Email(s),Role(s),Subscription(s)override. -
Inactive()is a convenience that setsSubscription="inactive". -
Admin()is a convenience that setsRole="admin". -
MustBuild()returns*Userand callst.Fatalif validation fails. -
Build()returns(*User, error)for callers that want to assert errors. - Uniqueness counter prevents two fixtures in the same test run colliding on
Email.
Hints
- Use a package-level `atomic.Int64` for the uniqueness counter. - `MustBuild` is the test-helper convention. Mark it with `t.Helper()`. - Keep `t` separate from the builder's data — don't write to `*testing.T` in step methods.Solution
package fixtures
import (
"errors"
"fmt"
"sync/atomic"
"testing"
)
type User struct {
Name string
Email string
Role string
Subscription string
}
type UserFixture struct {
t *testing.T
user User
err error
}
var fixtureCounter atomic.Int64
func NewUserFixture(t *testing.T) *UserFixture {
n := fixtureCounter.Add(1)
return &UserFixture{
t: t,
user: User{
Name: fmt.Sprintf("Fixture User %d", n),
Email: fmt.Sprintf("fixture-%d@example.com", n),
Role: "user",
Subscription: "active",
},
}
}
func (f *UserFixture) Name(s string) *UserFixture {
if f.err != nil {
return f
}
if s == "" {
f.err = errors.New("Name: empty")
return f
}
f.user.Name = s
return f
}
func (f *UserFixture) Email(s string) *UserFixture {
if f.err != nil {
return f
}
f.user.Email = s
return f
}
func (f *UserFixture) Role(s string) *UserFixture {
if f.err != nil {
return f
}
f.user.Role = s
return f
}
func (f *UserFixture) Subscription(s string) *UserFixture {
if f.err != nil {
return f
}
f.user.Subscription = s
return f
}
func (f *UserFixture) Inactive() *UserFixture { return f.Subscription("inactive") }
func (f *UserFixture) Admin() *UserFixture { return f.Role("admin") }
func (f *UserFixture) Build() (*User, error) {
if f.err != nil {
return nil, f.err
}
u := f.user
return &u, nil
}
func (f *UserFixture) MustBuild() *User {
f.t.Helper()
u, err := f.Build()
if err != nil {
f.t.Fatalf("UserFixture: %v", err)
}
return u
}
Discussion. Fixture builders are the most common builder you'll write in real Go code. The trick is MustBuild versus Build: the test-helper version (MustBuild) calls t.Fatal so the caller doesn't have to write if err != nil { t.Fatal(err) } in every test. Keep both — Build for tests that exercise the validation logic itself, MustBuild for the 95% of tests that just want a valid user.
Task 7: Order fixture with nested line-item builders¶
Extend Task 6 with OrderFixture. An order has a customer and one or more line items, each with its own product, quantity, and price.
order := NewOrderFixture(t).
Customer(u).
LineItem(NewLineItem().Product("Widget").Qty(2).Price(9.99)).
LineItem(NewLineItem().Product("Gadget").Qty(1).Price(29.99)).
MustBuild()
Acceptance criteria
-
LineItembuilder is independent (no*testing.Treference) and has its ownBuild(). -
OrderFixture.LineItem(*LineItemBuilder)accepts the inner builder and calls itsBuild()internally. - An invalid line item propagates to the outer builder's deferred error.
- Default customer is a fresh
UserFixture. -
MustBuildrequires at least one line item. -
Order.Totalis computed inBuild()from the line items.
Hints
- The inner builder is "off the test helper". Its errors flow into the outer fixture, which then surfaces them via `MustBuild`. - Compute `Total` once in `Build`, store it in `Order.Total`. Don't re-compute on every read.Solution
package fixtures
import (
"errors"
"fmt"
"testing"
)
type LineItem struct {
Product string
Qty int
Price float64
}
type LineItemBuilder struct {
li LineItem
err error
}
func NewLineItem() *LineItemBuilder { return &LineItemBuilder{} }
func (b *LineItemBuilder) Product(s string) *LineItemBuilder {
if b.err != nil {
return b
}
if s == "" {
b.err = errors.New("Product: empty")
return b
}
b.li.Product = s
return b
}
func (b *LineItemBuilder) Qty(n int) *LineItemBuilder {
if b.err != nil {
return b
}
if n <= 0 {
b.err = fmt.Errorf("Qty: must be > 0, got %d", n)
return b
}
b.li.Qty = n
return b
}
func (b *LineItemBuilder) Price(p float64) *LineItemBuilder {
if b.err != nil {
return b
}
if p < 0 {
b.err = fmt.Errorf("Price: negative %v", p)
return b
}
b.li.Price = p
return b
}
func (b *LineItemBuilder) Build() (LineItem, error) {
if b.err != nil {
return LineItem{}, b.err
}
if b.li.Product == "" {
return LineItem{}, errors.New("Build: Product required")
}
return b.li, nil
}
type Order struct {
Customer *User
Items []LineItem
Total float64
}
type OrderFixture struct {
t *testing.T
customer *User
items []LineItem
err error
}
func NewOrderFixture(t *testing.T) *OrderFixture {
cust := NewUserFixture(t).MustBuild()
return &OrderFixture{t: t, customer: cust}
}
func (f *OrderFixture) Customer(u *User) *OrderFixture {
if f.err != nil {
return f
}
if u == nil {
f.err = errors.New("Customer: nil")
return f
}
f.customer = u
return f
}
func (f *OrderFixture) LineItem(b *LineItemBuilder) *OrderFixture {
if f.err != nil {
return f
}
li, err := b.Build()
if err != nil {
f.err = fmt.Errorf("LineItem: %w", err)
return f
}
f.items = append(f.items, li)
return f
}
func (f *OrderFixture) Build() (*Order, error) {
if f.err != nil {
return nil, f.err
}
if len(f.items) == 0 {
return nil, errors.New("Build: at least one LineItem required")
}
var total float64
for _, li := range f.items {
total += float64(li.Qty) * li.Price
}
items := append([]LineItem(nil), f.items...)
return &Order{Customer: f.customer, Items: items, Total: total}, nil
}
func (f *OrderFixture) MustBuild() *Order {
f.t.Helper()
o, err := f.Build()
if err != nil {
f.t.Fatalf("OrderFixture: %v", err)
}
return o
}
Discussion. Nested builders are the standard pattern for object graphs. The inner builder builds a line item; the outer fixture aggregates many of them. Two design choices to ponder: do you want LineItem(b) to take a builder (as here) or a value (LineItem(LineItem{...}))? Taking the builder lets the caller use the chain; taking the value is simpler. Library taste varies; the builder-of-builders approach reads best for deep graphs.
Task 8: URL builder¶
Build a *net/url.URL via a fluent chain. The native net/url API is awkward; this is a real ergonomic win.
u, err := NewURL().
Scheme("https").
Host("api.example.com").
Path("v1", "users", "42").
Query("expand", "profile").
Query("expand", "settings").
Fragment("top").
Build()
// → https://api.example.com/v1/users/42?expand=profile&expand=settings#top
Acceptance criteria
-
Scheme(s)accepts only"http"and"https"; anything else is a deferred error. -
Host(s)takes a hostname optionally with a port ("example.com:8080"). -
Path(segments ...string)is additive — each call appends segments. Segments are URL-escaped before joining with/. -
Query(k, v)is additive (same key can be added multiple times). -
Fragment(s)overwrites. -
Build()returns(*url.URL, error). - A test asserts the example string.
Hints
- Use `url.PathEscape` for each segment. - Build `url.Values` once, encode at `Build()`. - `url.URL.String()` does the heavy lifting once the fields are set right.Solution
package urlbuild
import (
"errors"
"fmt"
"net/url"
"strings"
)
type Builder struct {
scheme string
host string
segments []string
query url.Values
fragment string
err error
}
func NewURL() *Builder { return &Builder{query: url.Values{}} }
func (b *Builder) Scheme(s string) *Builder {
if b.err != nil {
return b
}
if s != "http" && s != "https" {
b.err = fmt.Errorf("Scheme: invalid %q", s)
return b
}
b.scheme = s
return b
}
func (b *Builder) Host(h string) *Builder {
if b.err != nil {
return b
}
if h == "" {
b.err = errors.New("Host: empty")
return b
}
b.host = h
return b
}
func (b *Builder) Path(segments ...string) *Builder {
if b.err != nil {
return b
}
for _, s := range segments {
if s == "" {
b.err = errors.New("Path: empty segment")
return b
}
b.segments = append(b.segments, url.PathEscape(s))
}
return b
}
func (b *Builder) Query(k, v string) *Builder {
if b.err != nil {
return b
}
b.query.Add(k, v)
return b
}
func (b *Builder) Fragment(s string) *Builder {
if b.err != nil {
return b
}
b.fragment = s
return b
}
func (b *Builder) Build() (*url.URL, error) {
if b.err != nil {
return nil, b.err
}
if b.scheme == "" {
return nil, errors.New("Build: Scheme required")
}
if b.host == "" {
return nil, errors.New("Build: Host required")
}
u := &url.URL{
Scheme: b.scheme,
Host: b.host,
Path: "/" + strings.Join(b.segments, "/"),
RawQuery: b.query.Encode(),
Fragment: b.fragment,
}
return u, nil
}
Discussion. Compare this with the native net/url. The native API requires you to set fields directly on a url.URL value, remember to PathEscape yourself, build url.Values and call Encode(). The builder collapses all that into a chain. A common production extension: a Param(name, value string) method that does both path-segment substitution (e.g., /users/{id} → /users/42) and query-string injection depending on what the route expects. That's where builders shine — they encode small policies the underlying API can't express.
Task 9: Database connection string builder¶
Build connection strings for Postgres and MySQL. The two formats differ enough that you need two builders sharing a small core.
dsn := Postgres().Host("db.example.com").Port(5432).User("alice").Password("secret").Database("app").SSLMode("require").Build()
// → "host=db.example.com port=5432 user=alice password=secret dbname=app sslmode=require"
dsn := MySQL().User("alice").Password("secret").Host("db.example.com").Port(3306).Database("app").Build()
// → "alice:secret@tcp(db.example.com:3306)/app"
Acceptance criteria
- Two entry points:
Postgres()andMySQL(). - Each returns its own builder type with the appropriate methods.
- Postgres builder produces space-separated
key=valuepairs in deterministic order. - MySQL builder produces
user:password@tcp(host:port)/database. - Both expose
Build() (string, error). - Empty mandatory fields are errors.
- A
String()method on each builder returnsBuild()'s first return value, panicking on error — useful for tests but documented as dangerous.
Hints
- The deterministic order is what makes diffs readable. Iterate over a fixed `[]string` slice of field names, not a `map`. - Don't try to share a base struct between the two. The fields overlap (host, port, user) but the formatting differs — composition is harder than just having two structs.Solution
package dsn
import (
"errors"
"fmt"
"strings"
)
// Postgres
type PGBuilder struct {
host, user, password, database, sslmode string
port int
err error
}
func Postgres() *PGBuilder { return &PGBuilder{port: 5432, sslmode: "disable"} }
func (b *PGBuilder) Host(s string) *PGBuilder { b.host = s; return b }
func (b *PGBuilder) Port(n int) *PGBuilder { b.port = n; return b }
func (b *PGBuilder) User(s string) *PGBuilder { b.user = s; return b }
func (b *PGBuilder) Password(s string) *PGBuilder { b.password = s; return b }
func (b *PGBuilder) Database(s string) *PGBuilder { b.database = s; return b }
func (b *PGBuilder) SSLMode(s string) *PGBuilder { b.sslmode = s; return b }
func (b *PGBuilder) Build() (string, error) {
if b.err != nil {
return "", b.err
}
if b.host == "" || b.user == "" || b.database == "" {
return "", errors.New("Build: host, user, database required")
}
parts := []string{
fmt.Sprintf("host=%s", b.host),
fmt.Sprintf("port=%d", b.port),
fmt.Sprintf("user=%s", b.user),
}
if b.password != "" {
parts = append(parts, fmt.Sprintf("password=%s", b.password))
}
parts = append(parts,
fmt.Sprintf("dbname=%s", b.database),
fmt.Sprintf("sslmode=%s", b.sslmode),
)
return strings.Join(parts, " "), nil
}
func (b *PGBuilder) String() string {
s, err := b.Build()
if err != nil {
panic(err)
}
return s
}
// MySQL
type MySQLBuilder struct {
host, user, password, database string
port int
err error
}
func MySQL() *MySQLBuilder { return &MySQLBuilder{port: 3306} }
func (b *MySQLBuilder) Host(s string) *MySQLBuilder { b.host = s; return b }
func (b *MySQLBuilder) Port(n int) *MySQLBuilder { b.port = n; return b }
func (b *MySQLBuilder) User(s string) *MySQLBuilder { b.user = s; return b }
func (b *MySQLBuilder) Password(s string) *MySQLBuilder { b.password = s; return b }
func (b *MySQLBuilder) Database(s string) *MySQLBuilder { b.database = s; return b }
func (b *MySQLBuilder) Build() (string, error) {
if b.err != nil {
return "", b.err
}
if b.host == "" || b.user == "" || b.database == "" {
return "", errors.New("Build: host, user, database required")
}
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s", b.user, b.password, b.host, b.port, b.database), nil
}
Discussion. Two builders, almost identical inputs, very different outputs. The DRY principle would tempt you to share a base struct — don't. The formatting logic is the actual variance, and trying to parametrise it across drivers ("MySQL uses : between user and password; Postgres uses ") buys you nothing readable. Duplicate the fields; each builder owns its rendering.
Task 10: AST node builder for boolean expressions¶
A small DSL: assemble a boolean expression tree (AND, OR, NOT, equality leaves) via a builder. Useful as a base for query filters, rule engines, etc.
expr := And(
Eq("role", "admin"),
Or(
Eq("active", true),
Not(Eq("deleted", false)),
),
).Build()
// → (role = "admin" AND (active = true OR NOT (deleted = false)))
Acceptance criteria
- Types:
Expr(interface withString() string), and concreteandExpr,orExpr,notExpr,eqExpr. - Constructors
And(...Expr),Or(...Expr),Not(Expr),Eq(field string, value any). - A
*Builderaccumulates an expression tree and produces the final string viaBuild() (string, error). -
AndandOrwith zero arguments are an error. -
Eqwith emptyfieldis an error. - A nested expression renders with parentheses.
- String values are quoted; ints, bools, etc. use Go's
%v.
Hints
- Each constructor *returns* a `*Builder` that wraps the resulting expression. That builder can be passed to another constructor as a child. - Implement `String()` on each expr type. `Build()` just calls `expr.String()`.Solution
package boolexpr
import (
"errors"
"fmt"
"strings"
)
type Expr interface {
String() string
}
type Builder struct {
expr Expr
err error
}
func (b *Builder) Build() (string, error) {
if b.err != nil {
return "", b.err
}
if b.expr == nil {
return "", errors.New("Build: empty expression")
}
return b.expr.String(), nil
}
type eqExpr struct {
field string
value any
}
func (e eqExpr) String() string {
if s, ok := e.value.(string); ok {
return fmt.Sprintf("%s = %q", e.field, s)
}
return fmt.Sprintf("%s = %v", e.field, e.value)
}
type andExpr struct{ children []Expr }
func (e andExpr) String() string {
parts := make([]string, len(e.children))
for i, c := range e.children {
parts[i] = c.String()
}
return "(" + strings.Join(parts, " AND ") + ")"
}
type orExpr struct{ children []Expr }
func (e orExpr) String() string {
parts := make([]string, len(e.children))
for i, c := range e.children {
parts[i] = c.String()
}
return "(" + strings.Join(parts, " OR ") + ")"
}
type notExpr struct{ child Expr }
func (e notExpr) String() string { return "NOT " + e.child.String() }
func unwrap(b *Builder) (Expr, error) {
if b.err != nil {
return nil, b.err
}
return b.expr, nil
}
func Eq(field string, v any) *Builder {
if field == "" {
return &Builder{err: errors.New("Eq: empty field")}
}
return &Builder{expr: eqExpr{field: field, value: v}}
}
func And(children ...*Builder) *Builder {
if len(children) == 0 {
return &Builder{err: errors.New("And: no children")}
}
exprs := make([]Expr, 0, len(children))
for _, c := range children {
e, err := unwrap(c)
if err != nil {
return &Builder{err: err}
}
exprs = append(exprs, e)
}
return &Builder{expr: andExpr{children: exprs}}
}
func Or(children ...*Builder) *Builder {
if len(children) == 0 {
return &Builder{err: errors.New("Or: no children")}
}
exprs := make([]Expr, 0, len(children))
for _, c := range children {
e, err := unwrap(c)
if err != nil {
return &Builder{err: err}
}
exprs = append(exprs, e)
}
return &Builder{expr: orExpr{children: exprs}}
}
func Not(child *Builder) *Builder {
e, err := unwrap(child)
if err != nil {
return &Builder{err: err}
}
return &Builder{expr: notExpr{child: e}}
}
Discussion. This task is half-builder, half-DSL. Notice how the "chain" looks like a tree of constructor calls, not a . chain. That's deliberate: AST construction is not linear, so a linear chain is the wrong shape. The builder lives inside each node as the error-carrier; the user-facing API is the constructor functions. When the domain isn't sequential, abandon the dot-chain — the spirit of the pattern (separate type for assembly, terminal Build()) survives without it.
Task 11: Mock HTTP server builder¶
A test helper that spins up an httptest.Server whose responses you configured ahead of time.
srv := NewMockServer().
Route("GET", "/health", JSON(200, map[string]any{"ok": true})).
Route("POST", "/users", JSON(201, map[string]any{"id": 1})).
Default(JSON(404, map[string]string{"error": "not found"})).
Middleware(LogMiddleware(t)).
Start()
defer srv.Close()
Acceptance criteria
-
NewMockServer()returns a builder. -
Route(method, path string, handler http.Handler)registers a route. Method match is exact. -
Default(handler http.Handler)registers a fallback. -
Middleware(mw func(http.Handler) http.Handler)wraps the entire router. Multiple middlewares wrap in the order added. -
Start()returns a*httptest.Serverready to use.URL()returns its base URL. - Helper
JSON(status int, body any) http.Handlerproduces a JSON-responding handler. - A test using
http.Get(srv.URL() + "/health")asserts the configured response.
Hints
- Use `http.ServeMux` as the underlying router, then wrap with middlewares. - Apply middlewares in *reverse* order so the first one in the chain runs first. - `Start()` is your terminal; it's allowed to be more complex than `Build()` because it also starts a server.Solution
package mocksrv
import (
"encoding/json"
"net/http"
"net/http/httptest"
)
type Middleware func(http.Handler) http.Handler
type route struct {
method string
path string
handler http.Handler
}
type Builder struct {
routes []route
defaultH http.Handler
middlewares []Middleware
}
func NewMockServer() *Builder { return &Builder{} }
func (b *Builder) Route(method, path string, h http.Handler) *Builder {
b.routes = append(b.routes, route{method: method, path: path, handler: h})
return b
}
func (b *Builder) Default(h http.Handler) *Builder {
b.defaultH = h
return b
}
func (b *Builder) Middleware(mw Middleware) *Builder {
b.middlewares = append(b.middlewares, mw)
return b
}
func JSON(status int, body any) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
})
}
func (b *Builder) Start() *httptest.Server {
mux := http.NewServeMux()
routes := b.routes
defaultH := b.defaultH
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
for _, rt := range routes {
if rt.method == r.Method && rt.path == r.URL.Path {
rt.handler.ServeHTTP(w, r)
return
}
}
if defaultH != nil {
defaultH.ServeHTTP(w, r)
return
}
http.NotFound(w, r)
})
var h http.Handler = mux
for i := len(b.middlewares) - 1; i >= 0; i-- {
h = b.middlewares[i](h)
}
return httptest.NewServer(h)
}
Discussion. The builder's terminal does something — it starts a server. That's outside the "construct an object" mental model. The pattern still fits: assemble configuration, then act on it in the terminal. Compare with the database/sql pattern where db, err := sql.Open(...) is conceptually OpenBuilder.Open() — there's an action embedded in the terminal. Be deliberate about which terminal names imply what; Start, Open, Listen, Connect, Run all carry implications a plain Build doesn't.
Task 12: Validate-step builder with deferred-error pattern¶
A library that validates user input across multiple steps. Each step adds a check; Build() returns the first error or the validated object.
user, err := Validate(input).
Required("Name").
Required("Email").
Pattern("Email", emailRegex).
Range("Age", 0, 150).
Custom(func(u User) error {
if u.Role == "admin" && u.MFAEnabled == false {
return errors.New("admin must have MFA")
}
return nil
}).
Build()
Acceptance criteria
-
Validate(input User)returns a*Validator. -
Required(fieldName)uses reflection (or a switch) to assert the named field is non-zero. -
Pattern(fieldName, regex)checks a string field against a compiled regex. -
Range(fieldName, min, max int)checks an int field is in[min, max]. -
Custom(func(User) error)runs an arbitrary check. - Errors accumulate —
Build()returns a slice of all errors, not just the first. - If at least one error,
Build()returns(User{}, []error). Otherwise(input, nil). - A test asserts that two failing checks produce two errors.
Hints
- Accumulating errors is the variant where deferred-error becomes deferred-*errors* (plural). - Use `reflect` for the field access, or a hand-written switch on the field name. Switch is easier for this exercise. - Don't short-circuit — every check should run, even if earlier ones failed.Solution
package validate
import (
"errors"
"fmt"
"regexp"
)
type User struct {
Name string
Email string
Age int
Role string
MFAEnabled bool
}
type Validator struct {
input User
errs []error
}
func Validate(u User) *Validator { return &Validator{input: u} }
func (v *Validator) Required(field string) *Validator {
switch field {
case "Name":
if v.input.Name == "" {
v.errs = append(v.errs, errors.New("Required: Name"))
}
case "Email":
if v.input.Email == "" {
v.errs = append(v.errs, errors.New("Required: Email"))
}
default:
v.errs = append(v.errs, fmt.Errorf("Required: unknown field %q", field))
}
return v
}
func (v *Validator) Pattern(field string, re *regexp.Regexp) *Validator {
var s string
switch field {
case "Name":
s = v.input.Name
case "Email":
s = v.input.Email
default:
v.errs = append(v.errs, fmt.Errorf("Pattern: unknown field %q", field))
return v
}
if !re.MatchString(s) {
v.errs = append(v.errs, fmt.Errorf("Pattern: %s does not match", field))
}
return v
}
func (v *Validator) Range(field string, min, max int) *Validator {
var n int
switch field {
case "Age":
n = v.input.Age
default:
v.errs = append(v.errs, fmt.Errorf("Range: unknown field %q", field))
return v
}
if n < min || n > max {
v.errs = append(v.errs, fmt.Errorf("Range: %s=%d not in [%d,%d]", field, n, min, max))
}
return v
}
func (v *Validator) Custom(f func(User) error) *Validator {
if err := f(v.input); err != nil {
v.errs = append(v.errs, fmt.Errorf("Custom: %w", err))
}
return v
}
func (v *Validator) Build() (User, []error) {
if len(v.errs) > 0 {
return User{}, v.errs
}
return v.input, nil
}
Discussion. Two extensions of the basic pattern: (1) Build returns []error instead of one, because validators are most useful when they report every problem at once; (2) each step runs unconditionally — we don't short-circuit on the first error. This trades the single-error simplicity of junior.md §7 for completeness. It's the right call for validation; the wrong call for, say, a TCP connection builder where the first failure makes everything else moot.
Task 13: Cloneable builder with deep-copy semantics¶
A query builder where callers fork mid-chain. Implement Clone() correctly.
base := Select("id", "name").From("users").Where("active = ?", true)
adultsQuery := base.Clone().Where("age >= ?", 18)
minorsQuery := base.Clone().Where("age < ?", 18)
adultsSQL, _, _ := adultsQuery.Build()
minorsSQL, _, _ := minorsQuery.Build()
// adults: SELECT id, name FROM users WHERE active = ? AND age >= ?
// minors: SELECT id, name FROM users WHERE active = ? AND age < ?
Acceptance criteria
- Take your Task 2 SQL builder and add a
Clone()method. -
Clone()deep-copies every slice and map field. - After cloning, mutating the original does not affect the clone, and vice versa.
- A unit test calls
Clone, thenWhereon the clone, and asserts the original'swheresslice is unchanged. - A second test exercises the "spare capacity" trap:
b.wheres = make([]string, 1, 10), clone, append on the clone, assert original index 1+ is unaffected.
Hints
- The shallow `c := *b` followed by `return &c` is the bug. Slice headers share their backing array. - `append([]string(nil), b.wheres...)` is the idiomatic deep-copy of a slice. - Maps need `make(map[K]V, len(orig))` plus a loop.Solution
package query
func (b *Builder) Clone() *Builder {
c := *b
c.columns = append([]string(nil), b.columns...)
c.wheres = append([]string(nil), b.wheres...)
c.args = append([]any(nil), b.args...)
return &c
}
package main
import (
"fmt"
"example.com/query"
)
func main() {
base := query.Select("id", "name").
From("users").
Where("active = ?", true)
adults := base.Clone().Where("age >= ?", 18)
minors := base.Clone().Where("age < ?", 18)
a, _, _ := adults.Build()
m, _, _ := minors.Build()
fmt.Println(a)
fmt.Println(m)
}
Discussion. Three failure modes you must understand: (1) shared slice headers leak appends across clones; (2) shared map references leak writes across clones; (3) shared pointer-typed values (a *http.Client, a *log.Logger) usually should be shared — they're services, not data. Deciding which is which is the actual engineering work. As a rule: data is copied, services are aliased.
Task 14: Stage-typed builder (compile-time order enforcement)¶
For domains where misordered steps are catastrophic — e.g., a cryptographic key derivation — use Go's type system to enforce the order.
key, err := NewKey().
WithAlgorithm("AES-256"). // returns *NeedSaltBuilder
WithSalt(saltBytes). // returns *NeedRoundsBuilder
WithRounds(100000). // returns *FinalBuilder
Build() // returns *DerivedKey, error
Acceptance criteria
- Four builder types:
*InitialBuilder,*NeedSaltBuilder,*NeedRoundsBuilder,*FinalBuilder. - Each method advances to the next stage by returning a different type.
- Calling
WithSaltdirectly on*InitialBuilderis a compile error, not a runtime error. -
Build()only exists on*FinalBuilder. - No runtime
phase intfield — the types do the work. - A test in a separate
_test.goproves the chain works. A comment in the test points at code that would not compile if uncommented.
Hints
- Each stage carries the accumulated state forward. Don't use embedding; pass it through explicit struct copies (small enough that the copy is cheap). - `Build()` produces the final value — at this point all fields are guaranteed to be set.Solution
package keys
import (
"errors"
"fmt"
)
type DerivedKey struct {
Algorithm string
Salt []byte
Rounds int
// (real implementation would hold derived bytes; omitted)
}
type InitialBuilder struct{}
func NewKey() *InitialBuilder { return &InitialBuilder{} }
func (b *InitialBuilder) WithAlgorithm(alg string) *NeedSaltBuilder {
return &NeedSaltBuilder{alg: alg}
}
type NeedSaltBuilder struct {
alg string
err error
}
func (b *NeedSaltBuilder) WithSalt(s []byte) *NeedRoundsBuilder {
if len(s) < 8 {
return &NeedRoundsBuilder{err: errors.New("WithSalt: too short, need >= 8 bytes")}
}
cp := append([]byte(nil), s...)
return &NeedRoundsBuilder{alg: b.alg, salt: cp}
}
type NeedRoundsBuilder struct {
alg string
salt []byte
err error
}
func (b *NeedRoundsBuilder) WithRounds(n int) *FinalBuilder {
if b.err != nil {
return &FinalBuilder{err: b.err}
}
if n < 1000 {
return &FinalBuilder{err: fmt.Errorf("WithRounds: too few %d", n)}
}
return &FinalBuilder{alg: b.alg, salt: b.salt, rounds: n}
}
type FinalBuilder struct {
alg string
salt []byte
rounds int
err error
}
func (b *FinalBuilder) Build() (*DerivedKey, error) {
if b.err != nil {
return nil, b.err
}
return &DerivedKey{
Algorithm: b.alg,
Salt: append([]byte(nil), b.salt...),
Rounds: b.rounds,
}, nil
}
package keys
import "testing"
func TestKey(t *testing.T) {
k, err := NewKey().
WithAlgorithm("AES-256").
WithSalt([]byte("12345678")).
WithRounds(100000).
Build()
if err != nil {
t.Fatal(err)
}
if k.Rounds != 100000 {
t.Fatalf("rounds: %d", k.Rounds)
}
// The following would not compile — uncomment to verify:
// NewKey().WithSalt([]byte("12345678")) // *InitialBuilder has no WithSalt method
}
Discussion. Three types per chain, each holding strictly more state than the last. Verbose? Yes. Worth it? For one-off use cases, no — use the deferred-error pattern. For library APIs where callers will rely on the compiler to catch order mistakes (cryptographic ceremonies, protocol handshakes, anything where misordering can corrupt data silently), absolutely yes. The Rust ecosystem ("typestate") leans on this heavily; Go developers generally prefer runtime checks because the type machinery is unergonomic for evolution (adding a step is a 4-file refactor).
Task 15: Generic builder using type parameters¶
A reusable builder skeleton that any target type can plug into.
type Server struct {
Addr string
Timeout time.Duration
}
srv, err := NewBuilder[Server]().
Set(func(s *Server) { s.Addr = ":8080" }).
Set(func(s *Server) { s.Timeout = 5 * time.Second }).
Build()
Acceptance criteria
-
type Builder[T any] struct { ... }. -
NewBuilder[T any]() *Builder[T]returns an empty builder. -
Set(func(*T)) *Builder[T]accumulates apply-funcs. -
Validate(func(T) error) *Builder[T]accumulates validators run inBuild. -
Build() (*T, error)zero-initialisesT, applies eachSetin order, then runs eachValidate. - If any validator returns an error,
Buildreturns the first one. - Demonstrate with two unrelated targets (e.g.,
ServerandPizza) inmain.
Hints
- The generic builder is, structurally, functional options dressed up. It's most useful as a *framework piece* for code-generated builders. - Keep `Set` and `Validate` clearly distinct in your mind — `Set` writes, `Validate` reads.Solution
package gbuild
import (
"fmt"
)
type Builder[T any] struct {
applies []func(*T)
validates []func(T) error
}
func NewBuilder[T any]() *Builder[T] { return &Builder[T]{} }
func (b *Builder[T]) Set(f func(*T)) *Builder[T] {
if f != nil {
b.applies = append(b.applies, f)
}
return b
}
func (b *Builder[T]) Validate(f func(T) error) *Builder[T] {
if f != nil {
b.validates = append(b.validates, f)
}
return b
}
func (b *Builder[T]) Build() (*T, error) {
var t T
for _, a := range b.applies {
a(&t)
}
for i, v := range b.validates {
if err := v(t); err != nil {
return nil, fmt.Errorf("Build: validator %d: %w", i, err)
}
}
return &t, nil
}
package main
import (
"errors"
"fmt"
"time"
"example.com/gbuild"
)
type Server struct {
Addr string
Timeout time.Duration
}
type Pizza struct {
Size string
}
func main() {
srv, err := gbuild.NewBuilder[Server]().
Set(func(s *Server) { s.Addr = ":8080" }).
Set(func(s *Server) { s.Timeout = 5 * time.Second }).
Validate(func(s Server) error {
if s.Addr == "" {
return errors.New("Addr required")
}
return nil
}).
Build()
fmt.Printf("%+v err=%v\n", srv, err)
pizza, err := gbuild.NewBuilder[Pizza]().
Set(func(p *Pizza) { p.Size = "large" }).
Build()
fmt.Printf("%+v err=%v\n", pizza, err)
}
Discussion. Re-read middle.md §5 alongside this task. The generic builder is rarely the right end-user API — the func(*T) argument is awkward to read. It shines as infrastructure in libraries that auto-generate fluent builders from struct definitions (think: a code generator that scans a struct's fields and emits typed WithFoo, WithBar methods, with the generic builder handling the underlying mechanics). In application code, prefer one builder per target type with named methods.
Task 16: Builder ↔ functional-options hybrid¶
A package that supports both a structured builder API and injectable functional options for advanced configuration.
srv, err := NewServer().
Addr(":8080").
ReadTimeout(5*time.Second).
With(
WithMaxConns(1000),
WithRateLimit(100),
WithTracing(tracer),
).
Build()
Acceptance criteria
-
*Builderexposes named methodsAddr,ReadTimeoutfor "core" configuration. -
type Option func(*Builder). -
With(opts ...Option) *Builderapplies each option to the builder. -
WithMaxConns,WithRateLimit,WithTracingare options for "advanced" knobs. - Options skip nil values (
if opt == nil { continue }). - Build assembles a
*Serverwhose fields are populated from both the core methods and the options.
Hints
- The trick is `With` returning `*Builder` so it chains. - Options act on the *builder*, not the final `*Server`. The builder copies into `Server` at `Build()`.Solution
package server
import (
"errors"
"time"
)
type Tracer interface{ Trace(string) }
type Server struct {
Addr string
ReadTimeout time.Duration
MaxConns int
RateLimit int
Tracer Tracer
}
type Builder struct {
addr string
readTimeout time.Duration
maxConns int
rateLimit int
tracer Tracer
err error
}
func NewServer() *Builder { return &Builder{readTimeout: 30 * time.Second, maxConns: 100} }
func (b *Builder) Addr(s string) *Builder {
if b.err != nil {
return b
}
if s == "" {
b.err = errors.New("Addr: empty")
return b
}
b.addr = s
return b
}
func (b *Builder) ReadTimeout(d time.Duration) *Builder {
if b.err != nil {
return b
}
b.readTimeout = d
return b
}
type Option func(*Builder)
func (b *Builder) With(opts ...Option) *Builder {
if b.err != nil {
return b
}
for _, o := range opts {
if o == nil {
continue
}
o(b)
}
return b
}
func WithMaxConns(n int) Option { return func(b *Builder) { b.maxConns = n } }
func WithRateLimit(n int) Option { return func(b *Builder) { b.rateLimit = n } }
func WithTracing(t Tracer) Option { return func(b *Builder) { b.tracer = t } }
func (b *Builder) Build() (*Server, error) {
if b.err != nil {
return nil, b.err
}
if b.addr == "" {
return nil, errors.New("Build: Addr required")
}
return &Server{
Addr: b.addr,
ReadTimeout: b.readTimeout,
MaxConns: b.maxConns,
RateLimit: b.rateLimit,
Tracer: b.tracer,
}, nil
}
Discussion. The hybrid splits configuration into two categories: core (named builder methods) and advanced (functional options). The line is policy, not technique — you decide which knobs are "first-class" enough to deserve a method. As the API evolves, you can add new options without breaking callers, and promote one to a method when it becomes core. This is how database/sql.OpenDB, gorilla/mux, and several other widely-used libraries handle the long tail of optional configuration.
Task 17: Multi-terminal builder — Build, Plan, Explain¶
Extend the SQL SELECT builder from Task 2 with three terminals:
Build()— returns SQL + args.Plan()— returns a human-readable description of what the query would do.Explain(ctx, db)— prependsEXPLAINand returns the result of running it (without actually running the main query).
Acceptance criteria
- All three terminals are idempotent — calling
Build()thenPlan()thenBuild()returns the same thing as callingBuild()once. - None of the terminals mutate the builder's state.
-
Plan()returns a struct withTable string,Columns []string,Filters []string,Limit int. -
Explain(ctx, db)returns the EXPLAIN output asstring. - Provide a mock
DBinterface so the test doesn't need a real database. - A test calls all three terminals in different orders on the same builder and asserts consistent results.
Hints
- Factor a private helper `b.assemble() (sql string, args []any, plan Plan)` that computes everything. Each terminal calls it. - The mock `DB` interface needs only `QueryContext(ctx, sql, args ...any) (Rows, error)`. Stub it with a fake.Solution
package query
import (
"context"
"errors"
"fmt"
"strings"
)
type Plan struct {
Table string
Columns []string
Filters []string
Limit int
}
type DB interface {
QueryContext(ctx context.Context, sql string, args ...any) (Rows, error)
}
type Rows interface {
Next() bool
Scan(dest ...any) error
Close() error
}
// reuse the builder from Task 2; add these methods:
func (b *Builder) assemble() (string, []any, Plan, error) {
if b.err != nil {
return "", nil, Plan{}, b.err
}
if b.table == "" {
return "", nil, Plan{}, errors.New("assemble: From required")
}
var sb strings.Builder
sb.WriteString("SELECT ")
sb.WriteString(strings.Join(b.columns, ", "))
sb.WriteString(" FROM ")
sb.WriteString(b.table)
if len(b.wheres) > 0 {
sb.WriteString(" WHERE ")
sb.WriteString(strings.Join(b.wheres, " AND "))
}
if b.orderBy != "" {
sb.WriteString(" ORDER BY ")
sb.WriteString(b.orderBy)
}
if b.limit > 0 {
fmt.Fprintf(&sb, " LIMIT %d", b.limit)
}
plan := Plan{
Table: b.table,
Columns: append([]string(nil), b.columns...),
Filters: append([]string(nil), b.wheres...),
Limit: b.limit,
}
return sb.String(), append([]any(nil), b.args...), plan, nil
}
func (b *Builder) Plan() (Plan, error) {
_, _, plan, err := b.assemble()
return plan, err
}
func (b *Builder) Explain(ctx context.Context, db DB) (string, error) {
sql, args, _, err := b.assemble()
if err != nil {
return "", err
}
rows, err := db.QueryContext(ctx, "EXPLAIN "+sql, args...)
if err != nil {
return "", err
}
defer rows.Close()
var out strings.Builder
for rows.Next() {
var line string
if err := rows.Scan(&line); err != nil {
return "", err
}
out.WriteString(line)
out.WriteByte('\n')
}
return out.String(), nil
}
// Build calls assemble like the other terminals.
func (b *Builder) BuildV2() (string, []any, error) {
sql, args, _, err := b.assemble()
return sql, args, err
}
Discussion. Idempotency is the rule that ties this together. Multi-terminal builders are tempting to write incrementally — "let me cache the assembled SQL the first time assemble() runs, since it's expensive" — and that's how you get bugs where Plan() returns one thing before Build() and another after. Keep the terminals pure: every call recomputes from the builder's fields. If profiling shows the cost matters, then add caching, but make it explicitly invariant to call order.
Task 18: Refactoring a config struct into a builder¶
You inherit this code. Refactor it to use a builder without breaking existing callers.
// Existing API:
type Config struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
MaxConns int
TLSCertFile string
TLSKeyFile string
}
func NewServer(cfg Config) (*Server, error) {
// existing logic...
}
Acceptance criteria
- Add a
*ServerBuildertype andNewServerBuilder() *ServerBuilderconstructor. - Add chained methods for every
Configfield. - Add
Build() (*Server, error)that internally constructs aConfigand callsNewServer(cfg). - Existing callers passing a
Configdirectly toNewServerkeep working unchanged. - Add a deprecation note (
// Deprecated:) onNewServer(cfg Config)pointing at the builder. - Defaults that lived in
NewServermove intoNewServerBuilder. - No behaviour change — the builder produces the same
*Serverfor the same inputs.
Hints
- The builder is a *thin adapter* over the existing `NewServer(Config)`. Don't reimplement the logic. - Defaults are subtle: if `NewServer(cfg)` previously filled in zero values, those defaults must now be in the builder.Solution
package server
import (
"errors"
"time"
)
type Config struct {
Addr string
ReadTimeout time.Duration
WriteTimeout time.Duration
MaxConns int
TLSCertFile string
TLSKeyFile string
}
// Server itself, omitted; pretend NewServer exists.
type Server struct{ cfg Config }
// Deprecated: prefer NewServerBuilder for safer construction with defaults.
func NewServer(cfg Config) (*Server, error) {
if cfg.Addr == "" {
return nil, errors.New("NewServer: Addr required")
}
// fill defaults that the old API used to apply
if cfg.ReadTimeout == 0 {
cfg.ReadTimeout = 30 * time.Second
}
if cfg.WriteTimeout == 0 {
cfg.WriteTimeout = 30 * time.Second
}
if cfg.MaxConns == 0 {
cfg.MaxConns = 100
}
return &Server{cfg: cfg}, nil
}
type ServerBuilder struct {
cfg Config
err error
}
func NewServerBuilder() *ServerBuilder {
return &ServerBuilder{cfg: Config{
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
MaxConns: 100,
}}
}
func (b *ServerBuilder) Addr(s string) *ServerBuilder {
if b.err != nil {
return b
}
b.cfg.Addr = s
return b
}
func (b *ServerBuilder) ReadTimeout(d time.Duration) *ServerBuilder {
if b.err != nil {
return b
}
b.cfg.ReadTimeout = d
return b
}
func (b *ServerBuilder) WriteTimeout(d time.Duration) *ServerBuilder {
if b.err != nil {
return b
}
b.cfg.WriteTimeout = d
return b
}
func (b *ServerBuilder) MaxConns(n int) *ServerBuilder {
if b.err != nil {
return b
}
b.cfg.MaxConns = n
return b
}
func (b *ServerBuilder) TLS(certFile, keyFile string) *ServerBuilder {
if b.err != nil {
return b
}
b.cfg.TLSCertFile = certFile
b.cfg.TLSKeyFile = keyFile
return b
}
func (b *ServerBuilder) Build() (*Server, error) {
if b.err != nil {
return nil, b.err
}
return NewServer(b.cfg)
}
Discussion. Migration tasks are where you earn the right to refactor in real codebases. The rules: never break the old API in the same change you add the new one. Mark old APIs with // Deprecated: (vet picks it up). Let the new API call the old one internally where possible — duplication of logic is worse than duplication of surface. When the old API is gone in a future major version, you'll already have one battle-tested implementation behind both surfaces.
Task 19: Mini-project — a small httpserver package¶
Build a small httpserver package whose entire public API is a fluent builder. The result wraps net/http's server but adds: structured logging, graceful shutdown, route registration with middlewares, and TLS configuration.
srv := httpserver.New().
Addr(":8080").
ReadTimeout(5 * time.Second).
WriteTimeout(10 * time.Second).
Route("GET", "/health", healthHandler).
Route("POST", "/users", usersHandler).
Middleware(loggingMiddleware).
Middleware(authMiddleware).
GracefulShutdown(15 * time.Second).
Logger(slog.Default()).
MustStart(ctx)
Acceptance criteria
-
httpserver.New() *Builder. -
Addr,ReadTimeout,WriteTimeout,Logger,GracefulShutdown— each chainable. -
Route(method, path string, h http.Handler)registers a route. Multiple routes accumulate. -
Middleware(func(http.Handler) http.Handler)appends a middleware applied to all routes in registration order. -
TLS(certFile, keyFile string)switches to HTTPS. -
Start(ctx) errorruns the server. Whenctxis cancelled, gracefully shuts down within the configured timeout. -
MustStart(ctx)is a panic-on-error variant formainfunctions. - A demo
mainshows a real chain producing a working server.
Hints
- Use `http.ServeMux` underneath; wrap routes with middlewares. - Use `errgroup` or `select` over `ctx.Done()` and the server's error channel. - `srv.Shutdown(shutdownCtx)` handles graceful termination.Solution
package httpserver
import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
)
type Middleware func(http.Handler) http.Handler
type route struct {
method, path string
handler http.Handler
}
type Builder struct {
addr string
readTimeout time.Duration
writeTimeout time.Duration
shutdownTimeout time.Duration
tlsCert, tlsKey string
routes []route
middlewares []Middleware
logger *slog.Logger
err error
}
func New() *Builder {
return &Builder{
addr: ":8080",
readTimeout: 30 * time.Second,
writeTimeout: 30 * time.Second,
shutdownTimeout: 5 * time.Second,
logger: slog.Default(),
}
}
func (b *Builder) Addr(s string) *Builder {
if b.err != nil {
return b
}
if s == "" {
b.err = errors.New("Addr: empty")
return b
}
b.addr = s
return b
}
func (b *Builder) ReadTimeout(d time.Duration) *Builder {
if b.err != nil {
return b
}
b.readTimeout = d
return b
}
func (b *Builder) WriteTimeout(d time.Duration) *Builder {
if b.err != nil {
return b
}
b.writeTimeout = d
return b
}
func (b *Builder) GracefulShutdown(d time.Duration) *Builder {
if b.err != nil {
return b
}
b.shutdownTimeout = d
return b
}
func (b *Builder) Logger(l *slog.Logger) *Builder {
if b.err != nil {
return b
}
if l == nil {
b.err = errors.New("Logger: nil")
return b
}
b.logger = l
return b
}
func (b *Builder) TLS(certFile, keyFile string) *Builder {
if b.err != nil {
return b
}
b.tlsCert = certFile
b.tlsKey = keyFile
return b
}
func (b *Builder) Route(method, path string, h http.Handler) *Builder {
if b.err != nil {
return b
}
b.routes = append(b.routes, route{method: method, path: path, handler: h})
return b
}
func (b *Builder) Middleware(mw Middleware) *Builder {
if b.err != nil {
return b
}
if mw == nil {
b.err = errors.New("Middleware: nil")
return b
}
b.middlewares = append(b.middlewares, mw)
return b
}
func (b *Builder) build() (*http.Server, error) {
if b.err != nil {
return nil, b.err
}
mux := http.NewServeMux()
for _, rt := range b.routes {
var h http.Handler = rt.handler
for i := len(b.middlewares) - 1; i >= 0; i-- {
h = b.middlewares[i](h)
}
method, path := rt.method, rt.path
mux.Handle(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != method {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
h.ServeHTTP(w, r)
}))
}
srv := &http.Server{
Addr: b.addr,
Handler: mux,
ReadTimeout: b.readTimeout,
WriteTimeout: b.writeTimeout,
}
if b.tlsCert != "" && b.tlsKey != "" {
srv.TLSConfig = &tls.Config{MinVersion: tls.VersionTLS12}
}
return srv, nil
}
func (b *Builder) Start(ctx context.Context) error {
srv, err := b.build()
if err != nil {
return err
}
errCh := make(chan error, 1)
go func() {
b.logger.Info("server starting", "addr", srv.Addr)
if b.tlsCert != "" {
errCh <- srv.ListenAndServeTLS(b.tlsCert, b.tlsKey)
} else {
errCh <- srv.ListenAndServe()
}
}()
select {
case <-ctx.Done():
b.logger.Info("server shutting down")
shutCtx, cancel := context.WithTimeout(context.Background(), b.shutdownTimeout)
defer cancel()
if err := srv.Shutdown(shutCtx); err != nil {
return fmt.Errorf("shutdown: %w", err)
}
return nil
case err := <-errCh:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
}
}
func (b *Builder) MustStart(ctx context.Context) {
if err := b.Start(ctx); err != nil {
panic(err)
}
}
package main
import (
"context"
"net/http"
"os/signal"
"syscall"
"time"
"example.com/httpserver"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
httpserver.New().
Addr(":8080").
ReadTimeout(5*time.Second).
WriteTimeout(10*time.Second).
GracefulShutdown(15*time.Second).
Route("GET", "/health", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("OK"))
})).
Middleware(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
next.ServeHTTP(w, r)
})
}).
MustStart(ctx)
}
Discussion. This is the closest you get in a single file to a "real" library. Notice the trade-offs: Start(ctx) blocks; the caller controls the lifetime via the context; the builder accepts middlewares but applies them per-route (a common variation is to apply globally — both work). The pattern earns its complexity because the final API is one chain that does what would otherwise take 30 lines of http.Server setup. That's the test for whether your builder pulls its weight.
Task 20: Fluent test-assertion builder¶
A Expect(x) library where assertions chain.
Expect(t, user).
IsNotNil().
Field("Role").IsEqual("admin").
Field("Email").Matches(`.*@example\.com`).
Field("Age").IsBetween(18, 120).
Done()
Acceptance criteria
-
Expect(t *testing.T, v any)returns a*Expectation. -
IsNotNil()fails the test ifvis nil. -
Field(name string) *FieldExpectationreturns a child expectation focused on a struct field. Uses reflection. -
FieldExpectation.IsEqual(want any)fails the test on inequality. -
FieldExpectation.Matches(pattern string)works only for string fields. -
FieldExpectation.IsBetween(lo, hi int)works only for int fields. - Each
FieldExpectationmethod returns*Expectationso the chain returns to the top-level subject. -
Done()is a no-op terminal that signals the end — useful for IDEs andgofmt. - Errors call
t.Errorf, nott.Fatalf— multiple field failures are reported in one test.
Hints
- Use `reflect.ValueOf(v).Elem().FieldByName(name)` for struct field access. Handle the pointer-deref carefully. - `Field` returns a wrapper type; its methods chain back to `*Expectation` so the top-level chain stays uniform. - Don't try to be type-safe at compile time; this is a test helper — runtime errors are acceptable and informative.Solution
package expect
import (
"fmt"
"reflect"
"regexp"
"testing"
)
type Expectation struct {
t *testing.T
v any
}
func Expect(t *testing.T, v any) *Expectation {
t.Helper()
return &Expectation{t: t, v: v}
}
func (e *Expectation) IsNotNil() *Expectation {
e.t.Helper()
if e.v == nil {
e.t.Errorf("Expect: value is nil")
return e
}
rv := reflect.ValueOf(e.v)
if rv.Kind() == reflect.Pointer && rv.IsNil() {
e.t.Errorf("Expect: value is nil pointer")
}
return e
}
type FieldExpectation struct {
parent *Expectation
name string
val reflect.Value
ok bool
}
func (e *Expectation) Field(name string) *FieldExpectation {
e.t.Helper()
rv := reflect.ValueOf(e.v)
for rv.Kind() == reflect.Pointer {
if rv.IsNil() {
e.t.Errorf("Field(%q): nil pointer", name)
return &FieldExpectation{parent: e, name: name}
}
rv = rv.Elem()
}
if rv.Kind() != reflect.Struct {
e.t.Errorf("Field(%q): not a struct", name)
return &FieldExpectation{parent: e, name: name}
}
f := rv.FieldByName(name)
if !f.IsValid() {
e.t.Errorf("Field(%q): no such field", name)
return &FieldExpectation{parent: e, name: name}
}
return &FieldExpectation{parent: e, name: name, val: f, ok: true}
}
func (f *FieldExpectation) IsEqual(want any) *Expectation {
f.parent.t.Helper()
if !f.ok {
return f.parent
}
got := f.val.Interface()
if !reflect.DeepEqual(got, want) {
f.parent.t.Errorf("Field(%q): got %v want %v", f.name, got, want)
}
return f.parent
}
func (f *FieldExpectation) Matches(pattern string) *Expectation {
f.parent.t.Helper()
if !f.ok {
return f.parent
}
if f.val.Kind() != reflect.String {
f.parent.t.Errorf("Field(%q).Matches: not a string", f.name)
return f.parent
}
re, err := regexp.Compile(pattern)
if err != nil {
f.parent.t.Errorf("Field(%q).Matches: bad pattern %v", f.name, err)
return f.parent
}
if !re.MatchString(f.val.String()) {
f.parent.t.Errorf("Field(%q).Matches: %q does not match %q", f.name, f.val.String(), pattern)
}
return f.parent
}
func (f *FieldExpectation) IsBetween(lo, hi int) *Expectation {
f.parent.t.Helper()
if !f.ok {
return f.parent
}
switch f.val.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n := int(f.val.Int())
if n < lo || n > hi {
f.parent.t.Errorf("Field(%q).IsBetween: %d not in [%d,%d]", f.name, n, lo, hi)
}
default:
f.parent.t.Errorf("Field(%q).IsBetween: %s, not int", f.name, f.val.Kind())
}
return f.parent
}
func (e *Expectation) Done() {
// no-op terminal — for IDE-clarity and gofmt-friendly chains.
_ = fmt.Sprint(e)
}
Discussion. Two patterns worth absorbing. First, the focus shift via Field(name): the chain pivots from "the object" to "this one field" and back again. The return type swap (*Expectation → *FieldExpectation → *Expectation) is the trick — it lets gofmt line everything up. Second, Done() is a no-op. It exists purely so the chain has a clear end, which matters for chains that span 10+ lines (IDE folding, code review skimming). Most production assertion libraries (testify, gomega) don't bother; they treat the last method call as the implicit end. Whether to add Done() is purely stylistic.
Where to go from here¶
You've built twenty builders covering the core variants:
- pointer-receiver mutating (Tasks 1–5)
- nested object-graph builders (Tasks 6–7)
- domain adapters around awkward APIs (Tasks 8–9)
- DSL-style (Task 10)
- side-effecting terminals (Task 11)
- accumulation-of-errors validators (Task 12)
- clone-and-fork (Task 13)
- stage-typed (Task 14)
- generic (Task 15)
- hybrid with functional options (Task 16)
- multi-terminal (Task 17)
- migration into existing APIs (Task 18)
- mini-libraries (Tasks 19–20)
Read middle.md §17 cheat sheet again — every row corresponds to one of these tasks. The pattern is small but the choice space is large; the only reliable way to internalise it is to have written, and thrown away, builders for many domains.
Next: senior.md covers the architectural questions — versioning a builder API across major releases, builders in code-generated SDKs (protobuf-go, gRPC), and the trade-offs that surface only after a builder lives in a public library for two years.