Migrating from Hilt
This guide moves an Android app from Dagger Hilt to Koin, one step at a time: Gradle, the Application class, modules, bindings, ViewModels, entry points, qualifiers, Compose and tests. Each Koin sample comes in the three Koin styles; if you are not sure which one to pick, read Choosing your style. For a comparison of the two frameworks, see Koin vs Hilt.
For a full real-world migration, read Migrating Now in Android to Koin: Google's sample app with 30 Gradle modules, moved from Hilt to Koin Annotations.
Concept mapping
| Hilt | Compiler Plugin DSL | Annotations | Classic DSL |
|---|---|---|---|
@HiltAndroidApp | startKoin { } in Application.onCreate() | @KoinApplication + startKoin<T>() | startKoin { } in Application.onCreate() |
@AndroidEntryPoint | Not needed: by inject(), by viewModel() | Not needed | Not needed |
@Inject constructor | single<T>() / factory<T>() | @Singleton / @Factory on the class | singleOf(::T) / factoryOf(::T) |
@Module + @InstallIn | module { } | @Module + @ComponentScan | module { } |
@Provides | single { create(::fn) } | @Singleton / @Factory on a function | single { ... } |
@Binds | bind Interface::class | Automatic (supertypes) or binds = [...] | bind Interface::class |
@Singleton | single | @Singleton / @Single | single |
@HiltViewModel | viewModel<T>() | @KoinViewModel | viewModelOf(::T) |
@HiltWorker | worker<T>() | @KoinWorker | workerOf(::T) |
@Named, custom @Qualifier | named("x"), @Named on parameters | @Named, custom @Qualifier | named("x") |
@ApplicationContext | Context parameter, provided by androidContext() | Same | androidContext() |
@AssistedInject | Injected parameters | @InjectedParam | Injected parameters |
@Inject field in a class you own | Constructor parameter | Constructor parameter | Constructor parameter |
@EntryPoint (framework class Hilt can't inject) | KoinComponent at that entry point only | Same | Same |
@HiltAndroidTest | KoinTestRule | KoinTestRule | KoinTestRule |
Scope mapping
| Hilt | Koin DSL | Annotations | Lifetime |
|---|---|---|---|
@Singleton (SingletonComponent) | single | @Singleton | Application |
@ActivityRetainedScoped | activityRetainedScope { } | @ActivityRetainedScope | Survives configuration changes |
@ViewModelScoped | viewModelScope { } | @ViewModelScope | ViewModel |
@ActivityScoped | activityScope { } | @ActivityScope | Activity |
@FragmentScoped | fragmentScope { } | @FragmentScope | Fragment |
Scope DSL and annotations are explained in Android Scopes.
Step 1: Gradle
Remove the Hilt plugin, hilt-android, and hilt-compiler (kapt or KSP). Then add Koin:
dependencies {
implementation(platform("io.insert-koin:koin-bom:$koin_version"))
implementation("io.insert-koin:koin-android")
implementation("io.insert-koin:koin-androidx-compose") // if you use Compose
}
To use the Compiler Plugin DSL or annotations, also apply the Koin Compiler Plugin. It runs inside the Kotlin compiler and replaces kapt or KSP for Koin. The full list of artifacts is in Gradle Setup.
Step 2: Application class
Hilt:
@HiltAndroidApp
class MyApplication : Application()
Koin:
- Compiler Plugin DSL
- Annotations
- Classic DSL
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@MyApplication)
modules(appModule)
}
}
}
import org.koin.plugin.module.dsl.startKoin
@KoinApplication
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin<MyApplication> {
androidLogger()
androidContext(this@MyApplication)
}
}
}
@KoinApplication loads every module tagged @Configuration. To list modules explicitly, use @KoinApplication(modules = [AppModule::class]).
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@MyApplication)
modules(appModule)
}
}
}
androidContext() makes the Context available to every definition. See Starting Koin on Android.
Step 3: Modules and @Provides
Hilt:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
@Provides
@Singleton
fun provideApiService(client: OkHttpClient): ApiService =
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(client)
.build()
.create(ApiService::class.java)
}
Koin:
- Compiler Plugin DSL
- Annotations
- Classic DSL
import org.koin.plugin.module.dsl.create
fun okHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
fun apiService(client: OkHttpClient): ApiService =
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(client)
.build()
.create(ApiService::class.java)
val networkModule = module {
single { create(::okHttpClient) }
single { create(::apiService) }
}
@Module
class NetworkModule {
@Singleton
fun okHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
@Singleton
fun apiService(client: OkHttpClient): ApiService =
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(client)
.build()
.create(ApiService::class.java)
}
val networkModule = module {
single { OkHttpClient.Builder().build() }
single {
Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(get())
.build()
.create(ApiService::class.java)
}
}
There is no @InstallIn: a definition's lifetime comes from its type (single, factory, scoped), not from the component it is installed in. Classes with an @Inject constructor become single<T>() / factory<T>(), an annotation on the class, or singleOf(::T). More on library objects in Definitions.
Step 4: Interface bindings
Hilt:
@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
Koin:
- Compiler Plugin DSL
- Annotations
- Classic DSL
val dataModule = module {
single<UserRepositoryImpl>() bind UserRepository::class
}
@Singleton
class UserRepositoryImpl(private val api: ApiService) : UserRepository
The class is bound to all its supertypes. To bind only some, use @Singleton(binds = [UserRepository::class]).
val dataModule = module {
singleOf(::UserRepositoryImpl) bind UserRepository::class
}
Step 5: ViewModels
Hilt:
@HiltViewModel
class UserViewModel @Inject constructor(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
Koin:
- Compiler Plugin DSL
- Annotations
- Classic DSL
class UserViewModel(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
val appModule = module {
viewModel<UserViewModel>()
}
@KoinViewModel
class UserViewModel(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
class UserViewModel(
private val repository: UserRepository,
private val savedStateHandle: SavedStateHandle,
) : ViewModel()
val appModule = module {
viewModelOf(::UserViewModel)
}
Koin provides SavedStateHandle to ViewModel definitions. In an Activity or Fragment, replace by viewModels() with by viewModel(). In Compose, replace hiltViewModel() with koinViewModel(). See Android ViewModel.
Step 6: Activities, Fragments and other entry points
Hilt:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var analytics: Analytics
}
Koin:
class MainActivity : ComponentActivity() {
private val analytics: Analytics by inject()
}
Remove @AndroidEntryPoint. In Activities, Fragments and other Android components, which the framework creates, fields with @Inject lateinit var become by inject() (lazy) or get() (immediate). This is the same in every style. Services, BroadcastReceivers and other components are covered in Entry Points.
In your own classes (repositories, services, use cases, ViewModels), move @Inject fields to constructor parameters and declare the class as in Step 3. These classes never call get() or by inject():
// Hilt: field injection in a class you own
class SyncService @Inject constructor() {
@Inject lateinit var repository: UserRepository
}
// Koin: constructor injection
class SyncService(private val repository: UserRepository)
Hilt's @EntryPoint exists for framework classes Hilt can't inject, such as a ContentProvider or a library callback. Only at such an entry point, implement KoinComponent and call by inject(). Do not use KoinComponent to replace @Inject fields in app classes.
Step 7: Qualifiers
Hilt:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
class SyncRepository @Inject constructor(
@IoDispatcher private val dispatcher: CoroutineDispatcher,
)
Koin:
- Compiler Plugin DSL
- Annotations
- Classic DSL
import org.koin.core.annotation.Named
class SyncRepository(
@Named("io") private val dispatcher: CoroutineDispatcher,
)
val dispatcherModule = module {
single<CoroutineDispatcher>(named("io")) { Dispatchers.IO }
single<SyncRepository>()
}
Keep your qualifier annotation. Koin reads custom annotations marked with @Qualifier (Koin's, jakarta.inject or javax.inject):
import javax.inject.Qualifier
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Module
class DispatcherModule {
@Singleton
@IoDispatcher
fun ioDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
@Singleton
class SyncRepository(
@IoDispatcher private val dispatcher: CoroutineDispatcher,
)
val dispatcherModule = module {
single<CoroutineDispatcher>(named("io")) { Dispatchers.IO }
single { SyncRepository(get(named("io"))) }
}
Type qualifiers (named<IoDispatcher>()) and all other options are in Qualifiers. Hilt's @ApplicationContext needs no qualifier: a Context parameter receives the context passed to androidContext().
Step 8: Compose
Hilt needs an entry point to reach a non-ViewModel dependency from a Composable. Koin resolves it directly:
@Composable
fun UserScreen(viewModel: UserViewModel = koinViewModel()) {
val analytics: Analytics = koinInject()
}
See Koin for Compose.
Step 9: Tests
Hilt:
@HiltAndroidTest
class UserRepositoryTest {
@get:Rule
val hiltRule = HiltAndroidRule(this)
@Inject lateinit var repository: UserRepository
@Before
fun init() = hiltRule.inject()
}
Koin:
class UserRepositoryTest : KoinTest {
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(appModule, testModule)
}
private val repository: UserRepository by inject()
}
Hilt's @TestInstallIn and @UninstallModules become a test module loaded after the production modules. Remove HiltTestApplication and the Hilt test runner. See Unit Testing and Android Instrumented Testing.
Migrate gradually with @Inject
With annotations, the Compiler Plugin reads JSR-330 annotations: in a package covered by @ComponentScan, a class with an @Inject constructor becomes a factory, and a class annotated with @Singleton (from jakarta.inject or javax.inject) a single. You can keep these classes unchanged and migrate Gradle module by Gradle module. See JSR-330 Annotations.
Multi-module projects
Each Gradle module declares its own Koin module, and the app module loads them all in startKoin. There is no component hierarchy to configure and no @EntryPoint for cross-module access: a definition in :feature:home can use any definition loaded by the app.
With annotations, tag each feature module class with @Module, @ComponentScan and @Configuration. @KoinApplication in the app module finds them without listing them.
Module organization, visibility and feature modules are covered in Multi-Module Apps.
Common patterns
Assisted injection
Hilt's @AssistedInject and @AssistedFactory become injected parameters. The caller passes the runtime value:
- Compiler Plugin DSL
- Annotations
- Classic DSL
class UserDetailViewModel(
@InjectedParam private val userId: String,
private val repository: UserRepository,
) : ViewModel()
val appModule = module {
viewModel<UserDetailViewModel>()
}
@KoinViewModel
class UserDetailViewModel(
@InjectedParam private val userId: String,
private val repository: UserRepository,
) : ViewModel()
class UserDetailViewModel(
private val userId: String,
private val repository: UserRepository,
) : ViewModel()
val appModule = module {
viewModel { (userId: String) -> UserDetailViewModel(userId, get()) }
}
val viewModel: UserDetailViewModel by viewModel { parametersOf("user-42") }
See Injected Parameters.
Lazy and Provider
dagger.Lazy<T>: use a KotlinLazy<T>constructor parameter, orby inject()in an Android entry point (see Injection).Provider<T>: to create a new instance on each call from a class you own, use the provider pattern in Advanced Patterns.
WorkManager
@HiltWorker workers become worker definitions, and HiltWorkerFactory becomes workManagerFactory() in startKoin. See WorkManager.
Checklist
- Gradle: remove the Hilt plugin,
hilt-androidandhilt-compiler. Add Koin, and the Compiler Plugin if you use it. Remove kapt if nothing else needs it. - Application: remove
@HiltAndroidApp, start Koin inonCreate()withandroidContext(). - Modules: convert
@Module/@InstallIn/@Provides/@Bindsto Koin modules. - ViewModels: remove
@HiltViewModel, declare them, useby viewModel()/koinViewModel(). - Entry points: remove
@AndroidEntryPoint, useby inject()in Android components. Move@Injectfields of your own classes to constructor parameters. Replace@EntryPointwithKoinComponentonly in framework classes Koin can't create. - Qualifiers: keep custom
@Qualifierannotations (annotations style) or switch tonamed(). - Workers: replace
@HiltWorkerandHiltWorkerFactory. - Tests: remove
@HiltAndroidTest,HiltAndroidRuleand the Hilt test runner, useKoinTestRule. - Build: with the Compiler Plugin, a successful build means the graph is complete. Without it, add a verify() test.
- Run: launch the app and the test suite.
Troubleshooting
NoDefinitionFoundException
Koin has no definition for the requested type. Check that the module declaring it is loaded in startKoin (or found by @Configuration), and that the requested type matches the declared or bound type. A class that only had @Inject constructor and was injected by Hilt needs a Koin definition too, unless a @ComponentScan picks it up through its JSR-330 annotations.
Wrong instance with two definitions of the same type
With Hilt, a missing qualifier is a build error. With Koin, the last loaded definition wins, unless you start Koin with allowOverride(false). Add a qualifier to both definitions and to the injection points.
Circular dependency
Two classes depend on each other. Refactor to break the cycle, or make one side lazy with a Lazy<T> parameter. The Compiler Plugin reports cycles at build time. More in Troubleshooting.
Related
- Koin vs Hilt: how the two frameworks differ.
- JSR-330 Annotations: keep
@Injectand@Singletonduring the migration. - Starting Koin on Android:
startKoin,androidContext()and logging. - Android Scopes: Activity, Fragment and ViewModel scopes.
- Unit Testing: replace Hilt test rules.
- Compile-Time Safety: what the Compiler Plugin checks.