1. DI руками и точка сборки
DI (Dependency Injection) звучит так, будто сейчас мы будем поднимать «enterprise‑архитектуру» и звать шамана с контейнером зависимостей. На деле, в Go всё гораздо приземлённее: DI «руками» — это когда вы создаёте зависимости снаружи и передаёте их явно в то, что будет ими пользоваться. Не «внутри функции завёл себе базу данных», а «в main создал реализацию и отдал в сервис».
Самая важная мысль: DI — это не про красоту диаграмм, а про контроль связей. Когда зависимости создаются внутри бизнес‑логики, код становится сложно тестировать, сложно расширять и легко случайно «прибить гвоздями» к конкретной реализации. Когда зависимости передаются извне — становится понятно, кто за что отвечает, и компилятор помогает удерживать архитектуру, а не бороться с ней.
Зависимость в Go — это не только «база данных»
Новички часто думают, что dependency — это что-то большое и страшное: база, сеть, очереди. Но в Go зависимостью является любая вещь, которую ваш код “использует”, но не должен “создавать” внутри себя. Это может быть хранилище задач, генератор ID, writer для вывода, источник времени, конфиг и так далее. Чем честнее вы это признаёте — тем проще становится код.
В нашем учебном приложении (мини‑таск‑трекер) мы будем считать зависимостями как минимум хранилище задач и место, куда писать вывод. И сразу полезная привычка: если функция принимает context.Context, то все зависимости, которые делают работу (I/O, хранилище), тоже должны принимать ctx, чтобы отмена/таймаут операции мог пройти по цепочке.
Небольшая «карта» для ориентации:
| Что это | Пример | Почему это зависимость |
|---|---|---|
| Хранилище | |
Можно заменить in‑memory на файл/БД, не меняя сценарий |
| Вывод | |
Можно писать в stdout, buffer, файл — без переписывания логики |
| Контекст операции | |
Прокидывает отмену/дедлайны вниз по стеку вызовов |
Почему создавать зависимости внутри логики — ловушка
Давайте начнём с анти‑примера. Он жизненный: вы пишете функцию, вам нужно хранилище, вы такие «ну сейчас быстро сделаю».
package app
import "example.com/myapp/adapters/memstore"
func AddTaskBad(title string) {
store := memstore.New() // ← зависимость создаётся внутри
_ = store // дальше какая-то логика...
}
Проблема тут не в том, что memstore плохой. Проблема в том, что теперь ваша бизнес‑логика “знает” конкретную реализацию и фактически говорит: «я могу работать только с memstore». Хотите завтра хранить задачи в файле? Придётся менять app‑слой. Хотите протестировать? Придётся жить с тем, что внутри каждый раз создаётся новое хранилище. Хотите переиспользовать? Становится неудобно.
DI «руками» делает наоборот: app‑слой формулирует контракт (интерфейс), а реализация создаётся снаружи, обычно в main.
Точка сборки: одно место, где всё связывается
Точка сборки (composition root) — это место, где приложение «собирают из деталей»: создают конкретные реализации, прокидывают их в конструкторы, получают готовый объект верхнего уровня и запускают сценарии. В маленьких программах это почти всегда main.
Звучит скучно, но это прямо облегчает жизнь: вы не размазываете создание зависимостей по десяти файлам. Если потом что-то ломается, вы знаете, где искать: в одном месте.
Вот схема, как мы хотим думать о нашем приложении:
flowchart TD
main[main: точка сборки] --> mem[adapters/memstore: конкретная реализация]
main --> app[app: сценарии/сервис]
app --> domain[domain: модель и правила]
app -->|через интерфейс| mem
Ключ: app зависит от интерфейса, а main подсовывает реализацию.
2. Минимальный домен
Сейчас мы не будем усложнять домен. Нам важно, чтобы домен был «чистым»: структура данных и правила. Допустим, правило такое: заголовок задачи не должен быть пустым. Это уже знакомая логика ошибок: функция либо возвращает результат, либо error. В Go это стандартный стиль, и он очень хорошо сочетается с DI, потому что всё становится явно и проверяемо.
// domain/task.go
package domain
import "errors"
var ErrEmptyTitle = errors.New("empty title")
type Task struct {
ID int
Title string
Done bool
}
И маленький конструктор:
// domain/new_task.go
package domain
func NewTask(id int, title string) (Task, error) {
if title == "" {
return Task{}, ErrEmptyTitle
}
return Task{ID: id, Title: title, Done: false}, nil
}
Пока всё просто: домен не знает ни про fmt.Println, ни про файлы, ни про CLI. Он просто держит правило.
3. Контракт хранилища в app
Теперь перейдём к DI‑сердцевине. Мы хотим, чтобы сценарий “добавить задачу” умел работать с любым хранилищем, если оно поддерживает нужные операции. Поэтому интерфейс объявляет потребитель (app‑слой), а не реализация.
// app/store.go
package app
import (
"context"
"example.com/myapp/domain"
)
type TaskStore interface {
NextID(ctx context.Context) (int, error)
Save(ctx context.Context, t domain.Task) error
}
Обратите внимание: app импортирует domain, потому что использует domain.Task. Это нормально: сценарии знают про домен. Но app не импортирует memstore — и вот это уже принципиально.
4. Конструктор приложения NewApp
Самый узнаваемый DI‑приём в Go — конструкторы вида NewX(...). Не потому что язык требует, а потому что это помогает создавать объект сразу в корректном состоянии. Мы сделаем App — объект верхнего уровня app‑слоя.
Есть два частых стиля:
- NewApp(store, out, ...) параметрами
- NewApp(Deps{...}) через структуру deps
В учебных проектах и в реальных сервисах второй вариант часто удобнее, потому что зависимости не превращаются в «колбасу аргументов», когда их становится много.
Начнём с Deps.
// app/app.go
package app
import "io"
type Deps struct {
Store TaskStore
Out io.Writer
}
type App struct {
store TaskStore
out io.Writer
}
Теперь конструктор:
// app/new.go
package app
import (
"errors"
"io"
)
func NewApp(deps Deps) (*App, error) {
if deps.Store == nil {
return nil, errors.New("nil store")
}
if deps.Out == nil {
deps.Out = io.Discard // безопасный дефолт
}
return &App{store: deps.Store, out: deps.Out}, nil
}
Здесь DI выглядит максимально “по‑гошному”: без магии, без рефлексии, без контейнеров. Просто параметры, проверки и возвращаемое значение.
5. Сценарии в App
Теперь добавим метод AddTask. Он принимает context.Context, потому что операции хранилища потенциально могут быть долгими, и потому что это стандартная форма контракта в Go‑коде для операций.
// app/add_task.go
package app
import (
"context"
"fmt"
"example.com/myapp/domain"
)
func (a *App) AddTask(ctx context.Context, title string) (domain.Task, error) {
id, err := a.store.NextID(ctx)
if err != nil {
return domain.Task{}, fmt.Errorf("next id: %w", err)
}
return domain.NewTask(id, title)
}
Пока мы не сохраняем задачу — давайте добавим сохранение, но коротко:
// app/add_task_save.go
package app
import (
"context"
"fmt"
"example.com/myapp/domain"
)
func (a *App) AddAndSave(ctx context.Context, title string) (domain.Task, error) {
t, err := a.AddTask(ctx, title)
if err != nil {
return domain.Task{}, err
}
if err := a.store.Save(ctx, t); err != nil {
return domain.Task{}, fmt.Errorf("save: %w", err)
}
return t, nil
}
Заметьте характерную вещь: мы не создаём memstore.New() внутри. Мы просто используем a.store. Это и есть DI.
6. Адаптер memstore
Теперь напишем простейшее in‑memory хранилище. Оно будет жить в adapters/memstore. Важно: адаптер импортирует domain (ему нужно хранить domain.Task), но не импортирует app. Он просто “подходит” под интерфейс по методам.
// adapters/memstore/store.go
package memstore
import (
"context"
"example.com/myapp/domain"
)
type Store struct {
next int
data map[int]domain.Task
}
Конструктор:
// adapters/memstore/new.go
package memstore
func New() *Store {
return &Store{next: 1, data: make(map[int]domain.Task)}
}
Методы:
// adapters/memstore/methods.go
package memstore
import "context"
func (s *Store) NextID(ctx context.Context) (int, error) {
id := s.next
s.next++
return id, nil
}
// adapters/memstore/save.go
package memstore
import (
"context"
"example.com/myapp/domain"
)
func (s *Store) Save(ctx context.Context, t domain.Task) error {
s.data[t.ID] = t
return nil
}
Да, тут ctx пока не используется. Это нормально. Интерфейс требует — реализация принимает. Позже появятся реализации, которым ctx действительно важен (например, файловое или сетевое хранилище), и вы не будете переписывать весь контракт.
7. Сборка в main
Теперь самое вкусное: main — это наша точка сборки. Здесь мы создаём memstore.Store, выбираем out (пусть будет stdout), вызываем NewApp, получаем готовое приложение.
// main.go
package main
import (
"context"
"fmt"
"os"
"example.com/myapp/adapters/memstore"
"example.com/myapp/app"
)
func main() {
store := memstore.New()
a, err := app.NewApp(app.Deps{Store: store, Out: os.Stdout})
if err != nil {
fmt.Println("init error:", err) // init error: nil store (если забыли Store)
return
}
t, err := a.AddAndSave(context.Background(), "learn DI in Go")
if err != nil {
fmt.Println("runtime error:", err)
return
}
fmt.Fprintln(os.Stdout, "added task:", t.Title) // added task: learn DI in Go
}
Это выглядит почти смешно просто — и в этом сила Go. DI — это не отдельная технология, а дисциплина: «создавай снаружи, передавай внутрь».
8. Полезные нюансы DI
io.Writer как зависимость
Даже если вы пока не пишете тесты, io.Writer как зависимость — это супер‑приём для начинающих: вы перестаёте «прибивать» печать к fmt.Println прямо внутри бизнес‑логики. Вместо этого бизнес‑логика пишет туда, куда ей сказали.
Добавим простой метод “сказать пользователю, что задача добавлена”. Это не домен, это уже UX‑деталь, поэтому логичнее держать это на уровне App или внешнего слоя. Но для учебного примера пусть App умеет печатать в a.out.
// app/print.go
package app
import (
"fmt"
)
func (a *App) PrintAdded(title string) {
fmt.Fprintln(a.out, "ok:", title) // ok: learn DI in Go
}
И используем в main:
// main.go (фрагмент)
t, _ := a.AddAndSave(context.Background(), "learn DI in Go")
a.PrintAdded(t.Title) // ok: learn DI in Go
Если завтра вы захотите писать в файл — main поменяется, а app останется тем же. Это и есть «слабая связанность» без громких слов.
Почему NewApp иногда возвращает (*App, error)
Новичку легко впасть в ступор: «Почему тут error, а тут нет?» Ответ прагматичный: конструктор возвращает error, если он реально может обнаружить неправильную конфигурацию и это важно остановить сразу.
Например, Store == nil — это почти гарантированный будущий panic (или странное поведение), поэтому лучше упасть раньше, в точке сборки. А вот Out == nil можно заменить на io.Discard и продолжить.
Если конструктор не делает проверок и всегда создаёт корректное значение, можно возвращать просто *App. Но как только появляются «обязательные зависимости» — error становится полезным, потому что вы ловите проблему до запуска сценариев.
Три микро‑правила DI руками
DI легко довести до абсурда: начать передавать 20 зависимостей во все функции и гордо назвать это архитектурой. Но нам нужен здоровый минимум. Договоримся о простых правилах поведения, без фанатизма.
- Во-первых, зависимости создаёт точка сборки. Это обычно main, иногда отдельный пакет сборки, но в учебном проекте — main.
- Во-вторых, app‑слой не должен импортировать адаптеры: никакого import adapters/memstore внутри app.
- В-третьих, интерфейс живёт там, где его используют. Если app использует хранилище — интерфейс TaskStore лежит в app, а реализация просто подходит под него.
Если держать эти три правила в голове, DI становится не «фреймворком», а просто стилем написания кода: спокойным и предсказуемым.
9. Типичные ошибки при DI руками
Ошибка №1: “Сделаю глобальную переменную, и это будет DI”.
Глобальные переменные выглядят как удобный короткий путь, но на практике это скрытая зависимость: непонятно, кто её устанавливает, когда, и можно ли менять. В итоге вы получаете код, который зависит от порядка вызовов и «магии инициализации», а не от явных параметров.
Ошибка №2: Создавать реализацию зависимости внутри сценария.
Самая частая проблема: внутри AddTask внезапно появляется memstore.New() или os.OpenFile(...). Это ломает идею слоёв: сценарий начинает знать детали инфраструктуры. Правильнее, когда сценарий знает только интерфейс, а создание реализации остаётся в main.
Ошибка №3: Делать интерфейс “на всякий случай огромным”.
Новички иногда пишут интерфейс из 15 методов, потому что «ну вдруг понадобится». В результате реализацию тяжело подменять, и вы сами же усложняете себе жизнь. В Go интерфейсы принято делать маленькими: ровно столько методов, сколько нужно текущему потребителю.
Ошибка №4: Не проверять зависимости в NewApp и ловить панику позже.
Если Store обязателен, лучше обнаружить проблему сразу при сборке приложения. Иначе вы получите ошибку где-то в середине работы, и она будет выглядеть как «почему-то всё nil», хотя реально это ошибка конфигурации.
Ошибка №5: Передавать зависимости «куда попало», вместо того чтобы передать один собранный объект.
Если у вас в main создаётся store, out, cfg, и вы начинаете прокидывать их в каждую функцию отдельно — код быстро раздувается. Часто проще собрать App (или Service) один раз и дальше работать с ним как с единым объектом верхнего уровня. Это и есть смысл NewApp(deps...): один раз связать проводочки, дальше жить спокойно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ