Skip to Content
ConventionsDependency injection

Dependency Injection

Как wire’ить зависимости в Go-сервисах: без DI-framework’а, конструкторная инъекция, ручная сборка в main.go. Reference по project-layout — project-layout. Graceful shutdown — это обратная сторона DI, см. shutdown.

Содержание

Принцип

Конструкторная инъекция без framework’а. Каждый компонент (repository, service, handler) — struct с полями-зависимостями, которые задаются через конструктор NewX(dep1, dep2, ...). Всё wire’ится руками в cmd/server/main.go.

Ни wire, ни fx, ни dig. На ~20 зависимостей на сервис ручная сборка читается лучше, чем framework: main.go становится описью архитектуры сервиса, видно, кто от кого зависит, без скрытой магии.

Порядок build’а в main.go

Сборка идёт снизу-вверх: сначала «инфраструктура» (config, clients БД/Redis/Kafka), потом repositories поверх них, потом services (которые знают только про интерфейсы repo), потом handlers, потом router, потом HTTP-сервер и фоновые процессы.

func main() { // 1. Config cfg, err := config.Load() if err != nil { log.Fatalf("load config: %v", err) } // 2. Logger logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: cfg.Log.Level, })) // 3. Ctx + signal ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() // 4. Infra clients pool, err := pgxpool.New(ctx, cfg.DB.DSN()) if err != nil { logger.Error("pgxpool: " + err.Error()) os.Exit(1) } defer pool.Close() rdb := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr, Password: cfg.Redis.Password}) defer rdb.Close() kafkaPub, err := kafka.NewPublisher(kafka.PublisherConfig{ Brokers: cfg.Kafka.Brokers, }, watermillLogger) if err != nil { logger.Error("kafka publisher: " + err.Error()) os.Exit(1) } defer kafkaPub.Close() // 5. Repositories userRepo := postgres.NewUserRepo(pool) sessionRepo := postgres.NewSessionRepo(pool, rdb) // 6. Event publisher (outbox) sqlPub, err := watermillSQL.NewPublisher(stdlib.OpenDBFromPool(pool), watermillSQL.PublisherConfig{ SchemaAdapter: watermillSQL.DefaultPostgreSQLSchema{ GenerateMessagesTableName: func(string) string { return "outbox" }, }, }, watermillLogger) if err != nil { /* ... */ } // 7. Services (только интерфейсы repo + publisher) authSvc := service.NewAuth(userRepo, sessionRepo, sqlPub, cfg.Auth) // 8. Handlers authH := handler.NewAuth(authSvc) // 9. Router r := chi.NewRouter() router.Mount(r, authH) // 10. HTTP server srv := &http.Server{ Addr: cfg.HTTP.Addr, Handler: r, ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, } // 11. Background (Forwarder + Kafka router) forwarder, err := buildForwarder(pool, kafkaPub, watermillLogger) if err != nil { /* ... */ } // 12. Run via errgroup g, gctx := errgroup.WithContext(ctx) g.Go(func() error { logger.InfoContext(gctx, "http listen", "addr", cfg.HTTP.Addr) return srv.ListenAndServe() }) g.Go(func() error { return forwarder.Run(gctx) }) g.Go(func() error { <-gctx.Done() shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() return srv.Shutdown(shutdownCtx) }) if err := g.Wait(); err != nil && !errors.Is(err, http.ErrServerClosed) { logger.Error("run: " + err.Error()) os.Exit(1) } }

Этот скелет — фактически рабочий main.go для небольшого сервиса. Разница между сервисами — в repositories, services и handler’ах, верхнеуровневая структура одна и та же.

Правила

  • Ни одного init() func с логикой. init() используется только для registration (например, prometheus.MustRegister(...) в пакете метрик, если метрики определяются как package-level vars). Никакого чтения env, никакой инициализации state’а в init().
  • Никакого global state вне main.go. Исключения — узкий список: prometheus.DefaultRegisterer (зарегистрирован framework’ом), otel.GlobalTracerProvider() (ставится в main.go один раз). Любой другой var pool *pgxpool.Pool в package-level — антипаттерн.
  • Все constructor’ы принимают dependencies как arguments, не читают env/global. NewUserRepo(pool *pgxpool.Pool) — да. NewUserRepo() с os.Getenv("DB_DSN") внутри — нет.
  • Constructors возвращают (T, error), если setup может упасть. pgxpool.New возвращает ошибку; service.NewAuth — обычно нет, потому что ничего не делает кроме присваивания полей. Правило: если что-то может пойти не так (валидация config’а, открытие соединения) — возвращай error.
  • Interfaces — на стороне consumer’а. service/auth.go объявляет type UserRepo interface { ... }; internal/repository/postgres/ user.go реализует. См. §Interfaces pattern.
  • Handler не знает про БД. Handler вызывает service, service работает с repository. Если handler импортирует pgx — это архитектурная ошибка.

Interfaces pattern

Consumer объявляет интерфейс, producer реализует. Это даёт лёгкую замену в тестах и правильную однонаправленную зависимость пакетов.

// internal/service/auth.go package service type UserRepo interface { GetByEmail(ctx context.Context, email string) (*User, error) Save(ctx context.Context, u *User) error } type SessionRepo interface { Create(ctx context.Context, s *Session) error Revoke(ctx context.Context, userID int64) error } type AuthService struct { users UserRepo sessions SessionRepo hasher PasswordHasher } func NewAuth(users UserRepo, sessions SessionRepo, hasher PasswordHasher) *AuthService { return &AuthService{users: users, sessions: sessions, hasher: hasher} }
// internal/repository/postgres/user.go package postgres type UserRepo struct { pool *pgxpool.Pool } func NewUserRepo(pool *pgxpool.Pool) *UserRepo { return &UserRepo{pool: pool} } func (r *UserRepo) GetByEmail(ctx context.Context, email string) (*service.User, error) { // ... pgx query } func (r *UserRepo) Save(ctx context.Context, u *service.User) error { // ... pgx exec }

В main.go:

userRepo := postgres.NewUserRepo(pool) // *postgres.UserRepo authSvc := service.NewAuth(userRepo, ...) // принимает как service.UserRepo

Go проверит при компиляции, что *postgres.UserRepo удовлетворяет service.UserRepo. Если метод потерялся — ошибка сразу, не в рантайме.

Почему не объявить интерфейс в пакете postgres? Потому что тогда service импортирует postgres, а postgres импортирует service (для типов). Плюс service знает о конкретной реализации, что ломает суть DI. Consumer-side interface — одно из самых важных правил.

Тестирование

Fake repository в тестах подставляется конструктором — не нужен ни mocking-framework, ни reflection-магия:

type fakeUserRepo struct { byEmail map[string]*service.User } func (f *fakeUserRepo) GetByEmail(ctx context.Context, email string) (*service.User, error) { u, ok := f.byEmail[email] if !ok { return nil, service.ErrUserNotFound } return u, nil } func (f *fakeUserRepo) Save(ctx context.Context, u *service.User) error { f.byEmail[u.Email] = u return nil } func TestAuthService_Login(t *testing.T) { repo := &fakeUserRepo{ byEmail: map[string]*service.User{ "a@b.c": {ID: 1, Email: "a@b.c", PasswordHash: validHash}, }, } sessions := &fakeSessionRepo{} svc := service.NewAuth(repo, sessions, argon2Hasher{}) _, err := svc.Login(ctx, "a@b.c", "password") if err != nil { t.Fatalf("login: %v", err) } }

Это ровно тот же API, что в проде. Подмена — одной строкой в конструкторе теста. Никакой генерации кода, никакой refrection, никакой сложности отладки — тест делает именно то, что написано.

Детали тестирования (table-driven, testcontainers) — в testing.

Anti-patterns

Global pool variable

// Плохо package db var Pool *pgxpool.Pool func Init(dsn string) { Pool, _ = pgxpool.New(...) }

Последствия: невозможно поменять pool в тесте (глобальный state «протекает» между тестами, race между параллельными тестами), зависимость неявная (функция UserRepo.GetByEmail не показывает, что она пользуется db.Pool). Всегда Pool — параметр конструктора.

Constructor читает env/global

// Плохо func NewAuthService() *AuthService { secret := os.Getenv("JWT_SECRET") // скрытая зависимость return &AuthService{secret: secret} }

Constructor должен быть детерминирован: одни и те же аргументы → один и тот же результат. Чтение env — в config.Load(), который вызывается только в main.go. Сервисам передаётся готовый cfg.Auth.JWTSecret.

Interface на стороне implementation

// Плохо package postgres type UserRepo interface { // объявлен в реализующем пакете GetByEmail(...) (*User, error) } type userRepo struct { ... } func (r *userRepo) GetByEmail(...) { ... }

Проблемы:

  • Consumer (service) вынужден импортировать postgres, чтобы использовать интерфейс — ломается однонаправленная зависимость.
  • Интерфейс шире, чем нужно consumer’у: он включает методы, которыми consumer не пользуется → переиспользование сложнее.

Consumer объявляет минимальный интерфейс, который ему нужен. Реализация может иметь больше методов — Go не требует perfect-fit.

DI framework для малого количества зависимостей

google/wire, uber-go/fx, uber-go/dig — overkill для 10-20 зависимостей. wire требует code generation + build-step’а; fx/dig полагаются на reflection и «магически» строят граф в рантайме, что усложняет отладку. Ручная сборка в main.go явная и видимая.

Команда использует wire/fx — только по консенсусу всей backend- команды, с тикетом на обсуждение и конкретным количественным обоснованием (количество зависимостей, которое уже не вмещается в читаемый main.go).

God-struct App{...}

// Плохо type App struct { DB *pgxpool.Pool Redis *redis.Client Kafka *kafka.Publisher Logger *slog.Logger AuthSvc *AuthService UserSvc *UserService // ... ещё 30 полей } func NewApp() *App { /* гигантский конструктор */ }

Проблемы:

  • Конструктор разрастается, становится нечитаемым.
  • Любой handler, получающий *App, получает доступ ко всему, что нарушает принцип наименьших знаний.
  • Cycle risk: добавил поле App.WebSocketHub, в конструкторе его инициализация использует AuthSvc, который уже использует App — запутанные init-зависимости.

Правильно: каждый handler/service принимает только те зависимости, которые нужны ему. «App» не существует как типа — существует main.go, который собирает дерево.

Singleton через sync.Once

// Плохо var ( pool *pgxpool.Pool poolOnce sync.Once ) func GetPool() *pgxpool.Pool { poolOnce.Do(func() { pool, _ = pgxpool.New(...) }) return pool }

Та же проблема, что global variable + дополнительно — cannot reset between tests. Всегда конструктор + параметр.

Когда ручная DI не масштабируется

Сигналы:

  • Количество зависимостей > 30. main.go становится 200+ строк, читается тяжело.
  • Много однотипных объектов (10+ handler’ов, каждый с одним и тем же набором middleware) — wire’ить их руками утомительно.
  • Одна и та же сборка повторяется в cmd/server/main.go и в integration-тестах — копипаста расходится со временем.

Варианты смягчения до switch’а на framework:

  1. Вспомогательные build-функции. buildAuthStack(cfg, pool) → *Auth... — закрывает группу связанных объектов. Вызывается из main.go одной строкой.
  2. Shared testsupport пакет для integration-тестов: функция SetupApp(t) поднимает всё нужное, возвращает handle’ы.

Если всё равно тяжело — обсуждение в команде, ADR (внутренний, без ADR-NNN ссылок), и только потом — google/wire (code generation, статический граф). Не fx/dig: reflection-based frameworks усложняют отладку непропорционально пользе.

Graceful shutdown

DI-flow при shutdown идёт в обратном порядке: сначала останавливаем HTTP-сервер (не принимаем новые запросы), дожидаемся завершения текущих handler’ов, останавливаем Forwarder и Kafka-router, закрываем соединения (Redis, Postgres pool, Kafka producer).

Детали — shutdown. Ключевое: если в main.go правильно настроен DI с defer-ами и errgroup, shutdown срабатывает почти автоматически.

Связанные разделы

  • project-layout — раскладка папок, куда класть cmd/server/main.go, internal/service, internal/repository/postgres.
  • shutdown — graceful shutdown через errgroup, SIGTERM, terminationGracePeriodSeconds.
  • testing — тестирование с fake-implementation’ами, без mocking-framework’ов.
  • configurationconfig.Load() как единственная точка чтения env.
  • ../glossary — DI, constructor, consumer-side interface.
Last updated on