golang

Resolvable Config Struct — отличная альтернатива Functional Options в Go

  • суббота, 30 мая 2026 г. в 00:00:15
https://habr.com/ru/articles/1040882/

Если честно, то лично я считаю, что это не просто «альтернатива», а предпочтительный выбор по умолчанию для большинства API (где конфигурация — это именно данные).

По большому счёту, паттерн Resolvable Config Struct просто наводит порядок в тех структурах Config и Options, которыми мы все пользуемся на порядок-два чаще, чем Functional Options. Чтобы его внедрить понадобится лишь минимальный рефакторинг, потому что всеми элементами этого паттерна мы и так постоянно пользуемся, просто сейчас там бардак, в который нужно добавить немного строгости чтобы стало намного лучше.

TL;DR: Код говорит лучше тысячи слов, да?

// Enum.
type TLSMode uint8

const (
    TLSDefault TLSMode = iota
    TLSEnabled
    TLSDisabled

    tlsModeCount
)

func (m TLSMode) Valid() bool { return m < tlsModeCount }

// Sentinel.
const NoTimeout time.Duration = -1

type Config struct {
    // --- Core fields (no default semantics) ---
    AppName          string
    Tags             []string
    Headers          map[string]string
    // --- Options (has defaults) ---
    // Zero value is not valid and thus means "use default".
    Host             string        // "" = default
    Port             int           // 0 = default
    // Enum (alternative to *bool which does not enforce Clone requirement).
    TLS              TLSMode       // default/enabled/disabled
    // Pointer (all values are valid including zero).
    WriteBufferBytes *int          // nil = default, new(0) = explicit zero
    // Sentinel (zero is a valid value, but not all values are valid).
    ReadTimeout      time.Duration // 0 = default, NoTimeout = disable
    // Separate flag/enum (alternative to Pointer and Sentinel).
    WriteTimeout     time.Duration // 0 = default
    NoWriteTimeout   bool          // true = disable (ignore WriteTimeout value)
}

// Required only if Config contains references.
func (c Config) Clone() Config {
    c.Tags = slices.Clone(c.Tags)
    c.Headers = maps.Clone(c.Headers)
    if c.WriteBufferBytes != nil {
        c.WriteBufferBytes = new(*c.WriteBufferBytes)
    }
    return c
}

// Required! Idempotent.
func (c Config) Resolve() (Config, error) {
    c = c.Clone()
    // Defaults.
    if c.Host == "" {
        c.Host = "localhost"
    }
    if c.Port == 0 {
        c.Port = 1234
    }
    if c.TLS == TLSDefault {
        c.TLS = TLSEnabled
    }
    if c.WriteBufferBytes == nil {
        c.WriteBufferBytes = new(4096)
    }
    // Normalize.
    c.AppName = strings.TrimSpace(c.AppName)
    // Validate.
    var err error
    if c.AppName == "" {
        err = errors.Join(err, ErrNoAppName)
    }
    if !c.TLS.Valid() {
        err = errors.Join(err, ErrInvalidTLSMode)
    }
    if c.ReadTimeout < NoTimeout {
        err = errors.Join(err, ErrInvalidReadTimeout)
    }
    if c.NoWriteTimeout && c.WriteTimeout != 0 {
        err = errors.Join(err, ErrConflictingWriteTimeout)
    }
    return c, err
}

func NewClient(cfg Config) (*Client, error) {
    cfg, err := cfg.Resolve()
    if err != nil {
        return nil, err
    }
    return &Client{cfg: cfg}, nil
}

Предыстория

В Go есть паттерн, который принято считать почти стандартным ответом на проблему «у конструктора слишком много опций». Это Functional Options.

Обычно история рассказывается так: позиционные параметры перестают масштабироваться, поэтому мы заменяем их на WithX(...), получаем аккуратный вызов, и дальше живём счастливо.

Проблема в том, что в реальном коде Functional Options почти всегда проигрывают обычной структуре конфигурации. Не иногда, не «в некоторых командах», а почти всегда. И именно поэтому в нашем коде структуры Config и Options встречаются намного чаще, чем Functional Options.

Functional Options требуют безумный объём шаблонного кода. У них плохая discoverability — сложно увидеть список всех доступных опций. Их неудобно задавать по условиям. Их неудобно выводить для отладки. Их неудобно хранить как данные. Их неудобно передавать между слоями. Их неудобно читать из файла. Их неудобно показывать пользователю как итоговую конфигурацию. Их… или я уже придираюсь? Ладно, хватит так хватит, но если что - я могу ещё.

Безусловно, когда появились Functional Options они решали вполне реальные проблемы. Но если сравнивать с Resolvable Config Struct у Functional Options останется только один реальный плюс: они позволяют нагляднее задавать сложные опции. Например, WithRetry(backoff, attempts, jitter) выглядит естественно. На структуре это потребует отдельного подтипа, что более громоздко и редко оправдано. Но это не настолько критично и не так часто нужно, чтобы оправдать все остальные издержки.

На практике в Go на порядки чаще побеждают обычные Config-структуры. Просто они обычно реализованы хаотично. Где-то есть значения по умолчанию, где-то нет… Где-то есть проверка, где-то нет… Где-то ссылочное поле копируется, где-то нет… Где-то 0 означает «используй значение по умолчанию», а где-то — «явно выключено». Плюс много других мелких отличий, но, думаю, вы все уже узнали свои проекты.

Именно из этого хаоса и вырастает паттерн, который я называю Resolvable Config Struct.

Это не «ещё одна структура с полями», а способ привести распространённый, уже доминирующий в Go стиль к единому и безопасному виду почти не усложняя реализацию.

Что такое Resolvable Config Struct

Resolvable Config Struct — это паттерн, в котором:

  • параметры с поведением «не задано → взять значение по умолчанию» существуют как поля в обычной структуре Config или Options;

  • для каждого такого поля представление выбирается так, чтобы его zero значение означало «не задано → использовать default»; если zero значение исходного типа нужно как самостоятельное значение, поле переоформляется любым сохраняющим эту семантику способом (напр. используя указатель, sentinel value, enum или вспомогательное поле-флаг);

  • если в конфигурации есть ссылочные поля, она реализует Clone() (deep copy);

  • перед использованием конфигурация проходит через идемпотентный Resolve(), который клонирует, применяет значения по умолчанию, нормализует и валидирует.

То есть это полный жизненный цикл конфигурации: сырой ConfigClone() → значения по умолчанию → нормализация → проверка → готовый Config.

Добавление Resolve() может показаться незначительной деталью, которую можно реализовать массой других способов, но на самом деле это центральная часть паттерна. Именно она превращает структуру из пассивного набора полей в соглашение, с которым комфортно жить.

Если обязательные параметры хочется оставить позиционными - паттерн этому не мешает. Можно сделать NewClient(appName string, cfg Config). Он описывает не все возможные параметры конструктора, а именно необязательную часть конфигурации.

Почему Functional Options почти всегда хуже

Обычно Functional Options защищают ссылкой на «красивый вызов конструктора». Но если смотреть не на одну строку вызова, а на весь срок жизни конфигурации, картина резко меняется.

1. Это огромный объём шаблонного кода

Для каждой опции нужно написать отдельную функцию. Часто — отдельный тип. Иногда — отдельную логику проверки.

type Option func(*Config)

func WithHost(host string) Option {
    return func(c *Config) { c.Host = host }
}

func WithPort(port int) Option {
    return func(c *Config) { c.Port = port }
}

func WithTLS(mode TLSMode) Option {
    return func(c *Config) { c.TLS = mode }
}

На три поля это выглядит терпимо. На десять — уже раздражает. Если валидацию делают сами опции и возвращают error - код раздувается намного сильнее.

Со структурой конфигурации эквивалентный API выглядит так:

type Config struct {
    Host string
    Port int
    TLS  TLSMode
}

2. У них плохая discoverability

Когда я вижу Config, я открываю определение структуры и сразу вижу весь набор параметров, их типы, взаимосвязи, и нередко комментарии про семантику.

Когда я вижу Functional Options, мне нужно искать все WithX по пакету, собирать модель API по нескольким точкам, и отдельно разбираться, какие из этих функций относятся именно к этому конструктору, а какие вообще к другому объекту.

Это особенно неприятно в больших пакетах, где WithTimeout или WithLogger могут существовать в нескольких вариантах.

3. Их не так удобно задавать условно

Очень частый реальный сценарий: часть параметров включается только при некоторых условиях.

Со структурой это выглядит естественно:

cfg := Config{AppName: "billing"}
if debug {
    cfg.TLS = TLSDisabled
}
if writeBuf > 0 {
    cfg.WriteBufferBytes = new(writeBuf)
}
client, err := NewClient(cfg)

С тернарником ещё лучше:

client, err := NewClient(Config{
    AppName:          "billing",
    TLS:              lo.Ternary(debug, TLSDisabled, TLSDefault),
    WriteBufferBytes: lo.Ternary(writeBuf > 0, new(writeBuf), nil),
})

С Functional Options это обычно превращается в накопление среза:

opts := []Option{}
if debug {
    opts = append(opts, WithTLS(TLSDisabled))
}
if writeBuf > 0 {
    opts = append(opts, WithWriteBufferBytes(writeBuf))
}
client, err := NewClient("billing", opts...)

Формально, это тоже работает нормально. Но структура здесь чаще выигрывает тем, что остаётся обычным значением, с которым можно свободно работать до вызова конструктора.

4. Их неудобно отлаживать

Если у вас есть Config, вы можете его напечатать, передать дальше, сохранить, сравнить, или вывести как «итоговую конфигурацию после применения значений по умолчанию».

С Functional Options вы в лучшем случае видите набор функций-замыканий. То есть теряете конфигурацию как данные.

Да, внутри конструктора вы почти наверняка всё равно разворачиваете эти опции в какой-то внутренний Config. Но именно это и делает паттерн сомнительным: он заставляет сначала спрятать данные в поведение, а потом обратно восстановить поведение в данные.

5. Структура лучше работает как компактная форма данных

Одна из сильных сторон структуры в Go — возможность быстро видеть соответствие «имя поля → значение».

client, err := NewClient("billing", Config{
    Host:             "localhost",
    Port:             8443,
    TLS:              TLSEnabled,
    WriteBufferBytes: new(0),
})

Такой код удобно читать, править и проверять в ревью.

С Functional Options запись обычно не короче, но при этом уже не выглядит как одно компактное значение конфигурации:

client, err := NewClient(
    "billing",
    WithHost("localhost"),
    WithPort(8443),
    WithTLS(TLSEnabled),
    WithWriteBufferBytes(0),
)

В чём проблема существующих структур конфигурации

Обычные Config-структуры в Go на пару порядков популярнее Functional Options. Потому, что они проще, прозрачнее, понятнее, и лучше соответствуют природе языка.

Проблема в том, что они везде реализованы по-разному.

Одна команда считает, что 0 значит «возьми значение по умолчанию». Другая — что это валидное явное значение. Где-то ссылочные поля копируются. Где-то — нет. Где-то проверка вызывается из конструктора. Где-то — руками заранее. Где-то есть нормализация. Где-то никто не помнит, когда она должна происходить.

Именно это паттерн Resolvable Config Struct и пытается исправить. Не придумать новый способ писать конструкторы, а стандартизировать самый естественный и распространённый способ.

Ключевая идея: конфигурация — это данные

Если принять, что конфигурация — это прежде всего данные, то из этого почти автоматически следуют остальные правила.

Она должна:

  • быть удобно представима в виде значения;

  • быть пригодной для хранения и передачи;

  • иметь явную модель необязательных полей;

  • иметь явную точку приведения к рабочему состоянию;

  • не делить внутреннее состояние со внешним кодом по ошибке.

Минимальный пример выглядит так:

type Config struct {
    Host string
    Port int
}

func (c Config) Resolve() (Config, error) {
    if c.Host == "" {
        c.Host = "localhost"
    }
    if c.Port == 0 {
        c.Port = 8080
    }
    return c, nil
}

Пока структура состоит только из значимых нулевых значений и простых типов, этого достаточно.

Но в реальном API почти сразу появляются более сложные случаи.

Как кодировать «не задано»

В Go нет встроенного Option[T], поэтому различие между «не задано» и «задано явно» приходится моделировать вручную.

Плюс паттерна в том, что он заставляет решить этот вопрос явно, а не размазывать по десятку WithX.

Обычно достаточно четырёх вариантов.

Обычное поле

Если нулевое значение не нужно как явное, то его можно объявить прямо:

type Config struct {
    Host string // "" = значение по умолчанию
    Port int    // 0 = значение по умолчанию
}

Указатель

Если все значения валидны, включая ноль, а отсутствие значения тоже важно, то подходит указатель:

type Config struct {
    WriteBufferBytes *int // nil = значение по умолчанию, new(0) = явный ноль
}

Sentinel

Если у типа есть заведомо недопустимые значения, одно из них можно занять под специальный режим:

const NoTimeout time.Duration = -1

type Config struct {
    ReadTimeout time.Duration // 0 = значение по умолчанию, NoTimeout = отключить
}

Флаг или enum

Если поле выражает режим, а не просто число, то лучше выделить это в явный тип:

type TLSMode uint8

const (
    TLSDefault TLSMode = iota
    TLSEnabled
    TLSDisabled
)

type Config struct {
    TLS TLSMode
}

Или так:

type Config struct {
    WriteTimeout   time.Duration // 0 = значение по умолчанию
    NoWriteTimeout bool          // true = отключить
}

Clone() — не boilerplate, а штатный паттерн языка…

…хотя и кривой, но тут уж ничего не поделаешь.

Как только в конфигурации появляются ссылочные поля, нужно решить вопрос владения.

Если вызвать конструктор с таким Config:

type Config struct {
    Tags             []string
    Headers          map[string]string
    WriteBufferBytes *int
}

что будет, если вызывающий код потом изменит Tags, Headers или значение по ссылке WriteBufferBytes?

Если конструктор просто сохранит ссылки, внутреннее состояние объекта станет разделяемым снаружи. Для параметров конструктора это почти всегда ошибка.

Поэтому у паттерна здесь жёсткое правило: если структура содержит ссылочные поля, то она реализует Clone() (deep copy).

В актуальном Go это можно сделать компактно:

func (c Config) Clone() Config {
    c.Tags = slices.Clone(c.Tags)
    c.Headers = maps.Clone(c.Headers)
    if c.WriteBufferBytes != nil {
        c.WriteBufferBytes = new(*c.WriteBufferBytes)
    }
    return c
}

Resolve() — сердце паттерна

Без Resolve() структура конфигурации быстро превращается в набор полей, вокруг которых все договорённости держатся на памяти команды.

С Resolve() появляется единая точка, в которой происходит всё приведение к рабочему виду:

  • копирование ссылочных значений;

  • проставление значений по умолчанию;

  • нормализация;

  • валидация.

Почему это стоит принять как стандартный подход

Самое важное достоинство Resolvable Config Struct, на мой взгляд, не в том, что он придумывает новую технику. Его сила в другом: он берёт уже естественный, самый массовый, самый дешёвый для Go стиль и добавляет к нему недостающую строгость.

Не нужно строить дополнительный слой API поверх структуры. Не нужно плодить десятки WithX. Не нужно прятать данные в замыкания. Не нужно выбирать между «простая структура» и «безопасная структура».

Нужно просто договориться, что:

  • необязательные параметры живут в Config;

  • zero-значение для них всегда означает “значение по умолчанию”;

  • ссылочные поля копируются в Clone();

  • рабочее состояние получается через Resolve();

  • весь код ниже по стеку работает уже с результатом Resolve().

Это почти ничего не стоит, но очень сильно уменьшает хаос.

Вывод

У Functional Options была реальная историческая задача.

Роб Пайк показал красивую технику самоссылочных функций для временного изменения состояния с возможностью отката. Это изящный приём, но он вообще-то не про конструкторы как таковые, а про обратимые настройки.

А вот Дейв Чейни уже говорил именно про API конструкторов, и его претензии были вполне справедливы. Длинные сигнатуры неудобны. Зоопарк NewXxxWithYyy не масштабируется. Обязательная передача Config{} или nil ради значений по умолчанию выглядит плохо. 0 в конфигурации по значению часто двусмысленен. *Config создаёт риск разделяемого состояния. Все эти проблемы реальны.

Но Functional Options решают их в основном на поверхности API. Они убирают длинную сигнатуру и позволяют растить конструктор без её изменения, ценой того, что конфигурация перестаёт быть данными и превращается в набор функций.

Resolvable Config Struct предлагает решить те же самые проблемы в более правильном месте — в самой модели конфигурации.

Если не хочется заставлять пользователя писать Config{} ради вызова по умолчанию, то это решается дизайном конструктора (например принимать nil или добавить NewClient() рядом с NewClientWithConfig(cfg Config)), а не обязательным переходом к WithX(...). Если 0 двусмысленен, то это надо чинить явным кодированием состояния «не задано» через zero-значение, используя другие техники для компенсации “съеденного” zero-значения когда оно нужно (через указатель, sentinel, enum или отдельный флаг). Если *Config опасен из-за shared state, то это чинится Clone(). Если значения по умолчанию, нормализация и проверка размазаны по коду, то это чинится Resolve().

В результате мы сохраняем главное обещание хорошего API: конструктор можно расширять без раздувания сигнатуры, а поведение по умолчанию остаётся простым. Но при этом не теряем конфигурацию как данные. Её можно вывести для отладки, сравнить в тесте, передать между слоями, сериализовать, загрузить из файла и показать пользователю как итоговое состояние после Resolve().

Мой поинт не в том, что Functional Options были ошибкой. Скорее это был разумный ответ на плохие конструкторы и наивные Config-структуры образца «просто положим всё в поля и как-нибудь разберёмся». Но сегодня есть решение получше, и его стоит внедрять.

Поэтому, если у вас уже есть Config, осталось сделать совсем немного:

  • явно кодировать «не задано» в zero-значениях;

  • копировать ссылочные поля в Clone();

  • свести Clone, значения по умолчанию, нормализацию и валидацию в Resolve().

После этого вы получаете не просто “очередную структуру с тьмой полей”, а нормальный, цельный, безопасный стандарт для конструкторов.

Именно его я и предлагаю называть Resolvable Config Struct.