В небольшом 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; проверка реализации порта на этапе компиляции.