Skip to Content
Course content

231: The Functional Options Pattern Revisited

Click on the "Edit" button in the top corner of the screen to edit your slide content.

We've touched on basic struct initialization before, but as your projects grow, you'll hit a wall where your constructors start looking like a grocery list. I've seen this happen in almost every production codebase I've joined. You start with a simple NewServer(port int), and six months later, you're staring at NewServer(port int, timeout time.Duration, maxConn int, logger *log.Logger, tlsConfig *tls.Config). It's a nightmare to maintain, and it's an even worse experience for whoever has to call that function.

The Telescoping Constructor Trap

The most naive approach is simply adding arguments to your constructor as requirements evolve. This is what I call "telescoping." The problem isn't just the length of the function signature; it's the rigidity. If a user only wants to change the tlsConfig but is happy with the default timeout and maxConn, they are still forced to provide values for everything in between.

// This is brittle. Adding one more field breaks every single caller in the app.
func NewServer(port int, timeout time.Duration, maxConn int) *Server {
    return &Server{
        port: port, 
        timeout: timeout, 
        maxConn: maxConn,
    }
}

// Caller has to pass "zero values" for things they don't care about
srv := NewServer(8080, 0, 100) // Does 0 mean "no timeout" or "use default"?

You might think, "I'll just use a Config struct." That's a step up, but it introduces a new ambiguity: the zero-value problem. If you pass a Config struct and the MaxConn field is 0, did the user explicitly want zero connections, or did they just leave the field blank? You end up writing a mountain of if cfg.MaxConn == 0 { s.maxConn = defaultMaxConn } boilerplate, which is tedious and error-prone.

Defining Options as Behavior

This is where the Functional Options pattern becomes a lifesaver. Instead of passing data (a struct or a list of ints), we pass behavior. We define a function type that knows how to modify our server, and then we pass a slice of those functions to the constructor. It feels a bit meta at first, but it's incredibly clean once it clicks.

type Server struct {
    port    int
    timeout time.Duration
    logger  *log.Logger
}

// The magic happens here: a function that modifies a Server
type Option func(*Server)

func WithTimeout(t time.Duration) Option {
    return func(s *Server) {
        s.timeout = t
    }
}

func WithLogger(l *log.Logger) Option {
    return func(s *Server) {
        s.logger = l
    }
}

func NewServer(port int, opts ...Option) *Server {
    // 1. Set your sensible defaults first
    srv := &Server{
        port:    port,
        timeout: 30 * time.Second, 
    }

    // 2. Apply each option provided by the caller
    for _, opt := range opts {
        opt(srv)
    }

    return srv
}

Why This Actually Scales

Think about the API surface now. If I decide next week that the Server needs a MaxHeaderBytes setting, I don't have to change the NewServer signature. I just add a new WithMaxHeaderBytes function. Every existing call to NewServer in the entire codebase continues to compile and run perfectly. I've effectively decoupled the construction of the object from its configuration.

I will admit, there is a small cost: you're allocating a few more functions on the heap during initialization. In 99% of cases—especially for things like servers, database clients, or API wrappers—this cost is completely irrelevant compared to the benefit of a stable, readable API. You're trading a few nanoseconds of startup time for a codebase that doesn't break every time you add a feature. That's a trade I'll take every single time.




📋 Practical Task

Refactor the DatabaseClient Configuration

You have been handed a legacy DatabaseClient implementation that uses a clunky Config struct. This has led to bugs where developers forget to set critical fields, and the code is littered with if cfg.Field == 0 checks.

Your Task: Refactor the following code to use the Functional Options pattern. Implement WithTimeout and WithMaxRetries options, ensuring that NewClient applies defaults first and then overrides them with the provided options.

package main

import (
    "fmt"
    "time"
)

type DatabaseClient struct {
    connString string
    timeout    time.Duration
    maxRetries int
}

type Config struct {
    Timeout    time.Duration
    MaxRetries int
}

// REFACTOR THIS: Remove the Config struct and implement Functional Options
func NewClient(connString string, cfg Config) *DatabaseClient {
    client := &DatabaseClient{
        connString: connString,
        timeout:    5 * time.Second, // Default
        maxRetries: 3,               // Default
    }

    if cfg.Timeout != 0 {
        client.timeout = cfg.Timeout
    }
    if cfg.MaxRetries != 0 {
        client.maxRetries = cfg.MaxRetries
    }

    return client
}

func main() {
    // This should eventually look like: 
    // client := NewClient("postgres://localhost:5432", WithTimeout(10*time.Second))
    client := NewClient("postgres://localhost:5432", Config{Timeout: 10 * time.Second})
    fmt.Printf("Client: %+v\n", client)
}
Rating
0 0

There are no comments for now.

to be the first to leave a comment.