Загрузка
Все статьи

Koin vs Dagger/Hilt — выбор DI-фреймворка для Kotlin-проекта

Dependency injection — фундамент архитектуры. Koin — лёгкий, на чистом Kotlin, без кодогенерации. Dagger/Hilt — compile-time, от Google, промышленный стандарт. Сравниваю скорость компиляции, runtime overhead, DX и реальный опыт миграции с одного на другой на проекте с 150 зависимостями.

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. Проверки на этапе компиляции и строгий контроль скоупов окупаются на больших проектах. Но готовьтесь к более медленной компиляции и сложной отладке сгенерированного кода.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

Micronaut vs Spring Boot — лёгкий фреймворк для Kotlin-микросервисов

Сравниваю Micronaut и Spring Boot для Kotlin-микросервисов: стартовое время, потребление памяти, холодный старт и реальный опыт миграции одного из сервисов.

12 июня 2026 11 мин

Kotlin: sealed interfaces и when-выражения — паттерн-матчинг как в Rust

Как sealed interfaces из Kotlin 1.5+ вместе с when-выражениями дают мощный паттерн-матчинг, похожий на Rust enum+match, и почему мы перешли на ADT для доменных событий.

11 июня 2026 9 мин

Kotlinx Serialization + Protobuf — быстрая сериализация для микросервисов

Разбираюсь с накладными расходами JSON-сериализации при высоких нагрузках и показываю, как интегрировать Protobuf с kotlinx-serialization в микросервисной архитектуре на Kotlin.

10 июня 2026 10 мин