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 OfflineFirstTrailRepository(trailDao: TrailDao, trailApi: TrailApi). This is the style used throughout TrailMate: it makes every dependency visible at the type’s declaration, makes the type impossible to construct in an invalid state, and needs no framework at all to unit test — a test simply calls the constructor with fakes.

Field (setter) injection

A dependency is assigned to a lateinit var property after construction, typically because the framework — not application code — instantiates the class (an Activity or Fragment, which the OS constructs via a no-arg constructor). Hilt’s @AndroidEntryPoint uses field injection under the hood for exactly this reason; application-level TrailMate classes never use it directly.

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

@Inject constructor

Marks a constructor Dagger is allowed to call automatically, generating a factory for it at compile time. class OfflineFirstTrailRepository @Inject constructor(…​) tells Dagger it can build a OfflineFirstTrailRepository itself, as long as it can build every parameter too.

@Module / @Provides

A class (or object) annotated @Module, whose @Provides-annotated functions build a type Dagger cannot construct directly — because it comes from a third-party library (OkHttpClient, Retrofit), needs configuration (a base URL), or is an interface with no @Inject constructor of its own.

@Binds

An abstract function inside a Dagger module that tells Dagger "when something asks for interface I, give it implementation Impl`" — `abstract fun bindTrailRepository(impl: OfflineFirstTrailRepository): TrailRepository. Cheaper than @Provides (no generated factory method body, just a binding lookup) and is the idiomatic way to bind an interface to one implementation.

@Component

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 @Component directly.

Scope (@Singleton and friends)

An annotation on a @Provides/@Binds function (or an @Inject-constructed class) that says "build this once per component instance, then reuse it" — without a scope annotation, Dagger builds a fresh instance every time something asks for that type.

Qualifier

A custom annotation that disambiguates two bindings of the same type — Dagger cannot tell two CoroutineDispatcher bindings apart by type alone, so a qualifier like @IoDispatcher (shown below) tags which one a given @Provides function or constructor parameter means.

// 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

SingletonComponent

@Singleton

The application, for the process’s lifetime

NetworkModule, DatabaseModule — OkHttpClient, Retrofit, TrailMateDatabase

ActivityRetainedComponent

@ActivityRetainedScoped

An activity, surviving configuration changes (backed by a retained ViewModelStore-style holder)

Rarely used directly; mostly a parent for ViewModelComponent

ViewModelComponent

@ViewModelScoped

One ViewModel instance

A dependency that should live exactly as long as one TrailListViewModel, shared between it and something it constructs internally

ActivityComponent

@ActivityScoped

One activity instance

`MainActivity’s own field-injected dependencies

FragmentComponent

@FragmentScoped

One fragment instance

`TrailListFragment’s field-injected dependencies (legacy Views UI)

ViewComponent / ViewWithFragmentComponent

@ViewScoped

One custom View instance

A custom view that needs a Hilt-provided dependency, rare in TrailMate

ServiceComponent

@ServiceScoped

One Service instance

`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

@UninstallModules(RepositoryModule::class)

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.

@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])

Marks a test @Module as the direct replacement for a production module, without needing @UninstallModules on every test class — the swap applies automatically to every Hilt test that would otherwise pull in the original module.

// 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 NoDefinitionFoundException, or a build/test failure if verify() is run.

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 — koin-core is KMP; Android-specific integrations (koin-android, Compose) are not.

Yes — built for KMP from the start.

Android integration

None built in; hand-rolled.

Deep: generated components per Android lifecycle, @HiltViewModel, @HiltWorker, hiltViewModel().

Good: koin-android, koinViewModel(), scopes tied to Android classes.

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.

Diagram of the Hilt component hierarchy. At the top, a SingletonComponent box (scope @Singleton, process lifetime) holds NetworkModule and DatabaseModule, and branches down into two children: ActivityRetainedComponent (@ActivityRetainedScoped, survives configuration changes) on the left and ServiceComponent (@ServiceScoped, one Service such as RecordHikeService) on the right. ActivityRetainedComponent branches further into ViewModelComponent (@ViewModelScoped, hosting @HiltViewModel classes wired through RepositoryModule) and ActivityComponent (@ActivityScoped, hosting the @AndroidEntryPoint MainActivity). ActivityComponent branches into FragmentComponent (@FragmentScoped, hosting the @AndroidEntryPoint TrailListFragment) and ViewComponent (@ViewScoped, a custom view), and FragmentComponent branches further into ViewWithFragmentComponent (@ViewScoped, a view inside a fragment). A dashed side branch from SingletonComponent leads to a box for @HiltWorker (androidx.hilt:hilt-work), and a separate unconnected box explains @EntryPoint as a way to reach a component’s bindings from code Hilt does not manage. A legend states that a solid arrow means a parent component creates one child component per instance of its Android class, a dashed arrow is an androidx.hilt extension component layered on a standard Hilt component, and a binding installed higher in the tree is visible everywhere below it.

Common Mistakes

  • Reaching for @EntryPoint instead of constructor injection. It is an escape hatch for code Hilt genuinely cannot manage (a ContentProvider, a third-party callback), not a shortcut around designing a class’s dependencies properly.

  • Forgetting @AndroidEntryPoint on a hosting Activity. An @AndroidEntryPoint Fragment inside a non-annotated Activity fails at run time with IllegalStateException, not at compile time.

  • A missing qualifier on an ambiguous type. Two @Provides functions 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 @Singleton out of habit. A cheap, stateless class (provideTrailDao above, Retrofit.create(…​)) does not need its own scope annotation; over-scoping only holds an object alive longer than necessary and, for Koin’s single { }, on the main application-lifetime graph rather than a narrower scope that could be cleared.

  • Calling Dispatchers.IO directly inside a class instead of injecting a qualified dispatcher. It compiles and runs correctly in production but makes the class impossible to run deterministically under kotlinx-coroutines-test’s virtual time, which is exactly why `@IoDispatcher is injected above rather than referenced as a top-level constant.

  • Skipping koin.verify()/a @HiltAndroidTest smoke 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.