Перейти к содержанию

Dependency Injection

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

Содержание

Принцип

Конструкторная инъекция без 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 "user.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.md.

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.md. Ключевое: если в main.go правильно настроен DI с defer-ами и errgroup, shutdown срабатывает почти автоматически.

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

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