The constructor explosion
You are writing a library. You start with a simple constructor: NewClient(host string). It works. Then a user asks for a port. You add it. Then a timeout. Then TLS settings. Then a custom logger. Then retry logic. Then metrics. Your signature becomes NewClient(host, port, timeout, tls, logger, retries, backoff, metrics).
Callers now have to remember the order of eight arguments. They must pass nil or 0 for every parameter they don't care about. If you add a new parameter later, you break every existing call site. The API is brittle and painful to use.
The Options Pattern solves this by treating configuration as a set of patches applied to a default state. Callers pick only the options they need. The constructor handles the rest.
Options as configuration patches
Think of the Options Pattern like a pizza builder. You start with a base dough that has sensible defaults: tomato sauce and cheese. The customer doesn't hand you a full order form. They hand you a stack of small cards: "Extra Cheese", "No Onions", "Gluten Free". You apply each card to the pizza one by one. The result is the final pizza.
In Go, the "cards" are functions. Each function knows how to tweak the configuration. The constructor creates a config with defaults, loops over the functions, and applies them. The caller passes only the functions they want. The order of arguments doesn't matter because the functions are variadic. The API stays clean even as you add dozens of options.
The minimal skeleton
Here's the core structure: a config struct, a function type that mutates it, and a constructor that loops over variadic options.
// Config holds mutable settings for the service.
type Config struct {
Timeout int
Verbose bool
}
// Option is a function that modifies a Config in place.
// It takes a pointer so changes persist after the function returns.
type Option func(*Config)
// WithTimeout returns a closure that sets the timeout value.
func WithTimeout(d int) Option {
return func(c *Config) { c.Timeout = d }
}
// NewService applies options to a default config and returns a service.
func NewService(opts ...Option) *Service {
// Start with sensible defaults so callers don't have to repeat them.
cfg := &Config{Timeout: 30}
for _, opt := range opts {
opt(cfg) // Apply each patch function to the config.
}
return &Service{cfg: cfg}
}
type Service struct{ cfg *Config }
Options apply patches to a default base. The constructor orchestrates the final state.
How the mechanics work
The magic lives in the ...Option syntax and the closure. The ... tells the compiler that NewService accepts zero or more arguments of type Option. Inside the function, opts is a slice of functions.
When you call WithTimeout(60), the function executes immediately. It captures the argument 60 and returns a new function: func(c *Config) { c.Timeout = 60 }. This returned function is a closure. It holds a reference to the captured value.
NewService receives this closure in the opts slice. The loop iterates and calls opt(cfg). This invokes the closure, passing the config pointer. The closure modifies cfg.Timeout to 60.
The pointer is essential. If Option were func(Config), the function would receive a copy of the config. The closure would modify the copy, and the changes would vanish when the function returns. The compiler allows you to define func(Config), but the logic fails silently. Always use func(*Config).
Use pointers in the Option type. A value receiver copies the config. The option modifies the copy. The constructor keeps the original. You just wasted a function call.
Encapsulation and the private config
The Options Pattern gives you control over the public API. You can hide the config struct entirely. Callers cannot access the struct fields directly because they never see the struct. They only see the exported WithX functions.
This prevents callers from setting fields you didn't intend them to touch. It also lets you change the internal structure without breaking the API. If you rename a field or split the config into two structs, the options can adapt internally while the WithX signatures stay the same.
// config is unexported, so callers cannot access it directly.
type config struct {
timeout time.Duration
retries int
}
// Option modifies the private config.
type Option func(*config)
// WithTimeout sets the timeout.
func WithTimeout(d time.Duration) Option {
return func(c *config) { c.timeout = d }
}
// NewClient returns a client with a private configuration.
func NewClient(opts ...Option) *Client {
c := &config{timeout: 5 * time.Second}
for _, opt := range opts {
opt(c)
}
return &Client{cfg: c}
}
Hide the config struct to enforce encapsulation. Callers can only interact through the exported options.
Validation and error handling
Options often need to validate their input. The standard pattern returns Option, not (Option, error). This keeps the variadic signature clean. If an option receives invalid data, it usually panics.
Panicking in a constructor option is acceptable. It indicates a programming error, not a runtime failure. The caller passed bad data. The program cannot proceed. The panic crashes the program, which forces the developer to fix the call site. This is better than returning a silent error that gets ignored.
The constructor can also validate the final state after all options are applied. This catches conflicts between options. For example, if one option sets retries to 5 and another sets retries to 0, the constructor can check the final value and return an error.
// WithRetry sets the retry count and validates the input immediately.
func WithRetry(count int) Option {
if count < 0 {
// Panic on invalid input to catch configuration mistakes early.
panic("retry count must be non-negative")
}
return func(c *config) { c.retries = count }
}
// NewClient builds a client with validation and defaults.
func NewClient(opts ...Option) (*Client, error) {
c := &config{
timeout: 10 * time.Second,
retries: 3,
}
for _, opt := range opts {
opt(c)
}
// Validate the final state after all options are applied.
if c.timeout == 0 {
return nil, errors.New("timeout cannot be zero")
}
return &Client{cfg: c}, nil
}
Validate inside the option. Panic on bad input. Return errors on bad state.
Composing options and presets
Libraries often provide complex defaults. You can group options into presets. Write a function that returns a slice of options. Callers can spread the slice into the constructor.
This keeps the API clean while offering powerful defaults. A library might export WithProduction() which applies a set of timeouts, retries, and logging settings. The caller gets a one-liner. The library maintains the configuration logic.
Testing options is straightforward. Create a config, apply the option, and assert the result. This isolates the option logic. You don't need to construct the full client to test WithTimeout. Write a unit test that calls the option on a mock config and checks the field. This keeps tests fast and focused.
Pitfalls and compiler errors
The most common mistake is forgetting the pointer. If you define type Option func(Config), the compiler accepts it. The option function modifies a copy. The constructor keeps the original. The config never changes. You get a silent logic bug. The compiler complains with cannot assign to ... only if you try to modify a field in a way that violates assignment rules, but for structs, the assignment to the copy succeeds. The bug is invisible until runtime.
Another pitfall is order dependency. Options are applied in the order they appear in the variadic list. If you pass WithTimeout(10) then WithTimeout(20), the result is 20. The last option wins. This is a feature. It allows callers to override defaults explicitly. Document this behavior so callers know they can override.
Goroutine leaks are not a risk with options. Options run synchronously in the constructor. The constructor returns only after all options are applied. The resulting object might use goroutines, but the options themselves do not.
Trust the pointer. Check the type definition. If Option takes a value, fix it immediately.
When to use the Options Pattern
Use the Options Pattern when your constructor has more than three parameters and some are optional. Use a simple struct literal when the configuration is small and all fields are required. Use a builder pattern with methods like SetTimeout when you need fluent chaining or complex validation steps between settings. Use environment variables or a config file when the configuration comes from external deployment settings rather than code. Use a single configuration struct passed directly when the API is internal and you control all call sites.
Match the pattern to the complexity. Simple structs for simple configs. Options for flexible, extensible constructors.