Right, let’s settle this. You’re about to configure a thing in Go—a server, a client, a database connection, some complex monstrosity you built. You quickly realize your constructor function is getting out of hand. It’s starting to look like NewThing(host string, port int, timeout time.Duration, enableLogging bool, maxRetries int, name string, fluxCapacitorCapacity float64) and it’s an unreadable, unmaintainable mess. You need a pattern. You’ve likely seen two: the Options Struct and Functional Options. Let’s break down why both exist and when to use which.

The Options Struct: The Obvious First Draft

This is the pattern you probably think of first because it’s straightforward. You define a struct that holds all your configuration, and your constructor or function takes this struct as an argument. Often, you’ll provide defaults by creating a function to get a default config struct.

type ServerConfig struct {
    Host        string
    Port        int
    Timeout     time.Duration
    MaxConnections int
    TLSConfig   *tls.Config // Can be nil for no TLS
}

// DefaultConfig returns a sensible default configuration.
// This is a best practice; it saves the user from having to populate every field.
func DefaultConfig() ServerConfig {
    return ServerConfig{
        Host:        "localhost",
        Port:        8080,
        Timeout:     30 * time.Second,
        MaxConnections: 1000,
        TLSConfig:   nil,
    }
}

func NewServer(config ServerConfig) (*Server, error) {
    // ... use config.Host, config.Port, etc.
    return &Server{config: config}, nil
}

And you’d use it like this:

// Start with defaults
cfg := DefaultConfig()
// Override the specific things you care about
cfg.Host = "0.0.0.0"
cfg.MaxConnections = 5000

server, err := NewServer(cfg)

Why it works: It’s simple, readable, and self-documenting. You can see all possible options in one place. Adding a new option is trivial: just add a field to the struct. It also plays nicely with serialization libraries like JSON.

The Pitfalls: The big one is that the user can pass a completely empty struct ServerConfig{}, which might be invalid. You have to manually validate every required field inside NewServer. It also lacks a sense of progression; you can’t easily build the configuration step-by-step. And the TLSConfig field? If it’s a pointer, it could be nil, and that’s a potential source of nil pointer panics you have to handle. It’s clunky.

Functional Options: The Fancy, Powerful Alternative

This pattern is weirder looking but solves the problems of the Options Struct elegantly. The core idea: your constructor takes a required set of arguments and then any number of “option functions” that modify the internal configuration.

type Server struct {
    host        string
    port        int
    timeout     time.Duration
    maxConnections int
    tlsConfig   *tls.Config
}

// Option defines the type of a function that can configure a Server.
type Option func(*Server)

// WithHost returns an Option that sets the host.
func WithHost(host string) Option {
    return func(s *Server) {
        s.host = host
    }
}

// WithPort returns an Option that sets the port.
func WithPort(port int) Option {
    return func(s *Server) {
        s.port = port
    }
}

// WithTimeout returns an Option that sets the timeout.
func WithTimeout(timeout time.Duration) Option {
    return func(s *Server) {
        s.timeout = timeout
    }
}

// WithTLS returns an Option that configures TLS.
// This is more powerful than a struct field; we can do logic here!
func WithTLS(certFile, keyFile string) Option {
    return func(s *Server) {
        cert, err := tls.LoadX509KeyPair(certFile, keyFile)
        if err != nil {
            // This is the tricky part: handling errors in options.
            // We panic here because a function returning a closure
            // can't easily return an error itself. More on this later.
            panic(err)
        }
        s.tlsConfig = &tls.Config{Certificates: []tls.Certificate{cert}}
    }
}

// NewServer creates a new Server with mandatory fields and options.
// Notice the signature: a required 'id', and then zero or more Options.
func NewServer(id string, opts ...Option) *Server {
    // Start with your sensible defaults inside the constructor.
    s := &Server{
        host:    "localhost",
        port:    8080,
        timeout: 30 * time.Second,
        // ... other defaults
    }

    // Apply all option functions to the server instance.
    for _, opt := range opts {
        opt(s)
    }
    return s
}

Usage is clean and self-descriptive:

// Just the defaults
server := NewServer("api-server")

// Override specific options
server := NewServer("api-server",
    WithHost("0.0.0.0"),
    WithPort(443),
    WithTimeout(60*time.Second),
    WithTLS("cert.pem", "key.pem"),
)

Why it’s brilliant: It provides a fantastic user experience. The calls are readable. You get named parameters, essentially. It’s highly composable; you can create a function that returns a set of standard options (StandardProductionOptions() []Option). Most importantly, it allows you to embed logic within the option itself (like the TLS loading), which a dumb struct can’t do.

The Rough Edges: The error handling problem I mentioned is real. If an option like WithTLS can fail, you have to decide: panic (ugly), log and ignore (worse), or find a way to bubble the error up. This often leads to a slightly different pattern where NewServer returns (*Server, error) and the options are applied in a way that can collect errors. It’s more complex to write and has a steeper learning curve for newcomers who just see “magic functions.”

So Which One Do You Use?

Use the Options Struct when your configuration is simple, mostly data, and doesn’t require much internal logic to set up. It’s the “worse is better” approach—perfectly fine and far easier to implement correctly.

Use Functional Options when you have a public API that will be widely used, when configuration is complex, when options require internal logic (like opening files, validating values), or when you truly care about providing a clean, elegant, and powerful interface for the user. It’s more work for you, the library author, but less work and more joy for everyone using your code.