Опирается на правила: R-VLD-CFG-1, R-VLD-CFG-2, R-VLD-CFG-4, R-VLD-CFG-X1, R-VLD-CFG-X2, R-VLD-WHERE-2 из Validation Style Guide → раздел 7. Конфигурация.

Важно знать

  • config.Load() — две фазы: envconfig.Process (чтение переменных окружения → struct) + validate.Struct (проверка ограничений). Ошибка в любой фазе → os.Exit(1).
  • Обязательное поле — required:"true" в envconfig-теге. Пустая переменная окружения при required:"true" даёт ошибку на старте, не в рантайме.
  • Дополнительные ограничения — validate-теги: validate:"url", validate:"hostname_port", validate:"min=1,max=120". Без них envconfig проверяет только тип и presence.
  • Nested-struct: envconfig.Process раскрывает вложенные поля по префиксу (или через envconfig:"," встраивание); validate.Struct по умолчанию не входит в nested — нужен validate.Var или отдельный validate.Struct для каждой вложенной структуры.
  • Optional-поля — без required:"true", c default:"..." или без него. Pointer-тип (*string) сигнализирует «может быть nil».
  • Деньги и целочисленные лимиты — int / int64, не float64; тег validate:"min=1" вместо required для числа, где ноль недопустим.
  • os.Getenv("KEY") для required-конфига — антипаттерн: нет типизации, нет валидации, нет default.

Конфиг — единственное место, где «упасть на старте» лучше, чем «работать с битыми значениями». Неправильный PAYMENT_BASE_URL в DTO даёт 400 одному клиенту; тот же URL в конфиге сервиса ломает все платежи до перезапуска. fail-fast через envconfig + validate.Struct ловит проблему до того, как поступил первый запрос.

Двухфазный Load

R-VLD-CFG-1 / R-VLD-WHERE-2: конфиг валидируется на старте, результат — полностью готовая и проверенная struct.

// config/config.go
package config

import (
    "fmt"
    "time"

    "github.com/go-playground/validator/v10"
    "github.com/kelseyhightower/envconfig"
)

var validate = validator.New()

type Config struct {
    DatabaseURL string        `envconfig:"DATABASE_URL" required:"true"  validate:"url"`
    RedisAddr   string        `envconfig:"REDIS_ADDR"   required:"true"  validate:"hostname_port"`
    HTTPTimeout time.Duration `envconfig:"HTTP_TIMEOUT" default:"5s"`
    Payment     PaymentConfig
}

type PaymentConfig struct {
    BaseURL    string `envconfig:"PAYMENT_BASE_URL" required:"true" validate:"url"`
    APIKey     string `envconfig:"PAYMENT_API_KEY"  required:"true" validate:"min=32"`
    TimeoutSec int    `envconfig:"PAYMENT_TIMEOUT"  default:"10"    validate:"min=1,max=120"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("APP", &cfg); err != nil {
        return nil, fmt.Errorf("config: %w", err)
    }
    if err := validate.Struct(&cfg); err != nil {
        return nil, fmt.Errorf("config validate: %w", err)
    }
    if err := validate.Struct(&cfg.Payment); err != nil {
        return nil, fmt.Errorf("config validate payment: %w", err)
    }
    return &cfg, nil
}

main.go вызывает Load() и при ошибке завершается немедленно:

// cmd/order-service/main.go
func main() {
    cfg, err := config.Load()
    if err != nil {
        slog.Error("недопустимая конфигурация", "error", err)
        os.Exit(1)
    }
    // дальше — wire зависимостей, запуск HTTP-сервера
    run(cfg)
}

Что происходит при отсутствующем APP_DATABASE_URL:

  • envconfig.Process возвращает ошибку: required key APP_DATABASE_URL missing value.
  • slog.Error пишет причину в лог.
  • os.Exit(1) — процесс завершается. Kubernetes/systemd перезапустит и увидит тот же exit code. Healthcheck красный, деплой откатится.

Без валидации конфиг поднимается с пустым DatabaseURL, первый запрос к БД падает с pq: invalid connection string, причина — в пяти строках стека, а не в конфиге.

Обязательные и опциональные поля

R-VLD-CFG-2: обязательное поле — required:"true" без default. Опциональное — без required, можно с default.

// config/sber_client.go
type SberClientConfig struct {
    BaseURL    string        `envconfig:"SBER_BASE_URL"    required:"true" validate:"url"`
    APIKey     string        `envconfig:"SBER_API_KEY"     required:"true" validate:"min=16"`
    Timeout    time.Duration `envconfig:"SBER_TIMEOUT"     default:"10s"`
    MaxRetries int           `envconfig:"SBER_MAX_RETRIES" default:"3"     validate:"min=1,max=10"`
    ProxyURL   string        `envconfig:"SBER_PROXY_URL"`                            // optional
}

Для числового поля, где ноль недопустим, используй validate:"min=1", а не required:

type ProductConfig struct {
    MaxStockQty    int   `envconfig:"MAX_STOCK_QTY"    default:"10000" validate:"min=1"`
    ReorderPoint   int   `envconfig:"REORDER_POINT"    default:"100"   validate:"min=0"`
    PriceFloorCop  int64 `envconfig:"PRICE_FLOOR_COP"  required:"true" validate:"gt=0"`
}

validate:"required" на int в validator/v10 считает 0 невалидным — тип примитива не различает «не задан» и «явный ноль». Для числа без нуля — validate:"min=1" или validate:"gt=0".

Validate-теги для дополнительных ограничений

envconfig проверяет присутствие и умеет парсить типы (time.Duration, bool). Для дополнительных правил — validate-теги:

type CustomerServiceConfig struct {
    GRPCURL        string        `envconfig:"CUSTOMER_GRPC_URL"    required:"true" validate:"hostname_port"`
    TLSCertPath    string        `envconfig:"CUSTOMER_TLS_CERT"    required:"true"`
    PoolSize       int           `envconfig:"CUSTOMER_POOL_SIZE"   default:"10"    validate:"min=1,max=50"`
    CallTimeout    time.Duration `envconfig:"CUSTOMER_TIMEOUT"     default:"5s"`
    RetryCount     int           `envconfig:"CUSTOMER_RETRIES"     default:"2"     validate:"min=0,max=5"`
}

validate:"hostname_port" — строка вида host:port; validate:"url" — полный URL с схемой; validate:"min=32" на string — длина не менее 32 символов (подходит для ключей и токенов).

time.Duration парсится envconfig-ом: 5s, 500ms, 1m30s. Ошибка формата — envconfig.Process вернёт ошибку. Дополнительных validate-тегов для Duration нет — при необходимости напишите кастомный тег (см. go/custom-constraints.md).

Nested-конфиг

R-VLD-CFG-4: вложенные структуры валидируются явно — validate.Struct не входит в nested автоматически (в отличие от Jakarta @Valid).

// config/config.go
type Config struct {
    HTTP     HTTPConfig
    Database DatabaseConfig
    Kafka    KafkaConfig
}

type HTTPConfig struct {
    Port         int           `envconfig:"HTTP_PORT"         default:"8080" validate:"min=1,max=65535"`
    ReadTimeout  time.Duration `envconfig:"HTTP_READ_TIMEOUT"  default:"30s"`
    WriteTimeout time.Duration `envconfig:"HTTP_WRITE_TIMEOUT" default:"30s"`
    IdleTimeout  time.Duration `envconfig:"HTTP_IDLE_TIMEOUT"  default:"120s"`
}

type DatabaseConfig struct {
    URL         string `envconfig:"DATABASE_URL"          required:"true" validate:"url"`
    MaxOpenConn int    `envconfig:"DATABASE_MAX_OPEN_CONN" default:"25"   validate:"min=1,max=200"`
    MaxIdleConn int    `envconfig:"DATABASE_MAX_IDLE_CONN" default:"5"    validate:"min=0,max=200"`
}

type KafkaConfig struct {
    Brokers        string `envconfig:"KAFKA_BROKERS"         required:"true"`
    ConsumerGroup  string `envconfig:"KAFKA_CONSUMER_GROUP"  required:"true" validate:"min=1"`
    SessionTimeout int    `envconfig:"KAFKA_SESSION_TIMEOUT" default:"30"   validate:"min=6,max=300"`
}

func Load() (*Config, error) {
    var cfg Config
    if err := envconfig.Process("APP", &cfg); err != nil {
        return nil, fmt.Errorf("config: %w", err)
    }
    for _, s := range []any{&cfg.HTTP, &cfg.Database, &cfg.Kafka} {
        if err := validate.Struct(s); err != nil {
            return nil, fmt.Errorf("config validate: %w", err)
        }
    }
    return &cfg, nil
}

Цикл по []any позволяет масштабировать: добавление нового nested-конфига требует только добавить его в срез.

Альтернатива — вспомогательная функция:

func mustValidate(v *validator.Validate, sections ...any) error {
    for _, s := range sections {
        if err := v.Struct(s); err != nil {
            return err
        }
    }
    return nil
}

os.Getenv для required-конфига — антипаттерн

R-VLD-CFG-X2: os.Getenv и os.LookupEnv не дают ни типизации, ни валидации, ни default.

// НЕЛЬЗЯ
func newSberClient() *SberClient {
    baseURL := os.Getenv("SBER_BASE_URL")       // пустая строка при отсутствии
    apiKey  := os.Getenv("SBER_API_KEY")
    return &SberClient{baseURL: baseURL, apiKey: apiKey}
}

Что не так:

  • os.Getenv возвращает пустую строку, а не ошибку, если переменная не задана.
  • Нет required — сервис стартует с пустым baseURL.
  • Нет типизации — timeout := os.Getenv("SBER_TIMEOUT") даст строку, а не time.Duration.
  • Конфиг размазан по кодовой базе: каждый пакет читает свои переменные, нет единой точки входа.

Правильно: всё через config.Load(), вызывается один раз в main.go, результат передаётся как зависимость:

// ХОРОШО
type SberClient struct {
    cfg SberClientConfig
}

func NewSberClient(cfg SberClientConfig) *SberClient {
    return &SberClient{cfg: cfg}
}

// main.go
cfg, err := config.Load()
// ...
sberClient := sber.NewSberClient(cfg.Sber)

Конфиг без validate-тегов

R-VLD-CFG-X1: struct без validate-тегов на required-полях полагается только на envconfig — присутствие поля проверяется, содержимое — нет.

// ПЛОХО — нет validate-тегов
type OrderServiceConfig struct {
    DatabaseURL string `envconfig:"DATABASE_URL" required:"true"` // пустая строка пройдёт, если так задана переменная
    HTTPPort    int    `envconfig:"HTTP_PORT"    default:"8080"`   // 0 и 99999 не будут пойманы
}

// ХОРОШО
type OrderServiceConfig struct {
    DatabaseURL string `envconfig:"DATABASE_URL" required:"true" validate:"url"`
    HTTPPort    int    `envconfig:"HTTP_PORT"    default:"8080"  validate:"min=1,max=65535"`
}

required:"true" гарантирует, что переменная присутствует в окружении. Но DATABASE_URL=" " (пробел) пройдёт envconfig. validate:"url" отловит невалидную строку на этапе validate.Struct.

Что запрещено

АнтипаттернПравилоЧто взамен
Конфиг-struct без validate-тегов на ограниченияхR-VLD-CFG-X1validate:"url", validate:"min=1" и другие теги
os.Getenv("KEY") для required-конфигаR-VLD-CFG-X2envconfig.Process + required:"true"
validate.Struct только на корневом конфиге, nested без вызоваR-VLD-CFG-4validate.Struct(&cfg.Payment), validate.Struct(&cfg.Kafka) и т.д.
validate:"required" на int для проверки «не ноль»R-VLD-STD-X1validate:"min=1" или validate:"gt=0"
Деньги в float64 с validate:"gt=0.0"R-VLD-STD-5int64 (копейки) + validate:"gt=0"
Конфиг читается пакетами напрямую через os.GetenvR-VLD-CFG-X2единый config.Load() в main.go, struct передаётся как зависимость

Куда дальше

  • go/where-to-validate.md — где валидировать: HTTP-граница, конфиг, домен.
  • go/standard-constraints.md — какие теги validator/v10 использовать на каких типах.
  • go/custom-constraints.md — кастомные теги: future, ru_phone, vat_number.
  • go/cross-field-validation.md — RegisterStructValidation для правил с несколькими полями.
  • go/messages-and-i18n.md — русские тексты ошибок через Localize(fe).
  • Resilience Style Guide — таймауты в конфиге клиентов (R-RES-*).