Dependency Injection with Hilt, Dagger and Koin
|
This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production. See the section bibliography for the reference material consulted while preparing these pages. |
This page covers how TrailMate wires its dependencies together: manual dependency injection with an
AppContainer, the Dagger concepts Hilt builds on, Hilt itself (the section’s default), Koin 4.2 as a
reflection-free alternative, and Metro as a newer compile-time option for Kotlin Multiplatform. The baseline is
Dagger/Hilt 2.60.1, androidx.hilt 1.4.0, Koin 4.2.2 and Metro 1.4.5. NetworkModule and
DatabaseModule, introduced on Networking with
Retrofit, OkHttp and Ktor and Room Database and Paging
respectively, are shown here in full, since this page is where every module in the app is wired together.
ViewModel fundamentals, viewModelScope and lifecycle-aware coroutines are covered on
ViewModel, Lifecycle-Aware Components, Coroutines and
Flow and are assumed here; SavedStateHandle itself is introduced on
Saving UI State: Views vs. Compose. Background work
that Hilt also reaches into (@HiltWorker) is covered in full on
Background Work: WorkManager, Services and Alarms. Unit
testing patterns beyond DI itself (Mockito, MockK, Robolectric) are on
Unit Testing with JUnit, Mockito, MockK and
Robolectric. Kotlin language features used below — delegated properties (by inject()), reified generics,
sealed interfaces and coroutines — are not re-explained here; see
Coroutines Basics and
Flows.
Why Dependency Injection
A class that constructs its own collaborators — OfflineFirstTrailRepository calling Room.databaseBuilder(…)
itself, or building its own Retrofit instance — is hard to test and hard to reuse: a unit test cannot substitute
a fake database or a fake network without either hitting real I/O or reaching into private implementation detail.
Dependency injection (DI) flips this around: a class declares what it needs as constructor parameters, and
something else — a factory function, a container, or a framework — supplies (injects) concrete instances at
the point where the object is created.
| Injection style | What it looks like |
|---|---|
Constructor injection |
Dependencies are passed to the primary constructor, as |
Field (setter) injection |
A dependency is assigned to a |
Dependency injection vs. a service locator. A service locator is a global registry a class pulls
dependencies from at the point of use (ServiceLocator.trailRepository), rather than receiving them as
parameters. It looks similar to DI at a glance but hides a class’s real dependencies inside its method bodies,
so nothing about its signature reveals what it needs, and every consumer implicitly depends on the locator itself
being initialized first, which makes ordering bugs and test setup harder to reason about than dependency
injection’s explicit constructors. TrailMate does not use a service locator; the AppContainer pattern below
looks similar but keeps the dependency flow explicit, moving construction to one place without making individual
classes reach out to a registry.
Manual Dependency Injection: AppContainer
Before reaching for a framework, it helps to see what a framework is actually automating. A hand-rolled
AppContainer constructs every dependency exactly once, in the right order, and hands out the same instances to
whatever needs them:
// app/src/main/kotlin/com/example/trailmate/AppContainer.kt
package com.example.trailmate
import android.content.Context
import androidx.room.Room
import com.example.trailmate.core.data.OfflineFirstTrailRepository
import com.example.trailmate.core.data.TrailRepository
import com.example.trailmate.core.database.TrailMateDatabase
import com.example.trailmate.core.network.TrailApi
import kotlinx.coroutines.Dispatchers
import okhttp3.OkHttpClient
import retrofit2.Retrofit
class AppContainer(context: Context) {
private val okHttpClient: OkHttpClient by lazy { OkHttpClient.Builder().build() }
private val trailApi: TrailApi by lazy {
Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.build()
.create(TrailApi::class.java)
}
private val database: TrailMateDatabase by lazy {
Room.databaseBuilder(context, TrailMateDatabase::class.java, "trailmate.db").build()
}
val trailRepository: TrailRepository by lazy {
OfflineFirstTrailRepository(database.trailDao(), trailApi, ioDispatcher = Dispatchers.IO)
}
}
// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
class TrailMateApplication : android.app.Application() {
lateinit var container: AppContainer
private set
override fun onCreate() {
super.onCreate()
container = AppContainer(this)
}
}
A ViewModel (or its factory) then reaches into (application as TrailMateApplication).container to obtain
trailRepository. This works, and for a small app it is a perfectly reasonable choice — no annotation processor,
no generated code, no build-time cost. It breaks down as the graph grows: every new class that needs a dependency
means editing AppContainer by hand, swapping a fake in for a test still means either subclassing AppContainer
or exposing every property as var, and there is no compile-time check that every constructor parameter actually
has a provider — a missing wiring is a run-time NullPointerException or crash, not a build error. Hilt and Koin
exist to remove exactly this boilerplate and, in Hilt’s case, add compile-time verification on top of it. See
Android Developers — Manual dependency
injection.
Dagger Basics
Hilt is a layer of conventions on top of Dagger, Google’s compile-time DI framework for Java and Kotlin, and understanding a few Dagger concepts makes Hilt’s annotations legible rather than magic.
| Concept | Role |
|---|---|
|
Marks a constructor Dagger is allowed to call automatically, generating a factory for it at compile time. |
|
A class (or |
|
An abstract function inside a Dagger module that tells Dagger "when something asks for interface |
|
The bridge between modules and the classes that consume them: an interface Dagger implements at compile time, declaring which modules it draws from and which types it can hand out. Hilt generates every component TrailMate needs automatically (the next section), so application code never writes |
Scope ( |
An annotation on a |
Qualifier |
A custom annotation that disambiguates two bindings of the same type — Dagger cannot tell two |
// A module Dagger consults, independent of any Android/Hilt component.
@Module
object RetrofitModule {
@Provides
fun provideTrailApi(retrofit: Retrofit): TrailApi = retrofit.create(TrailApi::class.java)
}
Plain Dagger requires hand-writing @Component interfaces per Android class (an Activity’s component, a
`Fragment’s component, and so on) and the boilerplate that connects them, which is exactly the ceremony Hilt
automates — every concept above (modules, `@Inject, @Provides, @Binds, scopes, qualifiers) carries over
unchanged into Hilt; only the component-boilerplate layer disappears. See Dagger — User’s Guide and Dagger — Hilt.
Hilt Setup: the Plugin and KSP
Hilt needs its Gradle plugin (which generates the components) and KSP (which runs the annotation processing that
generates @Inject-constructor factories and module bindings):
// gradle/libs.versions.toml
[versions]
hilt = "2.60.1"
androidxHilt = "1.4.0"
ksp = "2.3.12"
[libraries]
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" }
hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "androidxHilt" }
hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidxHilt" }
hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "androidxHilt" }
hilt-work-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "androidxHilt" }
[plugins]
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
// app/build.gradle.kts
plugins {
alias(libs.plugins.android.application) // AGP 9: Kotlin is built in, no org.jetbrains.kotlin.android
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose)
implementation(libs.hilt.lifecycle.viewmodel.compose)
implementation(libs.hilt.work)
ksp(libs.hilt.work.compiler)
}
The Hilt plugin must be applied on the :app module (and on every module with an @AndroidEntryPoint Android
class); a pure Kotlin/JVM module that only contributes @Module/@Binds classes needs just hilt-android and
the KSP compiler, not the plugin itself. kapt still works for Hilt but is legacy — KSP is the baseline here, as
it is for Room; see Android Developers — Migrate to KSP from
kapt. See Android Developers — Hilt
dependency injection with Hilt and Android
Developers — androidx.hilt release notes.
@HiltAndroidApp and @AndroidEntryPoint
@HiltAndroidApp on the Application subclass triggers Hilt’s code generation and creates the application-level
Dagger component that every other component in the app hangs off:
// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
package com.example.trailmate
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
@HiltAndroidApp
class TrailMateApplication : Application()
<!-- app/src/main/AndroidManifest.xml -->
<application
android:name=".TrailMateApplication"
...>
</application>
Every Android class Hilt injects into — an Activity, Fragment, View, Service or BroadcastReceiver — needs @AndroidEntryPoint, which makes Hilt generate a base class that performs field injection right before
super.onCreate() runs:
// app/src/main/kotlin/com/example/trailmate/MainActivity.kt
package com.example.trailmate
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Field injection: Hilt assigns this after super.onCreate() runs internally,
// and before this class's own onCreate() body executes.
@Inject lateinit var connectivityObserver: com.example.trailmate.core.network.ConnectivityObserver
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { TrailMateApp() }
}
}
A Fragment needs @AndroidEntryPoint on both itself and the Activity that hosts it — Hilt derives the
Fragment’s component from its host `Activity’s component, so an un-annotated host `Activity makes an
annotated Fragment crash at run time with IllegalStateException. Most TrailMate ViewModel`s never need
`@AndroidEntryPoint at all: they use @HiltViewModel instead (below), which Hilt wires in through its own
ViewModelProvider.Factory, not through field injection. See
Android Developers — Hilt application class and Android
Developers — Inject dependencies into Android classes.
Hilt Components and Scopes
Hilt generates one Dagger component per Android lifecycle it understands, each scoped to a matching annotation
and nested inside the component whose lifetime contains it. A binding installed in a parent component (most
commonly SingletonComponent) is visible from every component below it in the tree; the reverse is never true.
| Component | Scope annotation | Created for | Typical TrailMate use |
|---|---|---|---|
|
|
The application, for the process’s lifetime |
|
|
|
An activity, surviving configuration changes (backed by a retained |
Rarely used directly; mostly a parent for |
|
|
One |
A dependency that should live exactly as long as one |
|
|
One activity instance |
`MainActivity’s own field-injected dependencies |
|
|
One fragment instance |
`TrailListFragment’s field-injected dependencies (legacy Views UI) |
|
|
One custom |
A custom view that needs a Hilt-provided dependency, rare in TrailMate |
|
|
One |
`RecordHikeService’s field-injected dependencies |
Not shown in the standard table above: androidx.hilt:hilt-work layers a WorkerComponent on top of
SingletonComponent for @HiltWorker classes (covered below), and the @EntryPoint escape hatch (also below)
reaches into any of these components from code Hilt does not manage directly. The figure at the end of this page
draws the whole tree together with the modules and injection sites TrailMate places at each level. See
Android Developers — Component lifetimes and Android
Developers — Generated components.
Modules: @Module and @InstallIn
A Hilt module is a plain Dagger @Module with one addition: @InstallIn names which generated component the
module’s bindings belong to. NetworkModule and DatabaseModule — already introduced on the networking and
Room pages — are installed in SingletonComponent because an OkHttpClient and a RoomDatabase should each
exist exactly once per process.
// app/src/main/kotlin/com/example/trailmate/di/NetworkModule.kt
package com.example.trailmate.di
import com.example.trailmate.core.network.AuthInterceptor
import com.example.trailmate.core.network.TokenAuthenticator
import com.example.trailmate.core.network.TokenStore
import com.example.trailmate.core.network.TrailApi
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
import javax.inject.Singleton
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator,
): OkHttpClient =
OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.authenticator(tokenAuthenticator)
.build()
@Provides
@Singleton
fun provideAuthInterceptor(tokenStore: TokenStore): AuthInterceptor = AuthInterceptor(tokenStore)
@Provides
@Singleton
fun provideTokenAuthenticator(tokenStore: TokenStore): TokenAuthenticator = TokenAuthenticator(tokenStore)
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
val json = Json { ignoreUnknownKeys = true }
return Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(okHttpClient)
.addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
.build()
}
@Provides
@Singleton
fun provideTrailApi(retrofit: Retrofit): TrailApi = retrofit.create(TrailApi::class.java)
}
DatabaseModule follows the same shape and is shown in full on
Room Database and Paging — it
provides the singleton TrailMateDatabase and its DAOs the same way NetworkModule provides OkHttpClient and
TrailApi. RepositoryModule is the third module TrailMate installs in SingletonComponent, and it is where
@Binds and a qualifier both appear:
// app/src/main/kotlin/com/example/trailmate/di/RepositoryModule.kt
package com.example.trailmate.di
import com.example.trailmate.core.data.OfflineFirstTrailRepository
import com.example.trailmate.core.data.TrailRepository
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import javax.inject.Qualifier
import javax.inject.Singleton
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@Provides
@IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindTrailRepository(impl: OfflineFirstTrailRepository): TrailRepository
}
// core/data/src/main/kotlin/com/example/trailmate/core/data/OfflineFirstTrailRepository.kt
class OfflineFirstTrailRepository @Inject constructor(
private val trailDao: TrailDao,
private val trailApi: TrailApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : TrailRepository {
// ...
}
@Binds needs an abstract class (or interface) because it never runs any code itself — it only tells Dagger
which already-injectable implementation satisfies an interface, so Dagger still relies on
OfflineFirstTrailRepository’s own `@Inject constructor to actually build it. @IoDispatcher solves the
qualifier problem from the Dagger section above: without it, a second CoroutineDispatcher binding (for
@MainDispatcher or @DefaultDispatcher, say) would collide with this one, since Dagger otherwise has no way
to tell two CoroutineDispatcher providers apart. Injecting a qualified dispatcher rather than calling
Dispatchers.IO directly is also what makes OfflineFirstTrailRepository testable with
kotlinx-coroutines-test’s `StandardTestDispatcher, by binding a different @IoDispatcher provider in a test
module (see Testing, below). See
Android Developers — Hilt
modules and Android
Developers — Component scopes.
@HiltViewModel and SavedStateHandle
@HiltViewModel lets Hilt build a ViewModel through its own ViewModelProvider.Factory, using constructor
injection exactly like any other Hilt-managed class — including SavedStateHandle, which Hilt injects
automatically without any extra wiring:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailViewModel.kt
package com.example.trailmate.feature.trails
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.example.trailmate.core.data.TrailRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject
@HiltViewModel
class TrailDetailViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
trailRepository: TrailRepository,
) : ViewModel() {
private val trailId: String = checkNotNull(savedStateHandle["trailId"]) {
"TrailDetail route requires a trailId argument"
}
val trail = trailRepository.observeTrail(trailId)
}
savedStateHandle["trailId"] reads the navigation argument that routed to this screen (the trailId in
@Serializable data class TrailDetail(val trailId: String), from
Navigation in Compose) the same way it would survive process death for
any other saved value, as covered in depth on
Saving UI State: Views vs. Compose. @HiltViewModel
needs no @InstallIn of its own — Hilt places every @HiltViewModel class in ViewModelComponent
automatically, and a plain @Provides/@Binds binding installed in it (or anything above it, like
SingletonComponent) is available to inject alongside SavedStateHandle. See
Android Developers — Hilt
and Jetpack, ViewModel with Hilt and
Android Developers — Save UI states with ViewModel and SavedStateHandle.
hiltViewModel() in Compose
Compose obtains a Hilt-provided ViewModel with hiltViewModel() rather than viewModel(), scoped by default to
the nearest ViewModelStoreOwner (typically the navigation back stack entry for the current route):
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailScreen.kt
package com.example.trailmate.feature.trails
import androidx.compose.runtime.Composable
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
@Composable
fun TrailDetailScreen(viewModel: TrailDetailViewModel = hiltViewModel()) {
val trail by viewModel.trail.collectAsStateWithLifecycle()
// ...
}
Since androidx.hilt 1.4.0, hiltViewModel() itself lives in androidx.hilt:hilt-lifecycle-viewmodel-compose
(package androidx.hilt.lifecycle.viewmodel.compose), a Compose-runtime-only artifact with no Navigation
dependency. The older, still-supported androidx.hilt:hilt-navigation-compose artifact provides the same
function together with an overload that scopes a ViewModel to a specific NavBackStackEntry (useful for a
ViewModel shared between two screens of the same navigation graph, such as a shared filter state across
TrailListScreen and a filter sheet). A screen that does not need Navigation-scoped sharing, like
TrailDetailScreen above, only needs the lighter hilt-lifecycle-viewmodel-compose dependency. See
Android Developers — androidx.hilt release notes
and Android Developers — Hilt
and Jetpack, Hilt with Compose.
Assisted Injection
Some classes need both Hilt-provided dependencies and a value only known at the call site — a
TrailDetailViewModel variant that takes a trailId directly as a constructor parameter rather than reading it
from SavedStateHandle, for example, or a presenter created once per list row. Assisted injection lets Hilt
build everything it can and leaves the rest to a small, Hilt-generated factory:
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailSummaryPresenter.kt
package com.example.trailmate.feature.trails
import com.example.trailmate.core.data.TrailRepository
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
class TrailSummaryPresenter @AssistedInject constructor(
private val trailRepository: TrailRepository,
@Assisted private val trailId: String,
) {
suspend fun summary(): String = trailRepository.observeTrail(trailId).toString()
@AssistedFactory
interface Factory {
fun create(trailId: String): TrailSummaryPresenter
}
}
trailRepository is filled in by Hilt as usual; trailId is supplied by whoever calls Factory.create(trailId)
at the point it is actually known — inside a TrailListScreen item, for instance, after @Inject-injecting the
Factory itself. androidx.hilt:hilt-navigation-compose builds on the same mechanism for its own
hiltViewModel(creationCallback = …) overload, which passes a CreationExtras-backed creationCallback lambda
into an @AssistedFactory-annotated ViewModel factory — letting a @HiltViewModel class take an assisted
trailId constructor parameter directly instead of reading it back out of SavedStateHandle:
@HiltViewModel(assistedFactory = TrailDetailViewModel.Factory::class)
class TrailDetailViewModel @AssistedInject constructor(
trailRepository: TrailRepository,
@Assisted private val trailId: String,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(trailId: String): TrailDetailViewModel
}
}
// In the composable:
val viewModel: TrailDetailViewModel = hiltViewModel(
creationCallback = { factory: TrailDetailViewModel.Factory -> factory.create(trailId) },
)
@HiltWorker
androidx.hilt:hilt-work extends the same assisted-injection mechanism to CoroutineWorker, so
TrailSyncWorker can receive Hilt-provided dependencies even though WorkManager, not Hilt, is what actually
constructs it:
// core/data/src/main/kotlin/com/example/trailmate/core/data/TrailSyncWorker.kt
package com.example.trailmate.core.data
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
@HiltWorker
class TrailSyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted workerParams: WorkerParameters,
private val trailRepository: TrailRepository,
) : CoroutineWorker(appContext, workerParams) {
override suspend fun doWork(): Result =
try {
trailRepository.refresh()
Result.success()
} catch (e: Exception) {
Result.retry()
}
}
// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
@HiltAndroidApp
class TrailMateApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}
HiltWorkerFactory is what makes this work: it is registered once as WorkManager’s worker factory (which
requires disabling `WorkManager’s default manifest-based initializer, covered together with the rest of
`TrailSyncWorker’s scheduling on
Background Work: WorkManager, Services and Alarms), and from
then on every `@HiltWorker-annotated CoroutineWorker that WorkManager instantiates is routed through it
instead of through its default no-arg reflection path. See
Android Developers — Hilt
and Jetpack, WorkManager with Hilt.
@EntryPoint
Not every piece of code TrailMate runs is a class Hilt manages directly — a ContentProvider (which the OS
constructs before Application.onCreate() even runs, too early for normal Hilt injection) is the canonical
example. @EntryPoint declares an interface Hilt implements against a specific component, which any code holding
a Context can then use to pull bindings out of that component manually:
// app/src/main/kotlin/com/example/trailmate/di/TrailRepositoryEntryPoint.kt
package com.example.trailmate.di
import com.example.trailmate.core.data.TrailRepository
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
import android.content.Context
@EntryPoint
@InstallIn(SingletonComponent::class)
interface TrailRepositoryEntryPoint {
fun trailRepository(): TrailRepository
}
fun trailRepositoryFrom(context: Context): TrailRepository =
EntryPointAccessors.fromApplication(context, TrailRepositoryEntryPoint::class.java).trailRepository()
EntryPointAccessors.fromApplication (or fromActivity/fromFragment for the narrower-scoped components) is
the one place @EntryPoint interfaces are actually invoked; the interface itself declares nothing but which
bindings it exposes. This is an escape hatch, not a general-purpose alternative to constructor injection — it
should only appear at the small number of integration points (a ContentProvider, a third-party SDK’s callback
interface, a BroadcastReceiver that intentionally avoids @AndroidEntryPoint) that genuinely cannot receive
dependencies any other way. See
Android Developers — Hilt
does not support, entry points.
Testing with Hilt
@HiltAndroidTest marks a test class for Hilt’s own JUnit rule to generate a test-specific component for, and
HiltAndroidRule drives its lifecycle:
// app/src/androidTest/kotlin/com/example/trailmate/TrailListScreenTest.kt
package com.example.trailmate
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import dagger.hilt.android.testing.HiltAndroidRule
import dagger.hilt.android.testing.HiltAndroidTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@HiltAndroidTest
@RunWith(androidx.test.ext.junit.runners.AndroidJUnit4::class)
class TrailListScreenTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val composeRule = createAndroidComposeRule<MainActivity>()
@Before
fun init() {
hiltRule.inject()
}
@Test
fun trailList_showsFetchedTrails() {
// composeRule.onNodeWithText("Eagle Ridge").assertIsDisplayed()
}
}
hiltRule must run before any rule that launches an activity (order = 0), and hiltRule.inject() performs
the actual field injection — it does not happen automatically inside @Before, unlike production code, because
a test frequently wants a chance to replace bindings first. Two annotations swap real bindings for fakes:
| Annotation | Effect |
|---|---|
|
Removes a production module from the generated test component entirely, so nothing in that module’s bindings is available unless another (test) module supplies them. |
|
Marks a test |
// app/src/androidTest/kotlin/com/example/trailmate/di/FakeRepositoryModule.kt
package com.example.trailmate.di
import com.example.trailmate.core.data.TrailRepository
import dagger.Binds
import dagger.Module
import dagger.hilt.components.SingletonComponent
import dagger.hilt.testing.TestInstallIn
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
abstract class FakeRepositoryModule {
@Binds
abstract fun bindTrailRepository(impl: FakeTrailRepository): TrailRepository
}
@BindValue is the lighter-weight alternative when a test only needs to swap in one already-constructed instance
rather than a whole module:
@HiltAndroidTest
class TrailDetailViewModelTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@BindValue
val trailRepository: TrailRepository = FakeTrailRepository()
// ...
}
Finally, Hilt’s generated test components require a custom Application subclass distinct from
TrailMateApplication, configured through the test manifest or testInstrumentationRunner:
// app/src/androidTest/kotlin/com/example/trailmate/TrailMateTestRunner.kt
package com.example.trailmate
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
class TrailMateTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
// app/build.gradle.kts
android {
defaultConfig {
testInstrumentationRunner = "com.example.trailmate.TrailMateTestRunner"
}
}
Koin 4.2: a Reflection-Free Alternative
Koin builds its dependency graph at run time from a Kotlin DSL rather than generating code at compile time — no annotation processor (unless the optional Koin Annotations module is used, below), which means faster builds and simpler tooling, at the cost of an error in the graph (a missing dependency, a wrong scope) surfacing at run time instead of at compile time, the way a Hilt/Dagger error would.
// app/src/main/kotlin/com/example/trailmate/di/TrailMateModule.kt
package com.example.trailmate.di
import com.example.trailmate.core.data.OfflineFirstTrailRepository
import com.example.trailmate.core.data.TrailRepository
import com.example.trailmate.core.database.TrailMateDatabase
import com.example.trailmate.core.network.TrailApi
import com.example.trailmate.feature.trails.TrailDetailViewModel
import com.example.trailmate.feature.trails.TrailListViewModel
import androidx.room.Room
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import org.koin.androidx.viewmodel.dsl.viewModelOf
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
import retrofit2.Retrofit
import retrofit2.converter.kotlinx.serialization.asConverterFactory
val trailMateModule = module {
single<CoroutineDispatcher> { Dispatchers.IO } // the only dispatcher binding, so no qualifier is needed
single { OkHttpClient.Builder().build() }
single { Room.databaseBuilder(get(), TrailMateDatabase::class.java, "trailmate.db").build() }
single { get<TrailMateDatabase>().trailDao() }
single {
Retrofit.Builder()
.baseUrl("https://api.example.com/v1/")
.client(get<OkHttpClient>())
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build()
.create(TrailApi::class.java)
}
// OfflineFirstTrailRepository(trailDao, trailApi, ioDispatcher) -- every parameter is defined above.
singleOf(::OfflineFirstTrailRepository) bind TrailRepository::class
viewModelOf(::TrailListViewModel)
viewModelOf(::TrailDetailViewModel)
}
single { } registers a factory Koin calls once and caches (Koin’s equivalent of @Singleton — no separate
component to install into, since Koin has only one graph per running Koin instance); factory { } (not shown
above) registers one Koin calls fresh every time. get() inside a lambda resolves another definition from the
same graph, by inferred type. singleOf(::OfflineFirstTrailRepository) and factoryOf(::SomeClass) are shorthand
for single { OfflineFirstTrailRepository(get(), get(), get()) }/factory { … } when every constructor
parameter can be resolved with a bare get() — Koin reads the constructor’s parameter types via a small
reflection-free lookup generated by the Kotlin compiler’s default-parameter mechanism, not full runtime
reflection. bind TrailRepository::class is Koin’s equivalent of @Binds, exposing the concrete singleton under
an additional supertype. viewModelOf(::TrailListViewModel) is the ViewModel-aware variant of singleOf
(technically a factory-like scope tied to the ViewModelStore, not a true singleton), used together with
koinViewModel() below.
// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
package com.example.trailmate
import android.app.Application
import com.example.trailmate.di.trailMateModule
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
class TrailMateApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@TrailMateApplication)
modules(trailMateModule)
}
}
}
startKoin builds the graph once, at application startup — there is no per-class annotation equivalent to
@AndroidEntryPoint; any class can pull a dependency from the graph wherever it runs.
Resolving Dependencies: by inject() and koinViewModel()
// A plain class outside Compose/ViewModel, e.g. a legacy Fragment.
class TrailListFragment : Fragment() {
private val trailRepository: TrailRepository by inject()
}
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailListScreen.kt
import org.koin.androidx.compose.koinViewModel
@Composable
fun TrailListScreen(viewModel: TrailListViewModel = koinViewModel()) {
// ...
}
by inject() is a property delegate that resolves lazily from the nearest KoinComponent scope (the global
scope by default) the first time the property is read; koinViewModel() is Koin’s Compose equivalent of
hiltViewModel(), resolving a ViewModel registered with viewModelOf/viewModel { } and scoping it to the
same ViewModelStoreOwner Compose would use anyway.
Koin Scopes
A Koin scope groups definitions that should live and die together, outside the fixed
singleton/application-lifetime default — for example, a scope tied to one hike-recording session that should be
closed (and its dependencies released) when the hike ends, independent of any ViewModel or Activity
lifecycle:
val hikeSessionModule = module {
scope<RecordHikeService> {
scoped { HikeSessionState() }
}
}
// Inside RecordHikeService:
val scope = getKoin().createScope<RecordHikeService>(id = "hike-session")
val sessionState: HikeSessionState = scope.get()
// ...
scope.close() // when the session ends
scoped { } behaves like single { } but only within that scope instance — two different createScope calls
each get their own HikeSessionState, and closing a scope releases every scoped instance created inside it.
Koin Annotations (Brief)
The separate Koin Annotations module (io.insert-koin:koin-annotations, KSP-based) lets a @Single/@Factory
annotation on the class itself generate the equivalent module { } definition, closing much of the compile-time
gap with Hilt while keeping Koin’s run-time resolution model:
@Single
class OfflineFirstTrailRepository(private val trailDao: TrailDao) : TrailRepository
This is an opt-in style, not a replacement for the plain DSL shown above — TrailMate’s Koin module uses the
plain module { } form throughout this page because it needs no extra compiler plugin to follow along.
Verifying the Graph: verify() and KoinTestRule
Because Koin resolves at run time, a missing binding is otherwise only caught by actually running the app (or a
test) far enough to reach it. verify() (from koin-test) statically checks every definition’s declared
constructor parameters against the module, without starting Koin or constructing anything:
// app/src/test/kotlin/com/example/trailmate/di/TrailMateModuleTest.kt
import android.content.Context
import androidx.lifecycle.SavedStateHandle
import org.junit.Test
import org.koin.test.verify.verify
class TrailMateModuleTest {
@Test
fun `trailMateModule has no missing dependencies`() {
// Types supplied by the platform at run time rather than declared in the module.
trailMateModule.verify(extraTypes = listOf(Context::class, SavedStateHandle::class))
}
}
verify() fails with MissingKoinDefinitionException for any constructor parameter type that has no definition — which is why trailMateModule declares the OkHttpClient and the CoroutineDispatcher that
OfflineFirstTrailRepository and the Retrofit definition need — and extraTypes lists the types the platform
provides at run time (Context via androidContext(), SavedStateHandle for viewModelOf).
KoinTestRule starts a real (test-scoped) Koin instance for a test class, typically with one or more production
modules overridden:
// feature/trails/src/test/kotlin/com/example/trailmate/feature/trails/TrailListViewModelTest.kt
import org.junit.Rule
import org.koin.test.KoinTest
import org.koin.test.KoinTestRule
import org.koin.dsl.module
class TrailListViewModelTest : KoinTest {
@get:Rule
val koinTestRule = KoinTestRule.create {
modules(module { single<TrailRepository> { FakeTrailRepository() } })
}
// ...
}
Metro: Compile-Time DI for Kotlin Multiplatform (Brief)
Metro (dev.zacsweers.metro, by Zac Sweers) is a newer Kotlin compiler-plugin-based DI framework that
generates a Dagger-like compile-time graph directly from Kotlin Multiplatform code, without the JVM-only
annotation-processing pipeline Dagger/Hilt relies on. It is mentioned here only because it is a genuine third
direction (compile-time safety and full KMP reach) worth knowing about if a project like TrailMate ever shares
its :core:data/:core:network modules with an iOS or desktop target; TrailMate itself uses Hilt as its
baseline and Koin as its alternative, and does not depend on Metro. See
Metro — GitHub repository.
See also Dependency Injection in KMP: Koin, Metro and Manual DI in the Kotlin Multiplatform section for Metro and Koin in shared code.
Comparing Hilt, Koin, Manual DI and Metro
| Manual DI | Hilt | Koin | Metro | |
|---|---|---|---|---|
Resolution |
Hand-written, whatever code you write. |
Compile-time (Dagger-generated). |
Run-time (a Kotlin DSL graph resolved on demand). |
Compile-time (Kotlin compiler plugin). |
Missing-dependency errors |
A build error only if the container code itself fails to compile; otherwise a run-time crash. |
Build error (Dagger fails to generate the component). |
Run-time |
Build error (the compiler plugin fails). |
Build-time cost |
None. |
KSP/annotation-processing pass on every build. |
None (unless Koin Annotations' KSP module is used). |
Compiler-plugin pass on every build. |
Kotlin Multiplatform |
Yes, trivially (it is plain Kotlin). |
No — JVM/Android only. |
Partial — |
Yes — built for KMP from the start. |
Android integration |
None built in; hand-rolled. |
Deep: generated components per Android lifecycle, |
Good: |
Minimal as of this writing; primarily used outside Android-specific lifecycles. |
Best fit |
A very small app, or teaching what a framework automates. |
An Android-only (or Android-first) app that wants compile-time safety and deep Jetpack integration — TrailMate’s default. |
A codebase that wants to avoid annotation processing, or needs partial KMP reach without a compiler plugin. |
A KMP-first codebase that wants Dagger-like compile-time guarantees across every target. |
No row is a universal winner: TrailMate’s choice of Hilt reflects that it is an Android-only app that values
catching a missing binding at build time and wants @HiltViewModel/`@HiltWorker’s ready-made Jetpack
integration; a team already committed to sharing business logic across Android and iOS/desktop, or one that finds
KSP’s build overhead too costly for its size, would reasonably choose Koin or Metro instead.
Putting It Together: the Hilt Component Hierarchy
The figure below shows how the components introduced in this page nest inside each other, which scope annotation
belongs to each one, and where TrailMate’s own modules and injection sites — NetworkModule/DatabaseModule in
SingletonComponent, RepositoryModule’s `@HiltViewModel consumers in ViewModelComponent, MainActivity in
ActivityComponent, TrailListFragment in FragmentComponent, and RecordHikeService in ServiceComponent — sit within it.
Common Mistakes
-
Reaching for
@EntryPointinstead of constructor injection. It is an escape hatch for code Hilt genuinely cannot manage (aContentProvider, a third-party callback), not a shortcut around designing a class’s dependencies properly. -
Forgetting
@AndroidEntryPointon a hostingActivity. An@AndroidEntryPointFragmentinside a non-annotatedActivityfails at run time withIllegalStateException, not at compile time. -
A missing qualifier on an ambiguous type. Two
@Providesfunctions returning the same type (String,CoroutineDispatcher) with no qualifier compile individually but fail the build with a "duplicate binding" error the moment both are installed in the same component. -
Scoping a lightweight object as
@Singletonout of habit. A cheap, stateless class (provideTrailDaoabove,Retrofit.create(…)) does not need its own scope annotation; over-scoping only holds an object alive longer than necessary and, for Koin’ssingle { }, on the main application-lifetime graph rather than a narrower scope that could be cleared. -
Calling
Dispatchers.IOdirectly inside a class instead of injecting a qualified dispatcher. It compiles and runs correctly in production but makes the class impossible to run deterministically underkotlinx-coroutines-test’s virtual time, which is exactly why `@IoDispatcheris injected above rather than referenced as a top-level constant. -
Skipping
koin.verify()/a@HiltAndroidTestsmoke test. A DI graph with dozens of bindings is easy to break silently by deleting or renaming a class; a single fast test that resolves the whole graph catches it long before a broader instrumented test would.