---
title: "Итоговый проект: конфигурируемый сервис"
description: "Viper итоговый проект: каркас конфигурируемого сервиса Go — internal/config, yaml-база, env-перекрытие, BindPFlag, валидация, hot-reload и тесты."
course: "Viper: конфигурация приложений на Go"
url: "https://golangmaster.ru/courses/viper/lessons/25-itogovyj-proekt/"
published: "2026-09-19"
updated: "2026-09-27"
minutes: 24
---

# Итоговый проект: конфигурируемый сервис

*Курс «Viper: конфигурация приложений на Go», урок 25 из 25. ~24 минут. Обновлено 27.09.2026. Источник: https://golangmaster.ru/courses/viper/lessons/25-itogovyj-proekt/*

Viper итоговый проект — каркас конфигурируемого сервиса: один пакет internal/config, куда сходится всё, что мы собирали руками по ходу курса. Файл-база, env поверх, флаги поверх env, валидация на старте, горячая перезагрузка на atomic.Value. Нового тут мало — и в этом смысл: к концу у вас останется скелет, который переезжает из сервиса в сервис.

### Viper итоговый проект: каркас конфигурируемого сервиса

Пятый сервис за год — и история повторяется: в понедельник конфигурация это os.Getenv в main, к среде там восемь переменных, yaml с паролем базы, закоммиченный «на время», и флаг, который работает только на дев-машине. К пятнице все мечтают просто скопировать чужой каркас. Итоговый проект курса viper и собирает такой каркас — единожды написанный пакет internal/config, который переезжает из репозитория в репозиторий почти без правок. Принципиально нового здесь не появится: файлы и форматы мы разбирали в начале курса, окружение и префиксы — в уроках про env, лестницу приоритетов раскладывали отдельно, флаги привязывали через BindPFlag, за изменениями следили через WatchConfig. Каждый элемент уже собран руками; проект только соединяет их в один файл, который переживёт прод.

Структура простая до скучности: internal/config с двумя файлами и cmd/app с одним. В config.go живёт Load — чтение базы, подключение env-слоя, привязка флагов. В validate.go — проверки обязательных значений; отдельным файлом, потому что валидация растёт быстрее любой другой части пакета. main.go про viper не знает ничего: получает структуру, вызывает Validate, стартует. Почему именно пакет, а не функции в main: тесты требуют свежих экземпляров, переиспользование между сервисами требует одного места, а диагностике из урока 22 нужен доступ к конкретному экземпляру, а не к глобальному реестру. Обёртку-пакет мы разворачивали в уроке про [пакет config](/courses/viper/lessons/18-paket-config/) — здесь она встаёт на своё место в дереве проекта.

### internal/config/config.go: Load

```go
// internal/config/config.go
package config

import (
    "github.com/spf13/pflag"
    "github.com/spf13/viper"
)

type Config struct {
    Port  int
    Host  string
    DBURL string
    Cache string
}

func Load(flags *pflag.FlagSet) (*Config, error) {
    v := viper.New()               // не глобальный: тесты и два конфига в одном процессе

    v.SetConfigName("config.dev")  // база: config.dev.yaml
    v.SetConfigType("yaml")
    v.AddConfigPath("./configs")

    v.SetEnvPrefix("APP")          // APP_PORT, а не PORT
    v.AutomaticEnv()               // env перекрывает файл
    if err := v.BindPFlags(flags); err != nil { // мост к Cobra: --port
        return nil, err
    }

    if err := v.ReadInConfig(); err != nil {
        return nil, err            // fail fast: нет базы — нет сервиса
    }

    return &Config{
        Port:  v.GetInt("port"),
        Host:  v.GetString("host"),
        DBURL: v.GetString("db.url"),
        Cache: v.GetString("cache.addr"),
    }, nil
}
```

Экземпляр из viper.New() вместо глобального реестра — это не педантизм: без него тесты пакета начнут таскать состояние друг у друга, а второй конфиг в одном процессе станет невозможным. Порядок вызовов до ReadInConfig технически свободен, но держите его одинаковым во всех сервисах — диагностика из урока 22 опирается на предсказуемость. Прогоните каркас локальным Go: go get для viper и pflag, затем go run ./cmd/app --port 9090 — пять минут, после которых пакет перестаёт быть абстракцией.

### Слои каркаса: yaml-база, env поверх, секреты только в env

config.dev.yaml — единственный файл, который каркас читает всегда: безопасные к коммиту значения по умолчанию и ключ, по которому видно, что это база, а не прод. Поверх работает env: SetEnvPrefix("APP") плюс AutomaticEnv — и APP_PORT перекрывает port из файла без единой строки кода в сервисе. Лестница здесь та же, что в уроке про [приоритеты источников](/courses/viper/lessons/09-prioritety-istochnikov/): Set выше всех, затем флаг, переданный пользователем, затем env, затем файл, в самом низу дефолты. Секреты в yaml не попадают никогда: пароль базы и токены приезжают только переменными окружения, как в уроке про секреты — тогда файл с конфигом можно коммитить, а продовый секрет живёт в окружении пода или в vault-агенте, и утечка через репозиторий становится невозможной физически.

> **Каркас без валидации: пустой порт.** Живое место, которое я видел минимум в трёх командах: сервис поднялся, healthcheck зелёный, алерты молчат — а трафик никуда не приходит. Виновник — viper.Get на отсутствующем ключе: порт вернулся нулём, net.Listen принял :0, ОС выдала случайный свободный порт, и балансировщик смотрит в никуда. Снаружи инцидента нет, потому что сервис честно работает — просто не там, где его ищут. Валидация на старте существует против именно этой тишины: обязательные значения проверяются до Listen, и без валидного порта процесс не стартует вовсе. Упасть на деплое дешевле, чем молча жить в проде.

### internal/config/validate.go: fail fast на errors.Join

```go
// internal/config/validate.go
package config

import (
    "errors"
    "fmt"
)

func Validate(c *Config) error {
    var errs []error

    if c.Port <= 0 || c.Port > 65535 {
        errs = append(errs, fmt.Errorf("port: %d не попадает в 1-65535", c.Port))
    }
    if c.Host == "" {
        errs = append(errs, errors.New("host: пусто — слушать нечего"))
    }
    if c.DBURL == "" {
        errs = append(errs, errors.New("db.url: пусто — без базы стартовать бессмысленно"))
    }

    return errors.Join(errs...)
}
```

errors.Join появился в Go 1.20 и собирает срез ошибок в одну цепочку: сервис на старте перечисляет всё, что забыли задать, а не умолкает после первого промаха. Срез вместо panic — потому что решение, как падать, принимает вызывающий: log.Fatal в CLI, os.Exit(1) в контейнере, отдельная ветка в тестах. Ноль проблем — nil, и каркас не навязывает лишнего: добавили поле в Config — добавили одну ветку в Validate.

### Флаги и профили: BindPFlag как мост к Cobra

Флаги входят в каркас через BindPFlags, и это мост к Cobra: команда serve --port 9090 из урока про [флаги и Cobra](/courses/viper/lessons/08-flagi-cobra/) попадает в тот же реестр, что yaml и env, — сервису всё равно, откуда пришло значение. Нюанс, который там же разбирали: перекрывает только флаг, переданный пользователем; непереданный проваливается ниже дефолтов viper. Поэтому порт по умолчанию держат в yaml или SetDefault, а не в flag.Int. Второй мост — профили окружений из урока 16: флаг --env выбирает базу, dev читает config.dev.yaml, prod — config.prod.yaml. В каркасе это одна строка в SetConfigName — fmt.Sprintf("config.%s", env), — а валидация и env-слой работают одинаково для любого профиля.

### Проверка: непереданный флаг

**Вопрос:** В config.dev.yaml задан port: 8080. Флаг объявлен как flag.Int("port", 9000, "порт") и привязан через BindPFlags; пользователь запустил сервис без --port, APP_PORT в окружении нет. Что вернёт GetInt("port")?

- ✅ 8080 — файл стоит выше непереданного флага и выше дефолтов
- 9000 — дефолт флага перекрывает файл
- 0 — флаг не передан, значение потеряно
- ошибку — конфликт двух источников

Перекрывает только флаг, который пользователь реально передал: он встаёт выше env. Непереданный флаг уезжает в самый низ лестницы — ниже дефолтов viper, поэтому 9000 даже не рассматривается. Именно поэтому порт по умолчанию в каркасе держат в yaml или SetDefault, а не в flag.Int.

### Hot-reload: WatchConfig и atomic.Value

Горячая перезагрузка — самая хрупкая деталь каркаса, и мы разбирали её в уроке про WatchConfig: v.WatchConfig() подписывается на изменения файла, v.OnConfigChange(func(e fsnotify.Event)) дёргается на каждое событие. В прод-варианте поверх живёт atomic.Value: обработчик перечитывает реестр, прогоняет результат через ту же Validate и кладёт новый снапшот в атомику; читатели достают его целиком — без мьютексов и без шанса увидеть половину старого с половиной нового. Второе правило — какие значения вообще можно перечитывать: таймауты, лимиты, уровень логов — да; порт и строку подключения к базе — только с рестартом, и никакая атомика этого не изменит. Границу фиксируйте в комментарии к Subscribe, чтобы через полгода никто не удивлялся.

### cmd/app/main.go: снапшот на atomic.Value

```go
// cmd/app/main.go — импорты: fmt, log, net/http, sync/atomic, pflag
func main() {
    flags := pflag.NewFlagSet("app", pflag.ExitOnError) // в Cobra — cmd.Flags()
    flags.Int("port", 0, "порт сервиса")

    cfg, err := config.Load(flags)
    if err != nil {
        log.Fatal(err)                     // файл не прочитан или флаги не привязаны
    }
    if err := config.Validate(cfg); err != nil {
        log.Fatal(err)                     // fail fast: пустой порт не доедет до Listen
    }

    var live atomic.Value                  // хранит *config.Config
    live.Store(cfg)

    // внутри Subscribe: тот же экземпляр viper, WatchConfig + OnConfigChange,
    // перечитанный конфиг проходит Validate; при ошибке держится старый снапшот
    config.Subscribe(func(next *config.Config) {
        live.Store(next)                   // замена снапшота целиком, атомарно
        log.Println("конфиг перечитан")
    })

    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        c := live.Load().(*config.Config)  // всегда целый снапшот
        fmt.Fprintf(w, "port=%d cache=%s", c.Port, c.Cache)
    })

    log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", cfg.Port), mux))
}
```

atomic.Value хранит конфигурацию снапшотом: Store заменяет её целиком, Load отдаёт либо старый объект, либо новый — никогда полусобранный. Писатель один, читателей сколько угодно, мьютексы не нужны. Subscribe живёт в пакете config рядом с Load и замыкает на себя WatchConfig из урока про WatchConfig. Горячую перезагрузку в песочнице не проверить — нужен локальный Go: запустите сервис, поменяйте config.dev.yaml и найдите в логе строку про перечитанный конфиг без рестарта.

> **OnConfigChange сработал на половине файла.** Две ловушки hot-reload, обе из разряда «неделю работало — потом упало». Первая: редакторы часто пишут файл двумя записями, и OnConfigChange дёргается на промежуточной — ReadInConfig возвращает ошибку, и обработчик обязан пережить её молча, оставив старый снапшот, а не складывать мусор в atomic.Value. Вторая: конфиг в обычной структуре без атомики — поля переписываются по одному, и под нагрузкой сервис отвечает старым лимитом с новым портом; поймала нас не продовая авария, а go test -race в CI, который воспроизвёл гонку на ровном месте. Обе закрываются одним решением: собрать новый объект, провалидировать и сделать Store целиком — или промолчать и держать старый.

Пакет config тестируется по схеме из урока про тестирование конфигов: каждый тест берёт свежий viper.New(), а не глобальный реестр — иначе тесты начнут перетаскивать состояние друг у друга, и -count=1 станет рулеткой. Три кейса закрывают каркас целиком: файл прочитан и значения легли в структуру; env перекрывает файл — t.Setenv("APP_PORT", "9091"), и Load обязан вернуть 9091; плохой конфиг проваливает Validate с непустой ошибкой. Чтобы не возиться с tmp-файлами, базу можно скормить через ReadConfig из bytes.Buffer: yaml лежит прямо в тесте строкой, и тест читается как спецификация каркаса.

### Проверка: viper.New() в тестах

**Вопрос:** Почему в каркасе Load() создаёт экземпляр через viper.New(), а не использует глобальный viper.Get?

- viper.New() быстрее глобального реестра
- глобальный реестр не поддерживает yaml-файлы
- ✅ свежий экземпляр изолирует состояние: тесты не перетаскивают env-слой и файл друг у друга
- глобальный реестр нельзя использовать в проде — это правило библиотеки

Глобальный реестр работает и в проде, но состояние течёт: один тест включил AutomaticEnv или задал SetDefault — и соседний тест наследует это молча. Экземпляр из viper.New() живёт внутри Load, ничего наружу не протекает, а тест задаёт только то, что проверяет. Ради этой изоляции каркас и завёрнут в пакет.

### Что вернёт GetInt?

```go
// configs/config.dev.yaml: port: 8080

v := viper.New()
v.SetConfigName("config.dev")
v.SetConfigType("yaml")
v.AddConfigPath("./configs")
v.SetEnvPrefix("APP")
v.AutomaticEnv()
v.SetDefault("port", 9000)
if err := v.ReadInConfig(); err != nil {
    log.Fatal(err)
}
os.Setenv("APP_PORT", "9091")

fmt.Println(v.GetInt("port"))
```

**Что выведет программа?**

- 8080 — файл прочитан последним и победил
- 9000 — дефолт выше всего, что объявлено позже
- ✅ 9091 — env стоит выше файла и дефолта на лестнице
- 0 — env объявлен после ReadInConfig, поэтому не виден

AutomaticEnv проверяет окружение в момент Get, а не в момент подключения, поэтому порядок строк с os.Setenv не важен: APP_PORT найден и стоит на лестнице выше файла и дефолта. Уберите Setenv — вернётся 8080 из файла; уберите и файл — 9000. Лестница одна на все источники.

### Соберите начало Load

Допишите подключение слоёв в Load: префикс, env-слой и мост к флагам.

```go
func Load(flags *pflag.FlagSet) (*Config, error) {
    v := viper.New()

    v.SetConfigName("config.dev")
    v.SetConfigType("yaml")
    v.AddConfigPath("./configs")

    v.___("APP")     // префикс: APP_PORT, а не PORT
    v.___()          // env перекрывает файл
    if err := v.___(flags); err != nil { // мост к Cobra
        return nil, err
    }

    if err := v.ReadInConfig(); err != nil {
        return nil, err
    }
    return &Config{Port: v.GetInt("port")}, nil
}
```

Подсказка: Префикс задаёт SetEnvPrefix, env-слой включает AutomaticEnv, флаги привязывает BindPFlags.

Правильные ответы в пропусках: `SetEnvPrefix`, `AutomaticEnv`, `BindPFlags`

### Порядок вызовов в Load

Соберите последовательность из шести строк: экземпляр, имя файла, префикс, env-слой, привязка флагов, чтение базы.

Правильный порядок строк:

1. `v := viper.New()`
2. `v.SetConfigName("config.dev")`
3. `v.SetEnvPrefix("APP")`
4. `v.AutomaticEnv()`
5. `v.BindPFlags(flags)`
6. `err := v.ReadInConfig()`

### Слой каркаса → файл → урок курса

| Слой каркаса | Где живёт | Урок курса |
| --- | --- | --- |
| yaml-база | configs/config.dev.yaml | файлы и форматы (02, 03) |
| env-перекрытие | APP_PORT и соседи | окружение и префиксы (06, 07) |
| флаги и Cobra | pflag через BindPFlags | флаги и Cobra (08) |
| профили | --env и config.prod.yaml | профили окружений (16) |
| hot-reload | WatchConfig, OnConfigChange | следим за файлом (12) |
| секреты | только env, никогда yaml | секреты (17) |
| валидация | internal/config/validate.go | этот проект |
| тесты пакета | config_test.go на viper.New() | тестирование конфигов (20) |

### Чек-лист запуска сервиса

1. config.dev.yaml на месте и читается: ошибка ReadInConfig останавливает старт, а не уезжает в warn.
2. SetEnvPrefix и AutomaticEnv включены до BindPFlags — лестница приоритетов собирается сама.
3. Флаг --env выбран: dev читает config.dev.yaml, prod — config.prod.yaml, путаницы профилей нет.
4. Секреты только в env: в yaml нет паролей, коммит не содержит креденшелов базы.
5. Validate прогнала обязательные поля: порт в диапазоне, db.url непустая, host задан.
6. Только после валидации — ListenAndServe: сервис физически не может слушать пустой порт.
7. WatchConfig включён, OnConfigChange гонит перечитанный конфиг через Validate перед Store.
8. Тесты пакета config на viper.New() зелёные под go test -race.

### Спор: свой каркас против готового пакета

Тезис, который команды любят оспаривать: готовые пакеты конфигурации чаще вредны, чем полезны, и свой каркас на viper из двух файлов — лучший дефолт. За готовое говорят честные аргументы: envconfig закрывает связку «переменные в структуру» почти без кода, koanf тянет больше форматов и провайдеров, и на старте проекта любое из этого быстрее. Цена проявляется позже: теги структур — магия вне компилятора, свой источник подключить нельзя без чтения чужого кода, а на вопрос «почему значение такое» отвечает grep по чужому репозиторию. Каркас на viper — около сотни строк, каждую из которых мы написали в этом курсе: лестница прозрачна, валидация своя, hot-reload понятен. Граница у спора такая: один маленький сервис, в котором конфиг — только env, живёт спокойно и без viper; второй формат, второй сервис или профили — и каркас окупается за неделю.

На этом каркас собран: Load с yaml-базой, env-слоем и привязкой флагов, Validate с errors.Join, main с atomic.Value и подпиской на изменения, тесты на свежих экземплярах. Дальше он растёт сам: новый источник — один вызов в Load, новое обязательное поле — одна ветка в Validate. Домашка ниже собирает это в рабочий запуск, а маршрут по всем темам, если какой-то элемент хочется освежить, — на [странице курса viper](/courses/viper/).

### Карточки: детали каркаса

- **Fail fast** — Падать на старте, а не молчать в проде: ошибка ReadInConfig и проваленная Validate останавливают сервис до Listen. Пустой порт, подхваченный ОС, дороже упавшего деплоя.
- **BindPFlags** — Привязка FlagSet к viper: переданный флаг стоит выше env, непереданный проваливается ниже дефолтов. Поэтому порт по умолчанию держат в yaml или SetDefault, а не в flag.Int.
- **Профили --env** — Флаг выбирает базу: config.dev.yaml для dev, config.prod.yaml для prod. В каркасе это одна строка в SetConfigName, а валидация и env-слой общие для всех профилей.
- **atomic.Value в hot-reload** — Снапшот конфигурации хранится целиком: Store заменяет его атомарно, Load отдаёт старый или новый, но не полусобранный. Гонок нет и мьютексы не нужны.
- **errors.Join** — Собирает несколько ошибок в одну цепочку: Validate перечисляет все проблемы конфигурации сразу, а не первую попавшуюся. Появился в Go 1.20.
- **viper.New() в тестах** — Свежий экземпляр на каждый тест вместо глобального реестра: состояние не течёт между тестами, env-слой включается точечно через t.Setenv.

## Коротко: что запомнить

- Итоговый проект — сборка: yaml-база, env, флаги, профили, валидация и hot-reload уже построены в уроках, здесь они сходятся в один пакет.
- Структура каркаса: internal/config с config.go и validate.go, cmd/app/main.go, который про viper не знает.
- Лестница приоритетов работает сама: переданный флаг выше env, env выше файла, непереданный флаг ниже дефолтов.
- Секреты только в env: yaml остаётся базой для разработки и коммитится без креденшелов.
- Валидация на старте с errors.Join перечисляет все проблемы сразу и не даёт сервису слушать пустой порт.
- Hot-reload лежит на atomic.Value: снапшот заменяется целиком, читатели не видят полусобранного конфига.
- Пакет config тестируется на viper.New(): свежий экземпляр изолирует состояние лучше глобального реестра.

## Домашнее задание

Соберите каркас в своём сервисе: Load с yaml-базой и env-слоем, Validate с errors.Join, main с atomic.Value и подпиской через Subscribe. Затем сломайте конфигурацию по-крупному: уберите APP_PORT, оставьте в yaml нулевой порт и пустую db.url — и убедитесь, что сервис падает на старте с полным списком ошибок, а не поднимается молча.

## Вопросы и ответы по уроку

### Замена viper в каркасе: когда брать envconfig или koanf?

Когда конфиг — это только env-переменные и одна структура, envconfig короче нашего каркаса, и спорить не о чем. Как только появляются файл-база, профили и hot-reload, объём готового пакета догоняет сотню строк каркаса, а лестница приоритетов перестаёт быть прозрачной. Механика слоёв из проекта переносится на любую библиотеку.

### Какие значения можно перечитывать на лету, а какие нельзя?

Горячая перезагрузка — для безобидных значений: таймауты, лимиты, уровень логов. Порт, адрес базы и креденшелы читаются один раз при старте: смена порта требует переоткрытия слушателя, смена базы — переподключения пула. Разграничение фиксируйте в структуре или в комментарии к Subscribe, чтобы правило пережило автора.

### Как каркас встраивается в Cobra из соседнего курса?

Cobra собирает команду и свой FlagSet, каркас получает его готовым: config.Load(cmd.Flags()) вызывает BindPFlags, и значения флагов попадают в общий реестр. Глобальные --env и --config живут на корневой команде, флаги команды serve — на ней самой. Ничего не дублируется: одна лестница приоритетов на всю программу.
