Dependency Injection¶
Как wire'ить зависимости в Go-сервисах: без DI-framework'а, конструкторная
инъекция, ручная сборка в main.go. Reference по project-layout —
project-layout.md. Graceful shutdown — это
обратная сторона DI, см. shutdown.md.
Содержание¶
- Принцип
- Порядок build'а в
main.go - Правила
- Interfaces pattern
- Тестирование
- Anti-patterns
- Когда ручная DI не масштабируется
- Graceful 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 "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¶
Последствия: невозможно поменять 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:
- Вспомогательные build-функции.
buildAuthStack(cfg, pool) → *Auth...— закрывает группу связанных объектов. Вызывается изmain.goодной строкой. - 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.md—config.Load()как единственная точка чтения env.../glossary.md— DI, constructor, consumer-side interface.