{} The Go Reference

Creational pattern · Gang of Four · Intermediate

Builder

Construct a complex object step by step, separating how it's built from its final representation.

Creational Intermediate ⏱ 4 min read Complete

🍔 Analogy

At a build-your-own-burger counter you start from a base and add what you want — cheese, bacon, hold the onions — while the kitchen assembles it step by step. You don’t recite a fixed 12-ingredient order; you specify only your changes, and defaults cover the rest.

The problem

An object with many fields — half of them optional — makes constructors miserable. NewServer("localhost", 8080, 30, true, false, nil) is unreadable, and “telescoping” constructors (NewServer, NewServerWithTLS, NewServerWithTLSAndTimeout…) multiply forever. Builder separates construction from the final object, letting callers set only what they care about.

Structure

graph LR
D["defaults<br/>port:8080, timeout:30s"] --> O1["WithPort(443)"]
O1 --> O2["WithTLS()"]
O2 --> F["final Server"]

Idiomatic Go — functional options

This is the Go community’s standard answer. NewServer applies defaults, then each Option closure tweaks the result. Edit and Run:

builder.go — editable & runnable
package main

import (
"fmt"
"time"
)

type Server struct {
host    string
port    int
timeout time.Duration
tls     bool
}

// Option configures a Server.
type Option func(*Server)

func WithPort(p int) Option              { return func(s *Server) { s.port = p } }
func WithTimeout(d time.Duration) Option { return func(s *Server) { s.timeout = d } }
func WithTLS() Option                    { return func(s *Server) { s.tls = true } }

// NewServer applies sensible defaults, then each option in turn.
func NewServer(host string, opts ...Option) *Server {
s := &Server{host: host, port: 8080, timeout: 30 * time.Second} // defaults
for _, opt := range opts {
	opt(s)
}
return s
}

func main() {
a := NewServer("localhost") // all defaults
b := NewServer("example.com", WithPort(443), WithTLS(), WithTimeout(5*time.Second))

fmt.Printf("%+v\n", *a)
fmt.Printf("%+v\n", *b)
}

The classic fluent builder

The GoF “method-chaining” builder is also valid Go — useful when construction is staged or needs a final Build() validation step:

type ServerBuilder struct{ s Server }

func NewServerBuilder(host string) *ServerBuilder {
	return &ServerBuilder{Server{host: host, port: 8080}}
}
func (b *ServerBuilder) Port(p int) *ServerBuilder { b.s.port = p; return b }
func (b *ServerBuilder) TLS() *ServerBuilder        { b.s.tls = true; return b }
func (b *ServerBuilder) Build() Server              { return b.s } // validate here

// NewServerBuilder("x").Port(443).TLS().Build()

🐹 Functional options win for libraries

Because Go has no default or named parameters, functional options are the idiomatic builder — and they’re what gRPC, the AWS SDK, and countless libraries use. The killer feature: you can add WithKeepAlive(...) later and every existing caller still compiles. Reach for the chained builder when you genuinely need staged construction or a validating Build() step.

Options that validate

Real builders often need to reject bad input at construction time. Make the option return an error and stop at the first failure — validation lives with construction, not scattered at first use:

type Option func(*Server) error

func WithPort(p int) Option {
	return func(s *Server) error {
		if p < 1 || p > 65535 {
			return fmt.Errorf("port out of range: %d", p)
		}
		s.port = p
		return nil
	}
}

func NewServer(host string, opts ...Option) (*Server, error) {
	s := &Server{host: host, port: 8080}
	for _, opt := range opts {
		if err := opt(s); err != nil {
			return nil, err
		}
	}
	return s, nil
}

Three ways to configure in Go

Builder isn’t one shape — pick by how the API will evolve:

ApproachLooks likeBest when
Struct literalServer{Host: "x", Port: 443}few fields, internal, stable
Config structNew(Config{...})a stable bundle of mostly-required fields
Functional optionsNew("x", WithTLS())library API, many optional knobs, must stay back-compatible
Fluent builderB("x").Port(443).Build()staged construction or a validating Build() step

In the standard library

  • strings.Builder — accumulate a string without repeated allocations.
  • bytes.Buffer — build up bytes incrementally.
  • net/http and text/template lean on option-style configuration.

Pitfalls

⚠️ Don't out-build a three-field struct

Functional options shine when there are many optional knobs. For a small struct, a named-field literal — Server{Host: "x", Port: 443} — is clearer than a pile of WithX functions. Add the machinery when the option count actually justifies it.

When to use it — and when not

✅ Reach for it when

  • An object has many fields, several of them optional, and you want sensible defaults.
  • You want a readable, extensible construction API — add an option without breaking callers.
  • Construction should be validated or staged before the object is usable.

⛔ Think twice when

  • The object has two or three required fields — a plain struct literal with field names is clearer.
  • You'd add a builder purely for symmetry; don't pay for machinery you don't need.

Check your understanding

Score: 0 / 5

1. What is the idiomatic Go form of Builder?

Go has no named/default parameters or constructor overloading, so the community settled on functional options: NewX(required, ...Option) where each Option is a closure that configures the value.

2. Why prefer functional options over one big constructor?

A long positional constructor is unreadable and brittle; options are self-documenting, order-independent, defaulted, and extensible without breaking existing calls.

3. Which standard-library type literally is a Builder?

strings.Builder builds a string piece by piece with Write/WriteString, then String() yields the result — Builder by name and by design.

4. How do you let a functional option report an invalid value?

Define Option as func(*T) error; NewX applies each and returns early on the first error. This keeps validation at construction time (e.g. WithPort rejecting a negative port) instead of deferring it to first use.

5. When is a plain config struct preferable to functional options?

A Config struct passed to NewX is great for internal code with a stable, mostly-required field set. Functional options earn their keep in libraries with many optional knobs and an evolving API, where adding WithFoo later must not break callers.

Comments

Sign in with GitHub to join the discussion.