DIP (Dependency Inversion Principle) — п'ятий принцип SOLID, який визначає правила побудови залежностей між модулями: модулі верхнього рівня не повинні залежати від модулів нижнього рівня, обидва повинні залежати від абстракцій. Абстракції не повинні залежати від деталей — деталі повинні залежати від абстракцій. Цей принцип, описаний Робертом Мартіном у Clean Architecture (2017), лежить в основі слабкозв'язаної архітектури. Згідно з цією книгою, принцип інверсії залежностей усуває жорсткі зв'язки між шарами додатка.
Головне
DIP (Dependency Inversion Principle) — принцип інверсії залежностей, який перевертає традиційне уявлення про напрям залежностей між модулями. Високорівневі модулі (бізнес-логіка) не повинні безпосередньо залежати від низькорівневих модулів (база даних, мережа, UI). Натомість обидва рівні залежать від абстракцій, визначених у модулі верхнього рівня.
Формальне формулювання DIP включає два правила: А — модулі верхнього рівня не повинні залежати від модулів нижнього рівня, обидва повинні залежати від абстракцій. Б — абстракції не повинні залежати від деталей, деталі повинні залежати від абстракцій. Друге правило є наслідком першого: якщо абстракція залежить від деталей, вона не може бути стабільною основою для модуля верхнього рівня.
Без DIP типова архітектура виглядає так: BusinessLogic → DatabaseRepository — бізнес-логіка безпосередньо залежить від конкретного репозиторію. З DIP: BusinessLogic → DatabaseServiceInterface ← DatabaseRepository. BusinessLogic не знає про існування DatabaseRepository, він знає лише інтерфейс DatabaseService, який реалізується поза бізнес-логікою.
Інверсія означає, що потік керування та потік залежностей спрямовані в протилежні сторони. Потік керування йде зверху вниз: UI → ViewModel → UseCase → Repository. Потік залежностей йде знизу вгору: Repository реалізує інтерфейс, визначений у UseCase. Repository (нижній рівень) залежить від UseCase (верхній рівень).
Ця інверсія — ключова відмінність DIP від звичайного розділення на шари. У традиційній шаруватій архітектурі кожен шар залежить від шару нижче. В архітектурі з DIP усі шари залежать від абстракцій, а реалізація цих абстракцій знаходиться в шарі інфраструктури, який «підключається» до верхніх шарів через механізми DI.
Механізм DIP реалізується через визначення абстракцій у модулях верхнього рівня та їх реалізацію в модулях нижнього рівня. Модуль верхнього рівня оголошує інтерфейс для необхідної йому функціональності. Модуль нижнього рівня реалізує цей інтерфейс. Збірка (wiring) відбувається на рівні композиційного кореня додатка.
Процес впровадження DIP в існуючий код: виділити інтерфейс для низькорівневого модуля, перенести цей інтерфейс у модуль верхнього рівня (або в окремий шар абстракцій), переписати залежність верхнього модуля на інтерфейс, змусити низькорівневий модуль реалізувати цей інтерфейс. Після цих кроків напрям залежності змінився на протилежний.
DIP вимагає наявності механізму композиційного кореня — точки в додатку, де створюються всі залежності та зв'язуються одна з одною. В Android це Application.get() або Hilt-компонент, в iOS — AppDelegate або SceneDelegate. Композиційний корінь — єдине місце, де код знає про конкретні реалізації.
DIP формує архітектурні межі між шарами додатка. Коли ViewModel залежить від інтерфейсу UserRepository, між presentation та domain-шаром виникає межа: ViewModel (presentation) не знає, звідки беруться дані. Ця межа дозволяє змінювати реалізацію UserRepository (Room → REST → Mock) без впливу на ViewModel. Чим більше таких меж, тим стійкішим є додаток до змін фреймворків і бібліотек.
В Android-архітектурі, рекомендованій Google, DIP реалізується через UseCase, які знаходяться в domain-шарі та залежать від інтерфейсів Repository. RepositoryImpl знаходяться в data-шарі та реалізують ці інтерфейси. Presentation-шар (ViewModel) залежить від UseCase. Напрям залежностей іде від presentation до domain, від domain до data — але жоден шар не знає про конкретні реалізації іншого шару.
DIP і DI часто плутають, але це різні концепції. DIP — архітектурний принцип (ЩО потрібно робити: залежати від абстракцій). DI — паттерн реалізації (ЯК це зробити: передавати залежності через конструктор). DIP відповідає на питання «на чому мають базуватися модулі?», DI — «як об'єкти отримують свої залежності?».
Dependency Injection — спосіб впровадження залежностей в об'єкт через конструктор, метод або властивість. Коли в Kotlin-клас передається інтерфейс Repository через конструктор — це DI. А те, що клас ViewModel залежить від інтерфейсу Repository, а не від конкретної реалізації RoomRepository — це DIP. DI — інструмент, DIP — мета.
Можна дотримуватися DIP без DI-фреймворку: ручне зв'язування залежностей у композиційному корені — це теж DI (manual DI). Можна використовувати DI-фреймворк (Dagger, Hilt, Koin), порушуючи DIP: якщо ViewModel безпосередньо створює об'єкт Repository через new() — DIP порушено, навіть якщо фреймворк встановлено. DIP — архітектурне рішення, DI — технічна деталь.
Розглянемо Android-приклад застосування DIP до шару даних. Без DIP ViewModel безпосередньо створює RoomDatabase та DAO. З DIP — ViewModel залежить від інтерфейсу UserRepository, а конкретна реалізація RoomUserRepository підставляється ззовні.
// Абстракція належить domain-шару (верхній рівень)
interface UserRepository {
fun getUser(id: Int): User
}
// Domain-шар залежить тільки від абстракції
class GetUserUseCase(
private val repo: UserRepository
) {
fun execute(id: Int): User = repo.getUser(id)
}
// Реалізація в data-шарі залежить від абстракції domain-шару
class RoomUserRepository(
private val dao: UserDao
) : UserRepository {
override fun getUser(id: Int): User {
return dao.getById(id)
}
}
// Композиційний корінь
class AppModule {
fun provideUserRepository(dao: UserDao): UserRepository {
return RoomUserRepository(dao)
}
}
iOS-приклад з Application Coordinator та протоколом для навігації:
// Абстракція навігації в domain-шарі
protocol AuthNavigation {
func navigateToHome()
func navigateToLogin()
}
// ViewModel залежить від абстракції, не від UIKit
final class AuthViewModel {
private let navigation: AuthNavigation
init(navigation: AuthNavigation) {
self.navigation = navigation
}
func onLoginSuccess() {
navigation.navigateToHome()
}
}
// Coordinator (UIKit-шар) реалізує протокол domain-шару
final class AppCoordinator: AuthNavigation {
func navigateToHome() {
// UIKit-код навігації
}
func navigateToLogin() {
// UIKit-код навігації
}
}
Ключовий момент: AuthViewModel (domain) не знає про існування AppCoordinator (UIKit). Він знає лише протокол AuthNavigation. Якщо завтра UIKit заміниться на SwiftUI — AuthViewModel не потребує змін. DIP робить domain-шар незалежним від фреймворків і бібліотек UI.
Hilt — стандартний інструмент DI для Android, рекомендований Google. Вбудований в Jetpack, підтримує ViewModel, Fragment, Service та інші компоненти Android. Hilt автоматизує створення композиційного кореня через анотації @Module, @Provides, @Inject. Використання Hilt не гарантує дотримання DIP — інтерфейс UserRepository має бути визначений у domain-шарі, а не в data-шарі.
Koin — легкий DI-фреймворк для Kotlin без генерації коду та процесора анотацій. DSL Koin (module, single, factory) простіший для вивчення, але перевірка залежностей відбувається в рантаймі, а не на етапі компіляції. Koin популярний у мультиплатформних проектах (KMP) завдяки підтримці iOS.
Dagger 2 — попередник Hilt, досі використовується у великих проектах. Dagger генерує DI-код на етапі компіляції, що дає максимальну продуктивність і діагностику помилок на етапі збірки. Hilt побудований поверх Dagger і надає спрощений API. Для нових проектів Google рекомендує Hilt як основний DI-фреймворк.
DI-модулі повинні відповідати архітектурним шарам і розділятися на DomainModule, DataModule, PresentationModule. DomainModule надає лише абстракції та UseCase. DataModule надає реалізації для абстракцій. PresentationModule зв'язує ViewModel з UseCase. Така організація гарантує, що domain-шар залишається незалежним від інфраструктурних бібліотек.
При міграції між DI-фреймворками (наприклад, з Koin на Hilt) структура DomainModule не змінюється — змінюються лише способи зв'язування в DataModule та PresentationModule. DIP забезпечує ізоляцію domain-логіки, а DI-фреймворк — технічний механізм зв'язування.
Часто задавані питання
DIP необхідний на архітектурних межах — між шарами додатка (domain → data, presentation → domain). Всередині одного шару DIP може бути надлишковим. Наприклад, утилітарний клас StringFormatter всередині domain-шару не потребує інтерфейсу — якщо немає передумов для його заміни.
Ні. DIP — принцип: модулі повинні залежати від абстракцій. DI — паттерн: об'єкт отримує залежності ззовні, а не створює їх сам. DI — спосіб реалізації DIP, але DIP можна дотримуватися і без DI (через фабрики або сервіс-локатор). DI без DIP можливий, але не має архітектурної цінності.
Інтерфейси належать тому модулю, який їх використовує, а не тому, який їх реалізує. UserRepository оголошується в domain-шарі, а реалізується в data-шарі. Це ключове правило DIP: власник абстракції — споживач, а не постачальник реалізації.
DIP робить тестування можливим на ізольованих шарах. ViewModel, яка залежить від UserRepository (інтерфейс), тестується з mock-реалізацією без бази даних. Без DIP ViewModel залежала б від RoomUserRepository і потребувала налаштування БД для кожного тесту. DIP + DI дають повну ізоляцію модулів при тестуванні.
Hilt — стандартний вибір для Android-проектів, рекомендований Google. Koin — альтернатива для проектів Kotlin Multiplatform. Dagger 2 — для існуючих проектів, де міграція на Hilt невиправдана. Вибір фреймворку не скасовує необхідності дотримуватися DIP на рівні архітектури.
Підсумки
Ми розробимо мобільний застосунок під ключ
IT Sectr створює застосунки для iOS та Android для стартапів і бізнесу з 2017 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також