Sprint 1: NewHTTPClient(host string, timeout time.Duration). Clean, readable,
nothing to complain about. Sprint 3: add an auth token and a user-agent. Sprint 5: retry count,
max connections, custom TLS. By sprint 8, the constructor looks like this:
client := NewHTTPClient(
"https://api.example.com",
10*time.Second,
3,
"Bearer eyJhbGci...",
"MyApp/2.1",
100,
true,
)
What's the 4th parameter again — the token or the user-agent? And what does that final
true enable? Strict TLS? Logging? Exponential backoff?
You can't read this without checking the signature. You can't maintain it without
making a mistake.
That's the problem functional options solve. It's not magic — it's an idiomatic Go convention popularized by Rob Pike, used throughout the standard library and major open-source projects.
The naive fix: a config struct
The first instinct is to group parameters into a struct:
type HTTPClientConfig struct {
BaseURL string
Timeout time.Duration
Retries int
AuthToken string
UserAgent string
MaxConns int
TLSStrict bool
}
func NewHTTPClient(cfg HTTPClientConfig) *HTTPClient {
// ...
}
Better at the call site. But it has two problems. First, every caller must supply
all fields — there's no clean way to express "use the default" without external
documentation. Second, you end up with cfg.Timeout = 0 that could mean
"no timeout" or "I forgot", and you can't tell which.
The functional options pattern
The idea: instead of passing a config struct, pass functions that modify the internal struct. Each option is a standalone, explicitly named function.
type HTTPClient struct {
baseURL string
timeout time.Duration
retries int
authToken string
userAgent string
maxConns int
}
// Option is a function that configures an HTTPClient.
type Option func(*HTTPClient)
func WithTimeout(d time.Duration) Option {
return func(c *HTTPClient) {
c.timeout = d
}
}
func WithRetries(n int) Option {
return func(c *HTTPClient) {
c.retries = n
}
}
func WithAuth(token string) Option {
return func(c *HTTPClient) {
c.authToken = token
}
}
func WithUserAgent(ua string) Option {
return func(c *HTTPClient) {
c.userAgent = ua
}
}
func WithMaxConns(n int) Option {
return func(c *HTTPClient) {
c.maxConns = n
}
}
The constructor sets sensible defaults, then applies each option in order:
func NewHTTPClient(baseURL string, opts ...Option) *HTTPClient {
c := &HTTPClient{
baseURL: baseURL,
timeout: 30 * time.Second, // sensible default
retries: 3,
userAgent: "Go-HTTP-Client/1.0",
maxConns: 100,
}
for _, opt := range opts {
opt(c)
}
return c
}
At the call site, every parameter is explicitly named. You only pass what you want to change:
// Minimal — defaults are fine
client := NewHTTPClient("https://api.example.com")
// Explicit overrides
client := NewHTTPClient("https://api.example.com",
WithTimeout(10*time.Second),
WithAuth("Bearer eyJhbGci..."),
WithRetries(5),
)
No more guessing. Order doesn't matter. Adding a new option never breaks existing callers — those who don't need it simply don't use it.
Defaults and validation
Defaults live in the constructor, not scattered across callers. That's the natural place to centralize and document them. Validation happens after options are applied:
func NewHTTPClient(baseURL string, opts ...Option) (*HTTPClient, error) {
if baseURL == "" {
return nil, errors.New("baseURL required")
}
c := &HTTPClient{
baseURL: baseURL,
timeout: 30 * time.Second,
retries: 3,
maxConns: 100,
}
for _, opt := range opts {
opt(c)
}
// Validate after options are applied
if c.timeout <= 0 {
return nil, errors.New("timeout must be positive")
}
if c.maxConns < 1 {
return nil, errors.New("maxConns must be >= 1")
}
return c, nil
}
The order matters: validate after applying options, not before. Otherwise
an option that sets timeout to zero followed by one that sets it to
5 seconds would be wrongly rejected.
Composing options
An option is just a function. You can compose them freely — useful for pre-configured profiles:
// Profile for internal calls (fast-fail)
func WithInternalDefaults() Option {
return func(c *HTTPClient) {
c.timeout = 2 * time.Second
c.retries = 1
c.maxConns = 500
}
}
// Profile for third-party APIs (patient)
func WithExternalDefaults() Option {
return func(c *HTTPClient) {
c.timeout = 15 * time.Second
c.retries = 5
c.maxConns = 20
}
}
You can also write a helper that bundles multiple options into one:
func WithOptions(opts ...Option) Option {
return func(c *HTTPClient) {
for _, o := range opts {
o(c)
}
}
}
// Composed profile for Stripe in production
func WithProductionStripe() Option {
return WithOptions(
WithExternalDefaults(),
WithUserAgent("MyApp/2.1"),
WithAuth(os.Getenv("STRIPE_TOKEN")),
)
}
When to skip it
The pattern is powerful but not universal. Three cases where something else is better:
The constructor has one or two parameters. NewCache(ttl time.Duration)
doesn't need to become NewCache(WithTTL(5*time.Minute)).
That's added complexity with no payoff.
Config needs to be serialized/deserialized. If you load config from YAML files or environment variables, a flat struct with JSON tags is simpler. Functional options don't marshal.
Options are strongly interdependent. If enabling option A without
option B makes no sense, the free-composition model becomes a trap. In that case,
dedicated constructors per mode are clearer:
NewHTTPClientWithTLS(...), NewHTTPClientWithProxy(...).
Conclusion
Functional options aren't a style trick — they solve a real API design problem. A constructor that keeps growing is a signal that its parameters have different weights: some are required, some have sensible defaults, some are rarely needed. Functional options let you express exactly that.
The real gain isn't call-site readability — it's that each option is a testable,
named unit. You can pass WithTimeout(t) in a test with
t = 100*time.Millisecond without changing the constructor's signature.
That's the lasting benefit.
In the next article in this series, we'll see how to combine these options with Go interfaces in a dependency injection pattern without a framework — where functional options and Go interfaces work together.