Dependency injection — первое архитектурное решение, которое вы принимаете в проекте. И в Kotlin-экосистеме есть два совершенно разных подхода: Koin — лёгкий DI на чистом Kotlin без кодогенерации, и Dagger/Hilt — промышленный DI от Google с compile-time проверкой. Я использовал оба на продакшене, и у каждого есть чёткая зона применения.
Рассказываю, как они работают под капотом, где каждый выигрывает и почему в нашем проекте мы в итоге выбрали Koin.
Koin: DI на чистом Kotlin
Koin не генерирует код. Он работает как DSL-реестр фабрик. Вы описываете, как создавать зависимости, Koin хранит эти описания в памяти и создаёт объекты при запросе. Всё это происходит в рантайме через Kotlin-функции и лямбды.
// Определение модуля
val appModule = module {
single { DatabaseConfig.fromEnv() }
single { DatabaseConnection(get()) }
single { ProductRepository(get()) }
single {
ProductService(
productRepository = get(),
cacheService = get(),
eventPublisher = get()
)
}
factory { CreateProductValidator() }
viewModel { CartViewModel(get(), get()) }
}
// Запуск в Application
fun Application.module() {
install(Koin) {
modules(appModule, networkModule, cacheModule)
}
// или
startKoin {
modules(appModule)
}
}
// Инъекция
class ProductController : KoinComponent {
private val productService: ProductService by inject()
}
// В Ktor route
get("/products/{id}") {
val service: ProductService by inject()
val product = service.findById(call.parameters["id"]!!.toLong())
call.respond(product)
}
Koin различает single (синглтон), factory (новый экземпляр при каждом запросе), named (квалификатор по имени или типу), viewModel (для Android). Функция get() разрешает зависимости из графа.
Плюсы Koin: минимальный бойлерплейт, быстрый старт (10 минут на изучение), DSL на Kotlin — идиоматично, работает везде (Ktor, Android, Multiplatform). Нет кодогенерации — сборка быстрее. Размер библиотеки: ~300 КБ.
Минусы: разрешение зависимостей в рантайме. Ошибки в графе обнаруживаются только при запуске, а не при компиляции. Если вы забыли зарегистрировать зависимость или создали циклическую ссылку — узнаете только в рантайме. Нет проверок на этапе компиляции, нет визуализации графа. Для больших проектов с сотнями зависимостей это становится проблемой.
Циклические зависимости
Koin обнаруживает циклические зависимости при запуске и бросает KoinAppAlreadyStartedException с указанием, какие бины участвуют в цикле. Но обнаружение происходит только при первом доступе к бину, а не при старте. Если бин не использовался — цикл не будет обнаружен.
// ❌ Циклическая зависимость
val moduleA = module {
single { ServiceA(get<ServiceB>()) }
}
val moduleB = module {
single { ServiceB(get<ServiceA>()) } // StackOverflowError или ошибка в рантайме
}
// ✅ Решение: инвертировать зависимость через интерфейс
val moduleA = module {
single { ServiceA(get<ServiceBCallback>()) }
single<ServiceBCallback> { serviceACallback }
}
val moduleB = module {
single { ServiceB(get<ServiceACallback>()) }
}
Dagger/Hilt: compile-time DI
Dagger — DI-фреймворк от Square (ныне Google). Он генерирует код инъекции на этапе компиляции через annotation processor. Hilt — это обёртка над Dagger, которая упрощает конфигурацию для Android- и Spring-проектов. Hilt предоставляет стандартные компоненты (SingletonComponent, ViewModelComponent, ActivityComponent) и упрощает модульную систему.
// Dagger-модуль
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com")
.addConverterFactory(Json.asConverterFactory(MediaType.parse("application/json")))
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}
// Инъекция через конструктор
@Singleton
class ProductRepository @Inject constructor(
private val apiService: ApiService,
private val cache: ProductCache
) {
suspend fun findById(id: Long): Product = apiService.getProduct(id)
}
// Hilt EntryPoint (для Ktor или не-Android компонентов)
@EntryPoint
@InstallIn(SingletonComponent::class)
interface ProductRepositoryProvider {
fun productRepository(): ProductRepository
}
// Получение зависимости
val entryPoint = EntryPointAccessors.fromApplication(application, ProductRepositoryProvider::class.java)
val repository = entryPoint.productRepository()
Dagger генерирует классы вроде DaggerMyApplication_HiltComponents_ApplicationC, которые содержат оптимальный код для создания графа зависимостей. В рантайме нет рефлексии, нет парсинга DSL — только вызовы конструкторов. Это даёт максимальную производительность.
Главное преимущество — проверка на этапе компиляции. Если граф зависимостей невалиден (не хватает провайдера, циклическая зависимость, конфликт скоупов) — вы узнаете об этом при компиляции. Красная ошибка в IDE, а не stack trace в production.
Скоупы и жизненные циклы
Dagger/Hilt строго контролирует жизненные циклы через скоупы: @Singleton живёт всё время приложения, @ViewModelScoped — пока жива ViewModel, @ActivityScoped — на время жизни Activity. Бин не может зависеть от бина с более узким скоупом (нельзя инжектить @ActivityScoped в @Singleton).
@Module
@InstallIn(ViewModelComponent::class)
object RepositoryModule {
@Provides
@ViewModelScoped
fun provideCartRepository(api: CartApi): CartRepository =
CartRepositoryImpl(api)
}
// ❌ Ошибка компиляции: @ViewModelScoped не может зависеть от @ActivityScoped
@ActivityScoped
class CartApi @Inject constructor(
@ActivityContext private val context: Context
)
Koin не имеет строгой системы скоупов. Вы можете создать скоуп named("activity"), но никто не проверит, что вы не инжектите бин из «viewmodel-скоупа» в бин из «application-скоупа». Ответственность за корректность — на разработчике.
Производительность
Замеры для графа с 150 зависимостями (3 модуля, вложенные скоупы):
Метрика | Koin | Dagger/Hilt
-----------------------------|------------|-------------
Время компиляции (инкр.) | 4 с | 18 с
Время старта приложения | 45 мс | 12 мс
Память под граф DI | 8 МБ | 2 МБ
Размер сгенерированного кода | 0 | ~350 КБ
Размер библиотеки JAR | 300 КБ | 1,2 МБ
Проверки на этапе компиляции | Нет | Да
Обнаружение циклов | Рантайм | Компиляция
Dagger быстрее в рантайме (12 мс против 45 мс на старт), но медленнее в компиляции (18 с против 4 с инкрементально). Для production-приложений скорость в рантайме важнее — Dagger генерирует оптимальный код без оверхэда в рантайме. Koin же «платит» при каждом запуске за парсинг модулей.
Тестирование
Оба фреймворка хорошо поддерживают тестирование. Koin проще в настройке — достаточно подменить модуль:
// Koin: подмена зависимостей в тестах
class ProductServiceTest {
@Test
fun `should create product with validation`() {
val mockRepo = mockk<ProductRepository>()
val mockPublisher = mockk<EventPublisher>()
startKoin {
modules(module {
single { mockRepo }
single { mockPublisher }
single { ProductService(get(), get()) }
})
}
val service: ProductService by inject()
every { mockRepo.save(any()) } returns Product(1L, "Test", BigDecimal("10"))
service.create(CreateProductRequest("Test", BigDecimal("10")))
verify { mockRepo.save(any()) }
verify { mockPublisher.publish(any()) }
stopKoin()
}
}
Dagger/Hilt использует @TestInstallIn или Hilt Testing (Android). Для Ktor-проектов подмена Dagger-модулей менее удобна — нужно создавать TestComponent и использовать @UninstallModules.
Мультиплатформа (KMP)
Если вы разрабатываете Kotlin Multiplatform — Koin безальтернативный выбор. Koin поддерживает KMP (iOS, Android, Desktop, Web), Dagger — только JVM и Android. В KMP-проектах Koin стал стандартом de facto.
// KMP-модуль (общий код)
val sharedModule = module {
single { SqlDelightDriver() }
single { ProductRepository(get()) }
single { ProductService(get()) }
}
// Android
class AndroidApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@AndroidApp)
modules(sharedModule, androidModule)
}
}
}
// iOS (через Kotlin/Native)
fun initKoin() {
startKoin {
modules(sharedModule, iosModule)
}
}
Мой вердикт
Для Android-проектов — Hilt. Google рекомендует Hilt, он отлично интегрируется с Jetpack (ViewModel, WorkManager, Navigation), проверки на этапе компиляции спасают от багов в рантайме, а экосистема (документация, примеры, Stack Overflow) огромна.
Для Ktor server-side — Koin. На сервере нет сложных скоупов жизненного цикла (как Activity/Fragment), граф зависимостей проще, и скорость компиляции важнее. Мы используем Koin во всех Ktor-микросервисах — это просто, быстро и достаточно надёжно.
Для Kotlin Multiplatform — только Koin. Без вариантов.
Для крупных Enterprise-проектов (> 500 зависимостей, десятки модулей, сложная иерархия скоупов) — Dagger. Проверки на этапе компиляции и строгий контроль скоупов окупаются на больших проектах. Но готовьтесь к более медленной компиляции и сложной отладке сгенерированного кода.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье