ФАЗА 26. АРХИТЕКТУРНЫЕ ПАТТЕРНЫ Bootstrap и Composition Root

Граф зависимостей собирается в одном месте

main · Wiring · Graceful shutdown

main читает конфигурацию, создаёт адаптеры, регистрирует use cases и закрывает ресурсы согласованно.

1. Конфигурация читается до графа

1 / 5

Database URL, ports и timeouts валидируются до создания соединений. Секреты приходят из среды, а не из кода.

  • Ошибка конфигурации ранняя.
  • Таймауты имеют значения.
  • Профиль среды явный.
DATABASE_URLrequiredHTTP_PORT8080Timeoutsvalidated

2. Адаптеры создаются реальными

2 / 5

main открывает pool, создаёт PostgresRepository, PaymentClient и HTTP handler. Доменные use cases получают их через параметры.

  • Нет глобального singleton.
  • Реализация выбирается один раз.
  • Тест передаёт fake по той же форме.
mainPostgresRepositoryPayOrderUseCaseHTTP handlerwired dependencies

3. Router получает готовые handlers

3 / 5

chi регистрирует middleware и routes после создания use cases. Граф не собирается повторно на каждый запрос.

  • Middleware порядок явный.
  • Handlers получают зависимости.
  • Старт происходит после проверки.
MiddlewareHandlersRoutesHTTP server

4. Сервер запускается после проверки графа

4 / 5

Обязательные зависимости и начальная миграция проверяются до ListenAndServe. Ошибка инициализации останавливает старт без частичного сервиса.

  • Pool ping выполняется.
  • Миграции идут до трафика.
  • Health не обещает раньше готовности.
ConfigPool pingMigrationsListen

5. Shutdown идёт в обратном порядке

5 / 5

SIGTERM останавливает приём, завершает handlers, workers и экспортеры, затем закрывает pool. Каждый ресурс получает deadline.

  • HTTP Shutdown сначала.
  • Workers завершают текущую работу.
  • Pool.Close идёт последним.
SIGTERMHTTP drainWorkers stopPool close

Bootstrap и Composition Root в гексагональной архитектуре (Go)

← назад к разделу

В небольшом Go-сервисе часто всё живёт в одном main.go: создаём базу, поднимаем роутер, запускаем сервер. Со временем файл разрастается, логика перемешивается с инфраструктурой, а в тестах нет способа подменить реальную базу на заглушку — всё завязано на глобальные переменные и init().

Гексагональная архитектура решает эту проблему через Composition Root — единственное место в программе, где все части собираются вместе. В Go это bootstrap/main.go.

Что такое Composition Root

Представьте, что у вас есть конструктор LEGO. Детали лежат отдельно: блок бизнес-логики, адаптер к базе данных, HTTP-обработчики. Никто из них не знает друг о друге. Composition Root — это инструкция по сборке: она знает все детали и соединяет их в нужном порядке.

В терминах Go:

  • core/ — бизнес-логика, ничего не знает о HTTP или базе данных.
  • adapter/in/http/ — HTTP-обработчики, принимают запросы и вызывают логику.
  • adapter/out/persistence/ — работа с базой данных, реализует порты из core/.
  • bootstrap/main.go — собирает всё вместе. Только он импортирует core/ и все адаптеры одновременно.

Ни один другой пакет не зависит от bootstrap/. Это принципиальное ограничение: адаптеры не знают друг о друге, core/ не знает об адаптерах.

Структура bootstrap/

bootstrap/
  main.go          # сборка всего приложения, запуск сервера
  config.go        # структура конфигурации, чтение из окружения
  Dockerfile
  docker-compose.yml

Здесь не должно быть handler.go, service.go или repository.go. Только сборка, конфигурация и запуск.

Конфигурация из переменных окружения

Вместо того чтобы вызывать os.Getenv("DATABASE_URL") в глубине адаптера, все переменные окружения читаются в одном месте — bootstrap/config.go. Адаптеры получают нужные значения через конструктор.

// bootstrap/config.go
package main

import (
    "fmt"
    "os"
    "time"

    "github.com/caarlos0/env/v11"
)

type Config struct {
    Addr        string        `env:"ADDR"                  envDefault:":8080"`
    DBURL       string        `env:"DATABASE_URL,required"`
    SberURL     string        `env:"SBER_API_URL,required"`
    SberKey     string        `env:"SBER_API_KEY,required"`
    ShutdownTTL time.Duration `env:"SHUTDOWN_TTL"          envDefault:"15s"`
    LogLevel    string        `env:"LOG_LEVEL"             envDefault:"info"`
}

func mustLoadConfig() Config {
    var cfg Config
    if err := env.Parse(&cfg); err != nil {
        fmt.Fprintf(os.Stderr, "config: %v\n", err)
        os.Exit(1)
    }
    return cfg
}

Библиотека caarlos0/env читает переменные по тегам env, проверяет обязательные (,required) и применяет envDefault; у альтернативы kelseyhightower/envconfig теги другие — envconfig:"ADDR" default:":8080", смешивать их нельзя, поле просто останется пустым. Если чего-то не хватает — программа сразу завершается с понятным сообщением, не продолжая работу с неполной конфигурацией.

Важное следствие: в тестах можно передать любую строку подключения напрямую в конструктор адаптера, не подменяя переменные окружения.

Сборка в main()

Порядок важен: сначала инфраструктура (база, HTTP-клиенты), потом адаптеры, потом обработчики, потом роутер.

// bootstrap/main.go
package main

import (
    "context"
    "log/slog"
    "net/http"
    "os"
    "os/signal"
    "syscall"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/jackc/pgx/v5/pgxpool"

    httpadapter "example.com/order-service/internal/adapter/in/http"
    "example.com/order-service/internal/adapter/out/persistence"
    "example.com/order-service/internal/adapter/out/sber"
    "example.com/order-service/internal/core/order/usecase"
)

func main() {
    cfg := mustLoadConfig()
    initLogger(cfg.LogLevel)

    db := mustOpenDB(cfg.DBURL)
    defer db.Close()

    // out-адаптеры: работа с базой и внешними системами
    orderRepo := persistence.NewOrderRepository(db)
    productRepo := persistence.NewProductRepository(db)
    sberClient := sber.NewClient(cfg.SberURL, cfg.SberKey)
    paymentAdapter := sber.NewPaymentAdapter(sberClient)

    // обработчики use case из core/
    confirmOrder := usecase.NewConfirmOrderHandler(orderRepo, paymentAdapter)
    createOrder := usecase.NewCreateOrderHandler(orderRepo, productRepo)
    cancelOrder := usecase.NewCancelOrderHandler(orderRepo, paymentAdapter)

    // in-адаптер: HTTP-роутер
    r := buildRouter(cfg, confirmOrder, createOrder, cancelOrder)

    srv := &http.Server{
        Addr:    cfg.Addr,
        Handler: r,
    }
    runWithGracefulShutdown(srv, cfg.ShutdownTTL)
}

Каждый конструктор (NewOrderRepository, NewConfirmOrderHandler и т.д.) принимает только то, что ему нужно. Никаких глобальных переменных, никаких init(). Если зависимость не передана — код не скомпилируется.

Роутер в отдельной функции

Роутер строится в bootstrap/, но в отдельной функции — это упрощает чтение main():

func buildRouter(
    cfg Config,
    confirmOrder *usecase.ConfirmOrderHandler,
    createOrder *usecase.CreateOrderHandler,
    cancelOrder *usecase.CancelOrderHandler,
) http.Handler {
    r := chi.NewRouter()

    r.Use(middleware.Recoverer)
    r.Use(middleware.RequestID)
    r.Use(slogMiddleware())

    orderH := httpadapter.NewOrderHandler(confirmOrder, createOrder, cancelOrder)
    r.Route("/orders", func(r chi.Router) {
        r.Post("/", orderH.CreateOrder)
        r.Post("/{id}/confirm", orderH.ConfirmOrder)
        r.Post("/{id}/cancel", orderH.CancelOrder)
    })

    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    return r
}

Одной ручки /health хватает для локального запуска; в Kubernetes её разводят на /health/live и /health/ready с флагом готовности, который выключают первым делом на SIGTERM — как это устроено, разобрано в статье про конфигурацию сервера при graceful shutdown.

Обратите внимание: adapter/in/http/ не создаёт роутер сам и не возвращает его наружу. Пакет экспортирует OrderHandler, а роутер строит bootstrap/. Это позволяет подключить несколько обработчиков к одному роутеру без взаимной зависимости между адаптерами.

Подключение к базе данных

func mustOpenDB(url string) *pgxpool.Pool {
    ctx := context.Background()
    pool, err := pgxpool.New(ctx, url)
    if err != nil {
        slog.Error("db connect", "err", err)
        os.Exit(1)
    }
    if err := pool.Ping(ctx); err != nil {
        slog.Error("db ping", "err", err)
        os.Exit(1)
    }
    slog.Info("db connected")
    return pool
}

Пул создаётся в bootstrap/ и передаётся в конструктор адаптера. Адаптер работает с пулом как с зависимостью — не создаёт его сам и не хранит URL базы.

Корректное завершение

Без graceful shutdown сервер при получении SIGTERM мгновенно обрывает все активные соединения. Пользователи получают ошибки посередине запроса, незакрытые транзакции могут оставить данные в неконсистентном состоянии.

func runWithGracefulShutdown(srv *http.Server, ttl time.Duration) {
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    go func() {
        slog.Info("server started", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            slog.Error("server error", "err", err)
            os.Exit(1)
        }
    }()

    <-ctx.Done()
    stop()
    slog.Info("shutdown signal received")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), ttl)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        slog.Error("graceful shutdown failed", "err", err)
        os.Exit(1)
    }
    slog.Info("server stopped")
}

signal.NotifyContext (в стандартной библиотеке с Go 1.16) — идиоматичный способ. При получении сигнала контекст отменяется, сервер перестаёт принимать новые соединения и ждёт завершения активных запросов. ShutdownTTL (по умолчанию 15 секунд) задаёт максимальное время ожидания.

Весь lifecycle сервера — запуск, ожидание сигнала, корректное завершение — живёт в bootstrap/. Адаптеры и core/ не вызывают os.Exit.

Логирование

slog.SetDefault вызывается один раз в bootstrap/main.go. Адаптеры используют slog.Default() или получают *slog.Logger через конструктор. core/ вообще не импортирует slog — логирование относится к инфраструктуре, а не к бизнес-логике.

func initLogger(level string) {
    var lvl slog.Level
    if err := lvl.UnmarshalText([]byte(level)); err != nil {
        lvl = slog.LevelInfo
    }
    h := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})
    slog.SetDefault(slog.New(h))
}

В продакшне — JSON-формат для систем сбора логов. При локальной разработке можно переключить на slog.NewTextHandler — это решение bootstrap/, остальные пакеты об этом ничего не знают.

Когда подключать google/wire

Ручная сборка в main() прекрасно работает для большинства сервисов. Если проект растёт и main() превращается в 100-строчный список конструкторов, можно подключить google/wire. Провайдеры объявляются в пакетах адаптеров, а wire-set собирается в bootstrap/:

// bootstrap/wire.go
//go:build wireinject

package main

import (
    "github.com/google/wire"
    "example.com/order-service/internal/adapter/out/persistence"
    "example.com/order-service/internal/adapter/out/sber"
    "example.com/order-service/internal/core/order/usecase"
)

func initApp(cfg Config) (*App, error) {
    wire.Build(
        persistence.ProviderSet,
        sber.ProviderSet,
        usecase.ProviderSet,
        newApp,
    )
    return nil, nil
}

persistence.ProviderSet содержит NewOrderRepository — без знания о bootstrap/. Единственное место, где все provider-set'ы собираются вместе — bootstrap/wire.go.

Wire — это опция, не требование. Начинайте с ручного wiring, переходите к wire когда сборка становится неудобной.

Частые ошибки

Бизнес-логика в bootstrap/. bootstrap/main.go — только сборка. Любая логика (валидация, расчёты, правила) должна быть в core/. Если в main() появляется условный оператор, связанный с бизнес-правилом — это сигнал что-то вынести.

// так делать не нужно: правило домена в точке сборки
func main() {
    ...
    r.Post("/orders", func(w http.ResponseWriter, req *http.Request) {
        var body CreateOrderRequest
        _ = json.NewDecoder(req.Body).Decode(&body)
        if body.Total > 100_000 { // бизнес-правило в bootstrap
            http.Error(w, "order too large", http.StatusBadRequest)
            return
        }
        ...
    })
}

init() в адаптерах. init() выполняется автоматически при загрузке пакета и его нельзя переопределить. Адаптер с init(), который открывает соединение с базой, невозможно нормально протестировать. Соединения создаются в bootstrap/ и передаются через конструктор.

os.Getenv внутри адаптера. Переменная окружения, прочитанная глубоко в адаптере, невидима снаружи. Тест вынужден подменять переменную окружения, что хрупко. Все env-переменные читаются в bootstrap/config.go.

// так делать не нужно: адаптер сам лезет в окружение
func NewPaymentAdapter() *PaymentAdapter {
    return &PaymentAdapter{baseURL: os.Getenv("SBER_URL"), timeout: 5 * time.Second}
}

// правильно: настройки приходят из bootstrap/config.go
func NewPaymentAdapter(cfg sber.Config) *PaymentAdapter {
    return &PaymentAdapter{baseURL: cfg.BaseURL, timeout: cfg.CallTimeout}
}

Роутер в adapter/in/http/. Если адаптер сам создаёт и возвращает chi.Router — он берёт на себя слишком много. Пакет регистрирует обработчики через структуру (OrderHandler), а маршруты подключает bootstrap/buildRouter.

os.Exit вне bootstrap/. Если core/ или адаптер вызывает os.Exit, то defer-выражения в main() не выполнятся — соединения не закроются корректно. Из core/ возвращается ошибка, из адаптера — тоже; только bootstrap/ решает что с ней делать.

Коротко

  • bootstrap/main.go — единственное место, где импортируются core/ и все адаптеры одновременно. Никакой бизнес-логики, только сборка.
  • Конфигурация читается в bootstrap/config.go через caarlos0/env; адаптеры получают нужные значения через конструктор, а не через os.Getenv.
  • Порядок сборки: инфраструктура → out-адаптеры → обработчики use case → in-адаптер (роутер).
  • Роутер строится в bootstrap/buildRouter; adapter/in/http/ экспортирует только структуру обработчика.
  • Graceful shutdown через signal.NotifyContext + srv.Shutdown(ctx) — весь lifecycle сервера в bootstrap/.
  • slog инициализируется один раз в bootstrap/; core/ не импортирует логгер.
  • init() и глобальные переменные для wiring запрещены — они делают код нетестируемым.
  • os.Exit только в bootstrap/; из core и адаптеров возвращается ошибка.
  • google/wire — опция для больших проектов, не обязательство.

Что почитать дальше