Skip to Content
Course content

98: The Functional Options Pattern

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

I was working on a networking library the other day and ran into a classic Go dilemma: how do I let the user configure a service without making the constructor a nightmare to maintain? I want to start with a few sensible defaults, but I want to give power users the ability to tweak everything.

The Constructor Explosion

My first instinct was the simplest one. I'll just put the configuration in the NewServer function. It starts off easy enough:

type Server struct {
    Addr    string
    Timeout time.Duration
}

func NewServer(addr string, timeout time.Duration) *Server {
    return &Server{Addr: addr, Timeout: timeout}
}

But then the requirements changed. I needed to add MaxConnections. Then I needed a ReadTimeout. Suddenly, my function signature looks like this: NewServer(addr string, timeout time.Duration, maxConn int, readTimeout time.Duration, writeTimeout time.Duration). It's hideous. If I want to add a sixth option, I break every single piece of code that calls this function across the entire project. That's a non-starter.

The "Zero Value" Trap

Okay, so I'll use a config struct. That's a common pattern, right? I'll pass a Config object and the constructor will just use it.

type Config struct {
    Addr    string
    Timeout time.Duration
    MaxConn int
}

func NewServer(cfg Config) *Server {
    return &Server{
        Addr:    cfg.Addr,
        Timeout: cfg.Timeout,
        MaxConn: cfg.MaxConn,
    }
}

Here is where I hit a wall. What happens if the user wants the default MaxConn, but they want to set a custom Timeout? They'll pass a Config struct where MaxConn is 0. But wait—is 0 their intended value, or is it just the default integer zero value? If I want the default to be 100, I can't tell if the user explicitly asked for 0 or if they just didn't provide a value.

I could use pointers in the struct (e.g., *int), but then the caller has to do this annoying dance of creating a variable just to take its address: MaxConn: &someIntVar. It's clunky and feels un-idiomatic.

Flipping the Script with Functions

I realized I didn't need the user to give me a data structure; I needed them to give me instructions on how to modify the server. This is where the Functional Options pattern comes in. Instead of a config struct, I'll define a function type that knows how to update a Server.

type Option func(*Server)

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

func WithMaxConn(n int) Option {
    return func(s *Server) {
        s.MaxConn = n
    }
}

Now, look at how the constructor changes. I can use a variadic parameter to accept any number of these options. I set my defaults first, and then I let the options overwrite them:

func NewServer(addr string, opts ...Option) *Server {
    // 1. Set the sensible defaults
    srv := &Server{
        Addr:    addr,
        Timeout: 30 * time.Second,
        MaxConn: 100,
    }

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

    return srv
}

This is a massive win. If I need to add a new configuration field next week, I just write a new WithSomething function. The NewServer signature never changes, nothing breaks, and the API is incredibly readable. You can see exactly what is being configured at a glance:

// Only change the timeout, keep other defaults
s1 := NewServer("localhost:8080", WithTimeout(10 * time.Second))

// Change everything
s2 := NewServer("localhost:8080", WithTimeout(5*time.Second), WithMaxConn(500))

I've found this to be the "gold standard" for Go libraries. It balances flexibility with a clean, stable API. It's a bit more boilerplate up front to write the With... functions, but your future self (and anyone using your code) will thank you for it.




📋 Practical Task

Implementing a Customizable Database Client

You are building a database client library. The client needs a ConnectionURL (which is mandatory), but it should have optional settings for RetryAttempts (default: 3) and EnableLogging (default: false).

Your Task:

  • Define a Client struct with fields for ConnectionURL, RetryAttempts, and EnableLogging.
  • Create an Option function type.
  • Implement two option functions: WithRetryAttempts(int) and WithLogging(bool).
  • Implement a NewClient(url string, opts ...Option) *Client constructor that applies the defaults and then processes the options.
  • In your main function, instantiate two clients: one using only the defaults, and one with 5 retry attempts and logging enabled.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.