Опирается на правила: R-HEX-BOOT-1R-HEX-BOOT-3 и R-HEX-BOOT-X1R-HEX-BOOT-X2 из Hexagonal Rules → раздел 7. Bootstrap / composition root.

Важно знать

  • bootstrap/main.go — единственное место, где импортируются core/, все adapter/in/* и все adapter/out/* одновременно. Никто не зависит от bootstrap/.
  • В Go нет DI-фреймворка по умолчанию — wiring делается вручную через конструкторы (NewXxx). google/wire — опция, не обязательство.
  • init() и глобальные переменные для wiring запрещены: init() в adapter/out/persistence/ создаёт соединение с базой, которое нельзя переопределить в тестах.
  • Конфигурация — envconfig-совместимая структура (Config) в bootstrap/config.go; core и адаптеры не читают os.Getenv напрямую.
  • Graceful shutdown строится через signal.NotifyContext + srv.Shutdown(ctx). os.Exit в core и адаптерах запрещён.
  • slog инициализируется один раз в bootstrap/main.go через slog.SetDefault; в core и адаптерах логируется через slog.Default() или принятый *slog.Logger.
  • Бизнес-логика и chi-handler'ы в bootstrap/ — запрещены. Только wiring, конфигурация, запуск сервера.

bootstrap/ — это «сборочный стол»: он знает все части системы и соединяет их вместе. core/ и адаптеры экспортируют конструкторы; bootstrap/main.go вызывает их в нужном порядке. Всё. Ни одной единицы бизнес-логики, ни одного chi-handler'а — только провод.

Что лежит в bootstrap/

R-HEX-BOOT-1: точный состав.

bootstrap/
  main.go          # composition root: wiring + chi.Router + http.Server + graceful shutdown
  config.go        # Config struct + mustLoadConfig()
  Dockerfile
  docker-compose.yml

Отдельные файлы для крупных блоков конфигурации (observability, middleware) — разумны, но только если это не бизнес-логика. Никаких handler.go, service.go, repository.go здесь не должно быть.

Конфигурация через envconfig

bootstrap/config.go — единственное место, откуда читается окружение:

// bootstrap/config.go
package main

import (
    "fmt"
    "os"
    "time"
)

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
}

adapter/out/sber/ принимает SberURL и SberKey через конструктор — адаптер не читает os.Getenv. Это позволяет подменить конфиг в тестах без monkey-patching окружения.

Wiring в main()

R-HEX-BOOT-1/R-HEX-BOOT-3: вся сборка — в main():

// 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-adapters
    orderRepo := persistence.NewOrderRepository(db)
    productRepo := persistence.NewProductRepository(db)
    sberClient := sber.NewClient(cfg.SberURL, cfg.SberKey)
    paymentAdapter := sber.NewPaymentAdapter(sberClient)

    // use case handlers
    confirmOrder := usecase.NewConfirmOrderHandler(orderRepo, paymentAdapter)
    createOrder := usecase.NewCreateOrderHandler(orderRepo, productRepo)
    cancelOrder := usecase.NewCancelOrderHandler(orderRepo, paymentAdapter)

    // in-adapter: chi router
    r := buildRouter(cfg, confirmOrder, createOrder, cancelOrder)

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

Порядок важен: сначала инфраструктурные зависимости (БД, HTTP-клиенты), потом out-adapters, потом handlers из core/, потом in-adapter (роутер). Graceful shutdown — последним.

chi.Router как отдельная функция

Роутер удобно выносить в отдельную функцию — buildRouter остаётся в bootstrap/, но это не обязательство:

// bootstrap/main.go (продолжение)

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
}

httpadapter.NewOrderHandler принимает конкретные handler-структуры из core/usecase/, не интерфейсы. Если нужна подмена в тестах in-adapter — handler'ы реализуют маленький локальный interface прямо в пакете adapter/in/http/.

Подключение к БД

// bootstrap/main.go (продолжение)

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
}

pgxpool.Pool передаётся в конструктор persistence.NewOrderRepository(db). Адаптер не создаёт пул сам — только bootstrap/ знает URL базы и lifetime пула.

Graceful shutdown

R-HEX-BOOT-1 — lifecycle сервера полностью в bootstrap/:

// bootstrap/main.go (продолжение)

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+. ttl приходит из Config.ShutdownTTL — значение настраивается через переменную окружения SHUTDOWN_TTL, по умолчанию 15 секунд. Этого достаточно для завершения текущих HTTP-запросов и коммита транзакций.

Инициализация slog

slog.SetDefault вызывается один раз в bootstrap/main.go. Адаптеры и core/ используют slog.Default() или получают *slog.Logger через конструктор:

// bootstrap/main.go (продолжение)

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))
}

В production — JSON-формат; в локальной разработке можно переключить на slog.NewTextHandler. Это решение bootstrap/ — не core/.

google/wire как опция

R-HEX-BOOT-2 — если проект растёт и ручной wiring превращается в 100-строчный main(), подключается 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/. bootstrap/wire.go — единственное место, где provider-sets собираются вместе.

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

АнтипаттернПравилоЧто взамен
chi-handler или бизнес-логика в bootstrap/main.goR-HEX-BOOT-X1Handler — в adapter/in/http/; логика — в core/usecase/
Создание chi.Router в adapter/in/http/ и возврат его в main()R-HEX-BOOT-X2adapter/in/http/ экспортирует OrderHandler; роутер строится в bootstrap/buildRouter
init() в adapter/out/persistence/ создаёт pgxpool.PoolR-HEX-BOOT-X1 / антипаттернPool создаётся в bootstrap/mustOpenDB, передаётся через конструктор
os.Getenv("DATABASE_URL") в adapter/out/persistence/R-HEX-BOOT-X1Все env-переменные читаются в bootstrap/config.go через mustLoadConfig()
Глобальный var db *pgxpool.Pool в core/R-HEX-CORE-X2Pool — в конструкторе адаптера; core/ не знает о pgx
os.Exit в core/ или адаптерахантипаттернos.Exit только в bootstrap/main.go; из core возвращается ошибка
Несколько точек входа (main.go в adapter/in/http/)R-HEX-BOOT-X2Один main.go в bootstrap/; адаптеры — библиотечные пакеты

Куда дальше

  • Структура модулей — пакетная раскладка internal/core/, internal/adapter/, bootstrap/ и правила стрелок зависимостей.
  • Core-слой — что разрешено импортировать в core/<bc>/; rich domain на Go.
  • Adapters In — chi-handler → маппер → UseCase.Handle; middleware auth в отдельном пакете.
  • Adapters Out — sqlc/pgx persistence-адаптер; compile-time assertion var _ out.XxxPort = (*XxxAdapter)(nil).
  • Ports — interface в core/<bc>/port/out/; port-ошибки как значения; почему (T, bool) — антипаттерн.
  • Архитектурные тесты — packages.Load + forbidden-imports в CI; почему ручного code-review недостаточно.
  • Когда переходить на Hexagonal — признаки «пора» и «рано»; cargo-cult как запрещённый антипаттерн.