Опирается на правила:
R-HEX-BOOT-1…R-HEX-BOOT-3иR-HEX-BOOT-X1…R-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.go | R-HEX-BOOT-X1 | Handler — в adapter/in/http/; логика — в core/usecase/ |
Создание chi.Router в adapter/in/http/ и возврат его в main() | R-HEX-BOOT-X2 | adapter/in/http/ экспортирует OrderHandler; роутер строится в bootstrap/buildRouter |
init() в adapter/out/persistence/ создаёт pgxpool.Pool | R-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-X2 | Pool — в конструкторе адаптера; 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 как запрещённый антипаттерн.