🍔 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:
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:
| Approach | Looks like | Best when |
|---|---|---|
| Struct literal | Server{Host: "x", Port: 443} | few fields, internal, stable |
| Config struct | New(Config{...}) | a stable bundle of mostly-required fields |
| Functional options | New("x", WithTLS()) | library API, many optional knobs, must stay back-compatible |
| Fluent builder | B("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/httpandtext/templatelean 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.
Related patterns
Provide an interface for creating families of related objects without specifying their concrete types.
creationalFactory MethodDefine an interface for creating an object, but let the implementation decide which concrete type to instantiate.
creationalPrototypeCreate new objects by cloning an existing, configured instance instead of building one from scratch.
Check your understanding
Score: 0 / 51. 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.