1. DI вручну і точка складання
DI (Dependency Injection) звучить так, ніби зараз ми будемо піднімати «enterprise-архітектуру» й кликати шамана з контейнером залежностей. Насправді в Go все набагато приземленіше: DI «вручну» — це коли ви створюєте залежності ззовні й передаєте їх явно туди, де ними користуватимуться. Не «всередині функції завів собі базу даних», а «у main створив реалізацію й передав її в сервіс».
Найважливіша думка: DI — це не про красу діаграм, а про контроль зв’язків. Коли залежності створюються всередині бізнес-логіки, код стає складно тестувати, складно розширювати й легко випадково «прибити цвяхами» до конкретної реалізації. Коли залежності передаються ззовні, стає зрозуміло, хто за що відповідає, а компілятор допомагає тримати архітектуру, а не боротися з нею.
Залежність у Go — це не лише «база даних»
Новачки часто думають, що dependency — це щось велике й страшне: база, мережа, черги. Але в Go залежністю є будь-яка річ, яку ваш код використовує, але не має створювати всередині себе. Це може бути сховище задач, генератор ID, writer для виводу, джерело часу, конфігурація тощо. Чим чесніше ви це визнаєте, тим простішим стає код.
У нашому навчальному застосунку (міні-таск-трекері) ми вважатимемо залежностями щонайменше сховище задач і місце, куди писати вивід. І ще одна корисна звичка: якщо функція приймає context.Context, то усі залежності, які виконують роботу (I/O, сховище), теж мають приймати ctx, щоб скасування або таймаут операції проходили ланцюжком.
Коротка таблиця для орієнтації:
| Що це | Приклад | Чому це залежність |
|---|---|---|
| Сховище | |
Можна замінити in-memory на файл/БД, не змінюючи сценарій |
| Вивід | |
Можна писати в stdout, буфер, файл — без переписування логіки |
| Контекст операції | |
Передає скасування та дедлайни далі по стеку викликів |
Чому створювати залежності всередині логіки — пастка
Почнімо з антиприкладу. Він дуже життєвий: ви пишете функцію, вам потрібне сховище, і ви такі: «ну зараз швидко зроблю».
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("порожній заголовок")
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/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("сховище не передано")
}
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("отримання наступного 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("збереження: %w", err)
}
return t, nil
}
Зверніть увагу на одну характерну деталь: ми не створюємо memstore.New() всередині. Ми просто використовуємо a.store. Це й є DI.
6. Адаптер memstore
Тепер напишемо найпростіше сховище в пам’яті. Воно житиме в 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("помилка ініціалізації:", err) // помилка ініціалізації: сховище не передано (якщо забули Store)
return
}
t, err := a.AddAndSave(context.Background(), "вивчаємо DI у Go")
if err != nil {
fmt.Println("помилка виконання:", err)
return
}
fmt.Fprintln(os.Stdout, "додано задачу:", t.Title) // додано задачу: вивчаємо DI у 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, "готово:", title) // готово: вивчаємо DI у Go
}
І використаємо його в main:
// main.go (фрагмент)
t, _ := a.AddAndSave(context.Background(), "вивчаємо DI у Go")
a.PrintAdded(t.Title) // готово: вивчаємо DI у 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...): один раз зв’язати все докупи, а далі жити спокійно.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ