April 19, 2026 9 min read

Go's Functional Options Pattern for Payment SDK Design

Why the functional options pattern is the best tool I've found for building internal payment SDKs that stay flexible without breaking every consumer on each release.

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."

Before: Giant Constructor
NewClient(apiKey,
  merchantID, baseURL,
  timeout, retries,
  tls, httpClient,
  proxy, sandbox,
  logger, breaker,
  idempPrefix)
12 params · breaks on every change · nil/zero ambiguity
After: Functional Options
NewClient(apiKey,
  WithMerchant(id),
  WithTimeout(30*s),
  WithRetries(3),
  WithTLS(true),
Only set what you need · backward compatible · self-documenting

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.

defaultConfig()
timeout: 30s
WithProdDefaults()
timeout: 30s
WithTimeout(90s)
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:

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

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.