1. Чому створювати залежність усередині методу — пастка
Коли ви тільки починаєте писати застосунок, дуже хочеться зробити «просто щоб працювало»: усередині методу створити все, що потрібно, і рухатися далі. Це як узяти скотч, намотати на нього ще один шар скотчу й сказати: «Ну тепер точно міцно». Працює? Так. Чи зручно потім змінювати? Ось тут і починається пригода.
Проблема в тому, що прихована залежність робить код непередбачуваним: ви читаєте сигнатуру методу й не бачите, що всередині раптово створюється репозиторій, який зберігає стан, може впасти з помилкою або взагалі дорого обходиться у створенні. У підсумку обʼєкт виглядає простим, але поводиться як мініоркестр.
Погляньмо на «поганий, але життєвий» приклад. Сервіс сам створює репозиторій усередині addBook(title:):
import Foundation
final class LibraryServiceBad {
func addBook(title: String) {
let repo = InMemoryBookRepository() // прихована залежність
let book = Book(id: BookID(), title: title)
try? repo.save(book) // і ще й помилку сховали 🙂
}
}
Тут одразу кілька неприємностей. По-перше, ми не можемо підмінити репозиторій. По-друге, стан репозиторію щоразу «обнуляється», бо ми створюємо новий об’єкт. По-третє, сервіс тепер сам вирішує, «яке сховище правильне», хоча це взагалі не його робота — він має описувати сценарій. І нарешті, try? удає, що помилок не буває, а вони бувають і дуже люблять приходити без попередження.
Ключова ідея DI звучить буденно, але дуже допомагає: залежність має бути параметром, а не сюрпризом.
2. DI через init: робимо залежність частиною контракту типу
Тепер ми зробимо простий крок, але він дуже «архітектурний»: сервіс перестане створювати репозиторій і почне отримувати його ззовні через ініціалізатор. Тобто сервіс говоритиме: «Я вмію працювати, якщо ви дасте мені обʼєкт, який відповідає контракту BookRepository».
Це і є DI через init: впровадження залежностей через ініціалізатор. У побутовій аналогії це звучить так: не «я сам будую собі дім і добуваю електрику», а «я підключаюся до розетки, а як влаштована електростанція — не моя справа».
Спершу зафіксуємо доменні типи — мінімально, але достатньо, щоб було з чим працювати:
import Foundation
struct BookID: Hashable {
let rawValue: UUID
init() { self.rawValue = UUID() }
}
struct Book {
let id: BookID
let title: String
}
Тепер контракт репозиторію — те, що сценарій очікує від шару даних:
protocol BookRepository {
func save(_ book: Book) throws
func all() throws -> [Book]
}
А тепер сервіс, який отримує залежність через init:
import Foundation
final class LibraryService {
private let repo: any BookRepository
init(repo: any BookRepository) {
self.repo = repo
}
func addBook(title: String) throws -> Book {
let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)
let book = Book(id: BookID(), title: trimmed)
try repo.save(book)
return book
}
}
Нюанс Swift: навіщо any BookRepository
Слово any виглядає як «ну просто ще одне слово, щоб ускладнити нам життя». І так, інколи саме так і здається. Але в нього є корисна педагогічна мета: ви бачите, що це не конкретний тип, а абстракція.
Уявіть коробку з написом «будь-яка чашка». Усередині може бути чашка червона, синя, з котиком, без котика. Поки ви тримаєте цю коробку, ви можете робити лише те, що дозволено контрактом «чашка»: наливати чай, тримати за ручку, якщо вона є, але не можете раптом вимагати «покажи мені малюнок котика», бо не кожна чашка зобов’язана бути з котиком.
Саме це і відбувається з існуючими типами: коли ви пишете any P, ви погоджуєтеся на динамічний поліморфізм і обмеження протоколу. Практично для нас висновок простий: у DI ми зазвичай зберігаємо залежності як any Protocol, бо нам важлива замінність реалізації.
Обов’язкові залежності та «свій init»
У Swift, особливо зі struct, ініціалізатори — це не просто «конструктор», а частина дизайну типу. І в мові є правила синтезу memberwise-ініціалізатора для структур, а також нюанси, коли ви оголошуєте власний init.
Чому це важливо саме тут? Тому що DI майже завжди приводить вас до думки: «Я хочу контролювати, що потрібно типу для роботи». А це зазвичай означає: «Я пишу свій init(...) і приймаю залежності параметрами».
Практичне правило: коли тип без залежності не має сенсу — зробіть залежність обовʼязковою в init. Тоді неможливо створити «напівоб’єкт», який ніби існує, але працювати не може.
3. Composition root: місце, де застосунок «збирають»
Коли ви впроваджуєте залежності через init, у новачків виникає логічне запитання: «Гаразд, а хто тоді взагалі створює ці об’єкти та передає їх одне одному? Хто головний?»
Відповідь: composition root — це місце в програмі, де створюють реальні реалізації й зв’язують залежності. У CLI-застосунку це зазвичай точка входу: верхній рівень файла, main-функція або тип — залежно від того, як ви запускаєте проєкт.
Важливо розуміти роль composition root: це не шар бізнес-логіки. Це «електрик», який прокладає дроти, а не «кухар», який готує страву. У composition root ми створюємо:
- конкретний репозиторій
- сервіс, якому потрібен репозиторій
- CLI, якому потрібен сервіс
Давайте створимо просту реалізацію репозиторію «в памʼяті»:
final class InMemoryBookRepository: BookRepository {
private var storage: [BookID: Book] = [:]
func save(_ book: Book) throws {
storage[book.id] = book
}
func all() throws -> [Book] {
Array(storage.values)
}
}
Тепер CLI-шар, якщо коротко. Він уміє друкувати результат і ловити помилки:
import Foundation
struct CLI {
let service: LibraryService
func runAdd(title: String) {
do {
let book = try service.addBook(title: title)
print("Гаразд: додано \(book.title)")
} catch {
print("Помилка:", error)
}
}
}
І ось composition root — «збирач»:
struct AppComposition {
static func makeCLI() -> CLI {
let repo = InMemoryBookRepository()
let service = LibraryService(repo: repo)
return CLI(service: service)
}
}
Якщо хочете побачити це візуально, то «граф обʼєктів» виглядає так:
flowchart TD
CLI[CLI] --> S[Сервіс]
S --> R[будь-який BookRepository]
R --> IM[Репозиторій у памʼяті]
Головне правило: composition root має бути тонким. Він не має валідувати title, вирішувати доменні правила чи друкувати «красиві» повідомлення. Він повинен лише створити й звʼязати обʼєкти.
4. Підміна залежностей: головний бонус DI
Підміна залежностей — це той момент, коли DI починає окупатися майже відразу. Причому «підміна» тут звучить як шпигунський трилер, але по суті це просто: «передати інший об’єкт у init».
Зробімо репозиторій, який завжди падає. Це корисно для перевірки того, як сервіс і CLI реагують на помилки, і взагалі — для впевненості, що помилки не «губляться»:
final class AlwaysFailingBookRepository: BookRepository {
enum Fail: Error { case storageIsDown }
func save(_ book: Book) throws { throw Fail.storageIsDown }
func all() throws -> [Book] { throw Fail.storageIsDown }
}
Тепер, не змінюючи LibraryService, ми можемо зібрати застосунок інакше:
let failingService = LibraryService(repo: AlwaysFailingBookRepository())
let cli = CLI(service: failingService)
cli.runAdd(title: "Clean Code") // помилка: сховище недоступне
Ось тут і народжується важливе відчуття архітектури: шар сценарію (service) не знає, що саме зламалося — він просто чесно передає помилку, а composition root вирішує, яка реалізація використовується сьогодні.
Це саме те, що нам потрібно, коли застосунок росте: різні середовища, різні способи зберігання, різні обмеження. І все це без DI-контейнерів, рефлексії та магічних фабрик — просто Swift-код і init.
5. Міні-CLI: один цикл і наскрізний потік DI
Тепер ми зробимо маленький каркас застосунку, не заглиблюючись у просунутий парсинг, але достатньо, щоб побачити роботу DI в живому потоці: введення → розбір команди → виклик сервісу → вивід.
Додамо в CLI метод run(), який читає рядки. Ми використовуємо readLine() і split, але без складних правил:
import Foundation
extension CLI {
func run() {
while let line = readLine() {
let parts = line.split(separator: " ", maxSplits: 1).map(String.init)
let cmd = parts.first ?? ""
if cmd == "add" {
runAdd(title: parts.count > 1 ? parts[1] : "")
} else if cmd == "exit" {
break
} else {
print("Команди: add <title>, exit")
}
}
}
}
І точка входу — у самому низу файла:
let cli = AppComposition.makeCLI()
cli.run()
Тепер ви можете запустити це й перевірити вручну:
- add The Pragmatic Programmer
- add (порожній title — поки що ми не перевіряємо його жорстко, але незабаром це стане доменним правилом)
- exit
І ключовий момент: ні CLI, ні сервіс не створюють репозиторій. Це робить лише composition root.
6. Типові помилки
Помилка № 1: «DI через init» перетворюють на «DI через init, але всередині все одно створюють залежність».
Іноді пишуть init(repo: BookRepository? = nil) і далі роблять self.repo = repo ?? InMemoryBookRepository(). Здається зручно, але це знову прихована залежність і неявне збирання. Такий код складно контролювати: ви не завжди впевнені, який репозиторій реально використовується, особливо якщо параметр за замовчуванням. Якщо залежність справді обовʼязкова — нехай вона буде обовʼязковим параметром init.
Помилка № 2: Composition root розростається і стає «другим сервісом».
Найпоширеніша помилка новачків: «раз уже тут усе створюється, давайте тут же валідувати, друкувати, ловити помилки й будувати логіку сценаріїв». У підсумку composition root починає містити логіку сценаріїв, а сервіс перетворюється на порожню обгортку. Лікується це просто: у composition root допустимі лише створення об’єктів та їхнє звʼязування; уся логіка — у сервісі або домені.
Помилка № 3: Бояться any і намагаються зберігати залежність як конкретний тип.
Якщо LibraryService зберігає InMemoryBookRepository, ви втрачаєте сенс DI: підміна неможлива без переписування сервісу. Використання any Protocol — нормальна форма для залежностей: це чесний сигнал «тут абстракція, реалізація замінна».
Помилка № 4: Роблять глобальний синглтон Repo.shared, бо так простіше.
Так, простіше — перші 15 хвилин. Потім починається «а чому дані дивно живуть між запусками?», «а чому тести (коли вони зʼявляться) впливають одне на одне?», «а чому один сценарій ламає інший?». Глобальний стан — це DI навпаки: залежність неявна, життєвий цикл неконтрольований, підміна майже неможлива.
Помилка № 5: Намагатися через DI впровадити взагалі все, навіть те, що не є залежністю.
DI потрібен для компонентів, які представляють зовнішній ресурс або контракт (репозиторій, клієнт, провайдер налаштувань). Але не варто тягнути через init кожен рядок і кожен Int. Якщо метод addBook(title:) отримує title, то title — це вхідні дані сценарію, а не залежність.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ