The Constructor That Ate Itself
A couple of years ago, I was maintaining an internal Go SDK that wrapped three different payment processors. The constructor for our main client looked something like this:
client := payments.NewClient(
apiKey,
merchantID,
"https://api.processor.com",
30 * time.Second, // request timeout
3, // max retries
true, // enable TLS 1.3
nil, // custom HTTP client (optional)
"", // proxy URL (optional)
false, // sandbox mode
logger, // structured logger
nil, // circuit breaker config (optional)
"", // idempotency key prefix (optional)
)
Twelve parameters. Half of them optional, passed as zero values. Every time we added a new config knob — and in payments, you always need another config knob — we broke every single team that imported the package. The checkout team, the subscriptions team, the refunds service. All of them had to update their constructor calls even if they didn't care about the new parameter.
We tried the config struct approach next. That helped with the breakage, but we lost the ability to distinguish between "the caller explicitly set this to zero" and "the caller didn't set this at all." In payment systems, that distinction matters. A timeout of zero means something very different from "use the default timeout."
The Pattern in Practice
The functional options pattern, popularized by Dave Cheney and Rob Pike, uses closures to configure a struct. Here's how I structure it for a payment client:
type ClientOption func(*clientConfig) error
type clientConfig struct {
baseURL string
timeout time.Duration
maxRetries int
tlsMinVersion uint16
circuitBreaker *CircuitBreakerConfig
httpClient *http.Client
logger Logger
sandbox bool
}
func defaultConfig() *clientConfig {
return &clientConfig{
baseURL: "https://api.processor.com",
timeout: 30 * time.Second,
maxRetries: 3,
tlsMinVersion: tls.VersionTLS12,
logger: NewNopLogger(),
sandbox: false,
}
}
func NewClient(apiKey string, opts ...ClientOption) (*Client, error) {
cfg := defaultConfig()
for _, opt := range opts {
if err := opt(cfg); err != nil {
return nil, fmt.Errorf("invalid client option: %w", err)
}
}
// Build the client from cfg...
return &Client{cfg: cfg, apiKey: apiKey}, nil
}
The key detail: each option function returns an error. A lot of examples online skip this, but in payment SDKs you want to fail loudly at initialization, not at runtime when you're processing someone's credit card.
Validation Inside Options
This is where the pattern really earns its keep. Each option function can validate its own input:
func WithTimeout(d time.Duration) ClientOption {
return func(cfg *clientConfig) error {
if d <= 0 {
return fmt.Errorf("timeout must be positive, got %v", d)
}
if d > 2 * time.Minute {
return fmt.Errorf("timeout %v exceeds maximum of 2m", d)
}
cfg.timeout = d
return nil
}
}
func WithRetries(n int) ClientOption {
return func(cfg *clientConfig) error {
if n < 0 || n > 10 {
return fmt.Errorf("retries must be 0-10, got %d", n)
}
cfg.maxRetries = n
return nil
}
}
func WithCircuitBreaker(threshold int, resetTimeout time.Duration) ClientOption {
return func(cfg *clientConfig) error {
if threshold < 1 {
return fmt.Errorf("circuit breaker threshold must be >= 1")
}
cfg.circuitBreaker = &CircuitBreakerConfig{
Threshold: threshold,
ResetTimeout: resetTimeout,
}
return nil
}
}
When a new engineer on the team writes WithTimeout(-5 * time.Second), they get a clear error at client construction time instead of mysterious behavior in production. I've seen negative timeouts cause goroutine leaks that took hours to track down. Never again.
Composing Presets
Once you have individual options, you can compose them into environment presets. This was a game-changer for our team because different services had different deployment contexts but shared the same SDK:
func WithProductionDefaults() ClientOption {
return func(cfg *clientConfig) error {
opts := []ClientOption{
WithTimeout(30 * time.Second),
WithRetries(3),
WithTLSVersion(tls.VersionTLS13),
WithCircuitBreaker(5, 30*time.Second),
}
for _, opt := range opts {
if err := opt(cfg); err != nil {
return err
}
}
return nil
}
}
func WithSandboxDefaults() ClientOption {
return func(cfg *clientConfig) error {
opts := []ClientOption{
WithBaseURL("https://sandbox.processor.com"),
WithTimeout(60 * time.Second),
WithRetries(1),
WithTLSVersion(tls.VersionTLS12),
}
for _, opt := range opts {
if err := opt(cfg); err != nil {
return err
}
}
cfg.sandbox = true
return nil
}
}
Now a consumer can write NewClient(key, WithProductionDefaults()) and get a sensible, secure configuration in one line. If they need to override one thing — say, a longer timeout for batch operations — they just append it:
client, err := payments.NewClient(apiKey,
payments.WithProductionDefaults(),
payments.WithTimeout(90 * time.Second),
)
The Gotcha: Option Ordering
That last example brings up the one real footgun in this pattern. Options are applied sequentially, left to right. The last write wins. This is usually what you want — presets first, overrides after — but it can bite you if you're not careful.
timeout: 30s
timeout: 30s
timeout: 90s ✓
Options apply left to right. The last write to a field wins.
Consider this ordering mistake:
// Bug: WithProductionDefaults() resets timeout back to 30s
client, err := payments.NewClient(apiKey,
payments.WithTimeout(90 * time.Second),
payments.WithProductionDefaults(), // overwrites the 90s timeout!
)
The fix is simple — always put presets first and overrides after — but it's worth documenting in your SDK's godoc. We added a comment on every preset function: "Apply before individual options to allow overrides."
Tip from production: If option ordering bugs keep showing up in code reviews, consider a two-phase approach: accept a Preset as the first argument and ...ClientOption as the variadic. This makes the ordering explicit in the function signature itself. The tradeoff is a slightly less elegant API, but in a payment SDK, correctness beats elegance every time.
Why This Matters for Payment SDKs Specifically
You might be thinking: this pattern works for any Go library, why call out payments? Fair question. Here's what makes payment SDKs a particularly good fit:
- Configuration surface area is huge. Between TLS requirements, timeout tuning, retry policies, circuit breakers, idempotency settings, and processor-specific quirks, a payment client easily has 15+ configurable knobs. Functional options keep this manageable.
- Backward compatibility is non-negotiable. When your SDK is used by the checkout flow, you cannot ship a breaking change and tell teams to "just update their code." Adding a new option function is always backward compatible.
- Defaults must be safe. The
defaultConfig()function is your chance to set PCI-compliant TLS versions, reasonable timeouts, and sane retry limits out of the box. Teams that don't customize anything still get a secure client. - Testing gets easier. In tests, you can pass
WithBaseURL(mockServer.URL)andWithTimeout(1 * time.Second)without building a whole config struct. It reads cleanly and makes test setup obvious.
A Note on the Error Return
I mentioned earlier that our option functions return error. Some teams prefer the simpler func(*config) signature and panic on bad input. I'd push back on that for payment code. Panics in a payment service can mean dropped transactions and angry customers. Returning an error from NewClient lets the caller handle it gracefully — log it, alert on it, fall back to a default client, whatever makes sense for their context.
The small cost is that NewClient returns (*Client, error) instead of just *Client. In Go, that's idiomatic anyway. Nobody's going to complain about checking an error in a payment system.
References
- Dave Cheney — Functional options for friendly APIs
- Rob Pike — Self-referential functions and the design of options
- Effective Go — Official Go documentation
- Uber Go Style Guide — Functional options section
Disclaimer: This article reflects the author's personal experience and opinions. Product names, logos, and brands are property of their respective owners. Pricing and features mentioned are subject to change — always verify with official documentation.