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

В небольшом 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/kelseyhightower/envconfig"
)

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

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

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

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

Сборка в 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
}

Обратите внимание: 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.21+. При получении сигнала контекст отменяется, сервер перестаёт принимать новые соединения и ждёт завершения активных запросов. 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() появляется условный оператор, связанный с бизнес-правилом — это сигнал что-то вынести.

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

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

Роутер в 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 через envconfig; адаптеры получают нужные значения через конструктор, а не через 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 — опция для больших проектов, не обязательство.

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

  • Структура модулей (Go) — пакетная раскладка internal/core/, internal/adapter/, bootstrap/ и правила зависимостей.
  • Core-слой (Go) — что разрешено импортировать в core/; как выглядит бизнес-логика без инфраструктуры.
  • Адаптеры In (Go) — chi-обработчик → маппер → use case; middleware аутентификации.
  • Адаптеры Out (Go) — persistence-адаптер на sqlc/pgx; проверка реализации порта на этапе компиляции.