Dependency Injection в Kotlin: простое руководство для начинающих

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Что такое Dependency Injection и зачем это нужно в Kotlin?

Dependency Injection (DI, внедрение зависимостей) — это один из ключевых принципов проектирования в современной разработке, особенно в Kotlin. Если вы только начинаете свой путь в программировании, эта концепция может показаться сложной. На самом деле всё просто: Dependency Injection — это способ передать объекту его зависимости извне, а не создавать их внутри самого объекта.

Давайте разберём на бытовом примере. Представьте, что вы печёте пирог. Вместо того чтобы каждый раз самим молоть муку, собирать яйца и сбивать масло (создавать зависимости внутри), вы просто идёте в магазин и покупаете готовые ингредиенты (получаете зависимости извне). DI работает по тому же принципу: вы «заказываете» готовые компоненты, а не создаёте их вручную.

В Kotlin DI особенно важен, потому что язык активно используется для создания Android-приложений, серверных решений на Ktor и микросервисов. Без DI код становится жёстко связанным, его трудно тестировать и поддерживать. С DI вы получаете:

  • Гибкость — легко заменить одну реализацию на другую
  • Тестируемость — можно подставить mock-объекты
  • Чистоту кода — классы не знают, как создаются их зависимости

Проблема без Dependency Injection

Рассмотрим типичную ситуацию. У нас есть класс UserService, который работает с базой данных. Без DI это выглядит так:

class UserService {
    private val database = Database() // Жёсткая связь

    fun getUser(id: Int): User? {
        return database.query("SELECT * FROM users WHERE id = $id")
    }
}

Какие здесь проблемы? Во-первых, UserService сам создаёт экземпляр Database. Если мы захотим заменить реальную базу данных на тестовую (например, in-memory), нам придётся менять код внутри класса. Во-вторых, юнит-тестирование становится невозможным — мы не можем подставить фальшивую базу данных. В-третьих, класс нарушает принцип единственной ответственности (SRP): он не только обрабатывает пользователей, но и управляет созданием базы данных.

Представьте, что таких зависимостей десятки — код превращается в «спагетти», где всё связано со всем. Именно для решения этой проблемы и существует Dependency Injection.

Реализация Dependency Injection вручную

Самый простой способ внедрения зависимостей — передавать их через конструктор. Это называется конструкторная инъекция. Перепишем наш пример:

class UserService(private val database: Database) {
    fun getUser(id: Int): User? {
        return database.query("SELECT * FROM users WHERE id = $id")
    }
}

// Использование:
val database = Database()
val userService = UserService(database)

Теперь UserService не знает, как создаётся Database. Он просто получает готовый экземпляр. Мы можем легко подменить базу данных:

class FakeDatabase : Database() {
    override fun query(sql: String): User? {
        return User(1, "Тестовый пользователь")
    }
}

// В тестах:
val fakeDb = FakeDatabase()
val userService = UserService(fakeDb)

Такой подход называется ручным DI. Для небольших проектов его вполне достаточно. Вы просто создаёте все зависимости в одном месте (например, в main() или в специальном классе-контейнере) и передаёте их туда, где они нужны.

Однако у ручного DI есть недостаток: когда проект разрастается, количество зависимостей становится огромным. Вам придётся вручную отслеживать порядок создания объектов и передавать их через несколько уровней. Здесь на помощь приходят DI-фреймворки.

Dependency Injection с помощью Koin

Koin — это популярный DI-фреймворк для Kotlin, который не требует генерации кода и прост в изучении. Он отлично подходит для начинающих. Давайте посмотрим, как использовать Koin для внедрения зависимостей.

Сначала добавьте зависимость в build.gradle.kts:

implementation("io.insert-koin:koin-core:3.5.0")

Теперь создадим модуль Koin, где опишем, как создавать наши объекты:

// Модуль зависимостей
val appModule = module {
    single { Database() } // single — объект будет создан один раз (синглтон)
    factory { UserService(get()) } // factory — каждый раз новый экземпляр
}

// Запуск Koin
fun main() {
    startKoin {
        modules(appModule)
    }

    // Получаем UserService из контейнера
    val userService: UserService by inject()
    println(userService.getUser(1))
}

Разберём код:

  • single — создаёт объект один раз и хранит его в контейнере. Подходит для баз данных, репозиториев.
  • factory — создаёт новый объект каждый раз при запросе. Подходит для короткоживущих объектов.
  • get() — автоматически получает зависимость из контейнера. Koin сам находит Database и передаёт её в UserService.
  • by inject() — ленивая инициализация: объект создаётся только при первом обращении.

Преимущество Koin в том, что вы описываете зависимости в одном месте, а фреймворк сам заботится о их создании и внедрении. Если вы захотите заменить Database на FakeDatabase для тестов, достаточно изменить модуль:

val testModule = module {
    single { FakeDatabase() }
    factory { UserService(get()) }
}

И подключить его вместо основного. Это делает код невероятно гибким.

Альтернативы: Hilt и Dagger

Помимо Koin, в мире Kotlin популярны Hilt и Dagger. Hilt — это надстройка над Dagger, оптимизированная для Android. Она использует аннотации и генерирует код на этапе компиляции. Вот как выглядит Hilt:

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject lateinit var userService: UserService

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // userService уже внедрён
    }
}

Hilt мощнее Koin, но сложнее в изучении. Для начинающих я рекомендую начать с Koin или ручного DI, а к Hilt переходить, когда освоитесь с базовыми принципами.

Заключение

Блоги

Рекомендаций по книгам