Functional options en Go : sortir du constructeur à 9 paramètres

Sprint 1 : NewHTTPClient(host string, timeout time.Duration). Parfait, lisible, rien à redire. Sprint 3 : on ajoute un token d'auth et un user-agent. Sprint 5 : retry, nombre max de connexions, TLS custom. Sprint 8, le constructeur ressemble à ça :

client := NewHTTPClient(
    "https://api.example.com",
    10*time.Second,
    3,
    "Bearer eyJhbGci...",
    "MyApp/2.1",
    100,
    true,
)

Le 4ème paramètre, c'est quoi déjà ? Le token ou le user-agent ? Et le true à la fin, il active quoi ? TLS strict ? Les logs ? Le retry exponentiel ? Impossible à lire sans regarder la signature. Impossible à maintenir sans faire une erreur.

C'est le problème que les functional options résolvent. Ce n'est pas de la magie — c'est une convention idiomatique Go popularisée par Rob Pike, et utilisée partout dans la stdlib et les grands projets open source.

La solution naïve : le config struct

Le premier réflexe, c'est de grouper les paramètres dans une struct :

type HTTPClientConfig struct {
    BaseURL   string
    Timeout   time.Duration
    Retries   int
    AuthToken string
    UserAgent string
    MaxConns  int
    TLSStrict bool
}

func NewHTTPClient(cfg HTTPClientConfig) *HTTPClient {
    // ...
}

C'est mieux pour la lisibilité à l'appel. Mais ça a deux problèmes. D'abord, toutes les valeurs doivent être fournies — impossible d'exprimer "défaut" clairement sans documentation externe. Ensuite, chaque appelant instancie la config entière, y compris les champs qu'il ne veut pas toucher. On retrouve souvent des cfg.Timeout = 0 qui signifient "pas de timeout" ou "j'ai oublié", et impossible de distinguer les deux.

Le pattern functional options

L'idée : au lieu de passer une struct de config, on passe des fonctions qui modifient la struct interne. Chaque option est une fonction autonome, nommée explicitement.

type HTTPClient struct {
    baseURL   string
    timeout   time.Duration
    retries   int
    authToken string
    userAgent string
    maxConns  int
}

// Option est une fonction qui modifie un 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
    }
}

Le constructeur initialise les valeurs par défaut, puis applique les options dans l'ordre :

func NewHTTPClient(baseURL string, opts ...Option) *HTTPClient {
    c := &HTTPClient{
        baseURL:   baseURL,
        timeout:   30 * time.Second, // défaut raisonnable
        retries:   3,
        userAgent: "Go-HTTP-Client/1.0",
        maxConns:  100,
    }
    for _, opt := range opts {
        opt(c)
    }
    return c
}

À l'utilisation, chaque paramètre est explicitement nommé. On ne passe que ce qu'on veut changer :

// Cas minimal — défauts suffisants
client := NewHTTPClient("https://api.example.com")

// Avec overrides explicites
client := NewHTTPClient("https://api.example.com",
    WithTimeout(10*time.Second),
    WithAuth("Bearer eyJhbGci..."),
    WithRetries(5),
)

Plus de devinettes. L'ordre n'a plus d'importance. Ajouter une nouvelle option ne casse aucun code existant — les appelants qui n'en ont pas besoin ne la voient pas.

Valeurs par défaut et validation

Les défauts vivent dans le constructeur, pas dispersés chez les appelants. C'est l'endroit naturel pour les centraliser et les documenter. La validation, elle, se fait après application des options :

func NewHTTPClient(baseURL string, opts ...Option) (*HTTPClient, error) {
    if baseURL == "" {
        return nil, errors.New("baseURL requis")
    }

    c := &HTTPClient{
        baseURL:  baseURL,
        timeout:  30 * time.Second,
        retries:  3,
        maxConns: 100,
    }

    for _, opt := range opts {
        opt(c)
    }

    // Validation post-options
    if c.timeout <= 0 {
        return nil, errors.New("timeout doit être positif")
    }
    if c.maxConns < 1 {
        return nil, errors.New("maxConns doit être >= 1")
    }

    return c, nil
}

L'ordre compte ici : on valide après avoir appliqué les options, pas avant. Sinon une option qui fixe timeout à zéro puis une autre qui le remet à 5 secondes serait rejetée à tort.

Composer les options

Une option n'est qu'une fonction. On peut les composer librement — utile pour des profils préconfigurés :

// Profil pour les appels internes (fast-fail)
func WithInternalDefaults() Option {
    return func(c *HTTPClient) {
        c.timeout   = 2 * time.Second
        c.retries   = 1
        c.maxConns  = 500
    }
}

// Profil pour les API tierces (patient)
func WithExternalDefaults() Option {
    return func(c *HTTPClient) {
        c.timeout  = 15 * time.Second
        c.retries  = 5
        c.maxConns = 20
    }
}

On peut aussi écrire un helper qui combine plusieurs options en une :

func WithOptions(opts ...Option) Option {
    return func(c *HTTPClient) {
        for _, o := range opts {
            o(c)
        }
    }
}

// Profil composé
func WithProductionStripe() Option {
    return WithOptions(
        WithExternalDefaults(),
        WithUserAgent("MyApp/2.1"),
        WithAuth(os.Getenv("STRIPE_TOKEN")),
    )
}

Quand s'en passer

Le pattern est puissant mais pas universel. Trois cas où il vaut mieux faire autrement :

Le constructeur n'a qu'un ou deux paramètres. NewCache(ttl time.Duration) n'a pas besoin de devenir NewCache(WithTTL(5*time.Minute)). C'est de la complexité ajoutée sans bénéfice.

La config doit être sérialisée/désérialisée. Si vous chargez votre config depuis un fichier YAML ou des variables d'environnement, une struct plate avec des tags JSON est plus simple. Les functional options ne se marshallent pas.

Les options sont fortement interdépendantes. Si activer l'option A sans l'option B n'a aucun sens, le modèle de composition libre des options devient un piège. Dans ce cas, des constructeurs dédiés par mode sont plus clairs : NewHTTPClientWithTLS(...), NewHTTPClientWithProxy(...).

Conclusion

Les functional options ne sont pas une astuce de style — elles résolvent un vrai problème d'API design. Un constructeur qui grossit est un signal que les paramètres ont des poids différents : certains sont obligatoires, d'autres ont des défauts raisonnables, d'autres encore sont rarement utiles. Les functional options permettent d'exprimer exactement ça.

Le vrai gain n'est pas la lisibilité à l'appel — c'est que chaque option est une unité testable et nommée. On peut passer WithTimeout(t) dans un test avec t = 100*time.Millisecond sans changer la signature du constructeur. C'est ça, l'avantage durable.

Dans le prochain article de cette série, on verra comment assembler ces options dans un pattern de dependency injection sans framework — où functional options et interfaces Go font équipe.

Commentaires (0)