JavaRush /Курси /Swift SELF /DI через init: composition root і підміна залежностей

DI через init: composition root і підміна залежностей

Swift SELF
Рівень 46, Лекція 4
Відкрита

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 ми створюємо:

  1. конкретний репозиторій
  2. сервіс, якому потрібен репозиторій
  3. 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 — це вхідні дані сценарію, а не залежність.

1
Задача
Swift SELF, 46 рівень, 4 лекція
Недоступна
Привіт через DI
Привіт через DI
1
Задача
Swift SELF, 46 рівень, 4 лекція
Недоступна
Збирання конвертера
Збирання конвертера
1
Задача
Swift SELF, 46 рівень, 4 лекція
Недоступна
Підміна репозиторію
Підміна репозиторію
1
Задача
Swift SELF, 46 рівень, 4 лекція
Недоступна
Спільний лічильник
Спільний лічильник
1
Опитування
Generics vs Existentials, рівень 46, лекція 4
Недоступний
Generics vs Existentials
Вибір між generics і any
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ