Builder Pattern in Go: Functional Options Alternative

Every Go developer eventually faces the same challenge: you need to initialize a struct with many optional parameters, but Go gives you no default parameters, no method overloading, and no named...

Key Insights

  • Functional options provide superior API ergonomics over traditional builders in Go, offering backward-compatible extensibility and self-documenting configuration without sacrificing type safety.
  • The pattern shines for library authors who need stable public APIs, while traditional builders remain useful for complex construction sequences with validation dependencies.
  • Error handling in functional options requires deliberate design—choose between panicking options, error-returning constructors, or a dedicated Validate() method based on your failure modes.

The Configuration Problem

Every Go developer eventually faces the same challenge: you need to initialize a struct with many optional parameters, but Go gives you no default parameters, no method overloading, and no named arguments. You’re left staring at a constructor that looks like this:

type HTTPClient struct {
    baseURL         string
    timeout         time.Duration
    maxRetries      int
    retryBackoff    time.Duration
    headers         map[string]string
    transport       http.RoundTripper
    logger          Logger
    rateLimiter     RateLimiter
    circuitBreaker  CircuitBreaker
    metrics         MetricsCollector
    traceProvider   TraceProvider
    compression     bool
}

func NewHTTPClient(
    baseURL string,
    timeout time.Duration,
    maxRetries int,
    retryBackoff time.Duration,
    headers map[string]string,
    transport http.RoundTripper,
    logger Logger,
    rateLimiter RateLimiter,
    circuitBreaker CircuitBreaker,
    metrics MetricsCollector,
    traceProvider TraceProvider,
    compression bool,
) *HTTPClient {
    // nightmare fuel
}

Callers must provide every parameter, even when they only care about two of them. Worse, adding a new parameter breaks every call site. This is the configuration problem, and Go developers have developed two primary solutions: the traditional builder pattern and functional options.

Traditional Builder Pattern Recap

The builder pattern, borrowed from object-oriented languages, uses method chaining to construct objects incrementally:

type HTTPClientBuilder struct {
    client *HTTPClient
}

func NewHTTPClientBuilder(baseURL string) *HTTPClientBuilder {
    return &HTTPClientBuilder{
        client: &HTTPClient{
            baseURL:    baseURL,
            timeout:    30 * time.Second, // default
            maxRetries: 3,                // default
        },
    }
}

func (b *HTTPClientBuilder) WithTimeout(d time.Duration) *HTTPClientBuilder {
    b.client.timeout = d
    return b
}

func (b *HTTPClientBuilder) WithMaxRetries(n int) *HTTPClientBuilder {
    b.client.maxRetries = n
    return b
}

func (b *HTTPClientBuilder) WithLogger(l Logger) *HTTPClientBuilder {
    b.client.logger = l
    return b
}

func (b *HTTPClientBuilder) Build() (*HTTPClient, error) {
    if b.client.baseURL == "" {
        return nil, errors.New("baseURL is required")
    }
    return b.client, nil
}

// Usage
client, err := NewHTTPClientBuilder("https://api.example.com").
    WithTimeout(10 * time.Second).
    WithMaxRetries(5).
    WithLogger(myLogger).
    Build()

This works, but it introduces problems specific to Go. You now have two types to maintain instead of one. The builder is mutable, which can cause subtle bugs if reused. Method chaining obscures which methods return errors. And the pattern feels distinctly un-idiomatic—Go prefers functions over method chains.

Functional Options Pattern Explained

Functional options flip the builder pattern inside out. Instead of a builder object with methods, you pass configuration functions to the constructor:

type Option func(*HTTPClient)

func NewHTTPClient(baseURL string, opts ...Option) *HTTPClient {
    client := &HTTPClient{
        baseURL:    baseURL,
        timeout:    30 * time.Second,
        maxRetries: 3,
        headers:    make(map[string]string),
    }
    
    for _, opt := range opts {
        opt(client)
    }
    
    return client
}

func WithTimeout(d time.Duration) Option {
    return func(c *HTTPClient) {
        c.timeout = d
    }
}

func WithMaxRetries(n int) Option {
    return func(c *HTTPClient) {
        c.maxRetries = n
    }
}

func WithLogger(l Logger) Option {
    return func(c *HTTPClient) {
        c.logger = l
    }
}

// Usage
client := NewHTTPClient("https://api.example.com",
    WithTimeout(10*time.Second),
    WithMaxRetries(5),
    WithLogger(myLogger),
)

The pattern leverages Go’s first-class functions and variadic parameters. Each option is a closure that captures its configuration value and applies it to the target struct. The constructor handles defaults, then applies options in order.

Implementing Functional Options Step-by-Step

Let’s build a production-ready implementation with proper error handling. The naive approach above ignores validation entirely. Here’s how to handle it properly:

type Server struct {
    addr           string
    readTimeout    time.Duration
    writeTimeout   time.Duration
    maxHeaderBytes int
    tlsConfig      *tls.Config
    handler        http.Handler
}

// Option that can return an error
type Option func(*Server) error

func NewServer(addr string, handler http.Handler, opts ...Option) (*Server, error) {
    if addr == "" {
        return nil, errors.New("addr is required")
    }
    if handler == nil {
        return nil, errors.New("handler is required")
    }

    s := &Server{
        addr:           addr,
        handler:        handler,
        readTimeout:    15 * time.Second,
        writeTimeout:   15 * time.Second,
        maxHeaderBytes: 1 << 20, // 1MB
    }

    for _, opt := range opts {
        if err := opt(s); err != nil {
            return nil, fmt.Errorf("applying option: %w", err)
        }
    }

    return s, nil
}

func WithReadTimeout(d time.Duration) Option {
    return func(s *Server) error {
        if d <= 0 {
            return errors.New("read timeout must be positive")
        }
        s.readTimeout = d
        return nil
    }
}

func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) error {
        cert, err := tls.LoadX509KeyPair(certFile, keyFile)
        if err != nil {
            return fmt.Errorf("loading TLS cert: %w", err)
        }
        s.tlsConfig = &tls.Config{
            Certificates: []tls.Certificate{cert},
            MinVersion:   tls.VersionTLS12,
        }
        return nil
    }
}

func WithMaxHeaderBytes(n int) Option {
    return func(s *Server) error {
        if n <= 0 {
            return errors.New("max header bytes must be positive")
        }
        if n > 10<<20 { // 10MB sanity check
            return errors.New("max header bytes exceeds 10MB limit")
        }
        s.maxHeaderBytes = n
        return nil
    }
}

The error-returning variant catches configuration mistakes at construction time rather than runtime. This is particularly valuable for options like WithTLS that perform I/O operations that can fail.

Comparing Both Approaches

Let’s examine both patterns side by side with identical functionality:

// Builder pattern usage
client, err := NewHTTPClientBuilder("https://api.example.com").
    WithTimeout(10 * time.Second).
    WithMaxRetries(5).
    WithHeader("Authorization", "Bearer token").
    WithHeader("User-Agent", "MyApp/1.0").
    Build()

// Functional options usage  
client, err := NewHTTPClient("https://api.example.com",
    WithTimeout(10*time.Second),
    WithMaxRetries(5),
    WithHeader("Authorization", "Bearer token"),
    WithHeader("User-Agent", "MyApp/1.0"),
)

The call sites look similar, but the differences matter:

Extensibility: Adding a new option to functional options requires only a new function. The constructor signature stays stable. With builders, you add a method, which is also backward-compatible, but you’ve added to an already-growing type.

Composability: Functional options are values. You can store them, combine them, pass them around:

// Create reusable option sets
var ProductionDefaults = []Option{
    WithTimeout(30 * time.Second),
    WithMaxRetries(5),
    WithCircuitBreaker(defaultBreaker),
}

var DebugOptions = []Option{
    WithLogger(debugLogger),
    WithMetrics(verboseMetrics),
}

// Combine them
client, err := NewHTTPClient(url, append(ProductionDefaults, DebugOptions...)...)

Testing: Functional options make testing cleaner. You can inject test-specific options without building separate constructors:

func TestClientTimeout(t *testing.T) {
    client, _ := NewHTTPClient("http://test",
        WithTimeout(1*time.Millisecond), // Force timeout
        WithTransport(slowTransport),
    )
    // test timeout behavior
}

Advanced Patterns and Variations

For complex systems, you can combine functional options with a defaults struct for documentation and IDE support:

type Config struct {
    Timeout    time.Duration
    MaxRetries int
    Logger     Logger
}

var DefaultConfig = Config{
    Timeout:    30 * time.Second,
    MaxRetries: 3,
}

func WithConfig(cfg Config) Option {
    return func(c *HTTPClient) error {
        c.timeout = cfg.Timeout
        c.maxRetries = cfg.MaxRetries
        c.logger = cfg.Logger
        return nil
    }
}

// Users can see all options in one place
client, _ := NewHTTPClient(url, WithConfig(Config{
    Timeout:    10 * time.Second,
    MaxRetries: 5,
}))

With Go 1.18+, you can create generic functional options for common patterns:

type Configurable[T any] interface {
    *T
}

type Option[T any] func(T) error

func Apply[T any](target T, opts ...Option[T]) error {
    for _, opt := range opts {
        if err := opt(target); err != nil {
            return err
        }
    }
    return nil
}

// Now any type can use this pattern
type ServerOption = Option[*Server]
type ClientOption = Option[*HTTPClient]

When to Use Which

Choose functional options when:

  • You’re building a library with a public API that must remain stable
  • Configuration is primarily additive (setting values, enabling features)
  • You want maximum composability and testability
  • Look at gRPC-go, Zap, and Uber’s fx—they all use functional options

Choose traditional builders when:

  • Construction has a complex sequence with dependencies between steps
  • You need to validate partial state during construction
  • The build process involves multiple phases (configure → validate → build)
  • You’re porting code from languages where builders are idiomatic

For application code: Often neither pattern is necessary. A simple config struct with sensible zero values works fine when you control all call sites. Reserve these patterns for boundaries—packages, libraries, and APIs where flexibility and stability matter.

The functional options pattern has become the de facto standard for configurable constructors in Go libraries. It’s not because it’s clever—it’s because it solves real problems: backward compatibility, self-documenting APIs, and composable configuration. When you’re building something others will depend on, that matters more than saving a few lines of code.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.