SYSONLINE
SDK36 · MIN 24
KOTLIN2.0
UTC22:22:57
UP00:01
MOD · ARTICLE · ARCHITECTURES/N · AX-HILT-DPUBLISHED
All Articles
Architecture · Updated 11 MIN

Dependency Injection with Hilt: The Complete Android Guide

Learn how to structure your Android app with Hilt for testable, maintainable code. Covers modules, scopes, qualifiers, and testing patterns.

By Rocky Elsalaymeh · Founder & Principal Consultant, Strategia-X

Why Dependency Injection Matters

Dependency injection means an object receives the things it depends on instead of building them. Without it, a ViewModel creates its repository, which creates its Retrofit service, which creates its HTTP client. Every layer is welded to the one below, and a test cannot replace any of them. With DI, each class declares what it needs in its constructor and something else does the wiring. On Android that something is usually Hilt: "Hilt is Jetpack's recommended library for dependency injection in Android", and it works "by providing containers for every Android class in your project and managing their lifecycles automatically for you". It is "built on top of the popular DI library Dagger", so the wiring is generated and checked at compile time. The versions in this article were read on 11 October 2026: Dagger and Hilt 2.60.1 (releases), KSP 2.3.12 (releases) and the androidx.hilt extensions at 1.4.0 (releases).

Setting Up Hilt

Setup is two plugins, two dependencies and one annotation on the Application class, as in the official guide. Two things changed since this article was first written, and the old sample had both wrong: - hiltViewModel() moved. The androidx.hilt 1.3.0 notes: "The hiltViewModel() APIs for Compose have been moved to a new artifact (androidx.hilt:hilt-lifecycle-viewmodel-compose) and package (androidx.hilt.lifecycle.viewmodel.compose) such that they can be used without transitively depending on androidx.navigation." The sample used to pull in hilt-navigation-compose for it. - In Compose you annotate one activity. "In Compose, you don't need to annotate individual composables. Instead, annotate your root ComponentActivity with @AndroidEntryPoint." If the project keeps its dependency versions in a catalog, these lines belong there; see version catalogs and convention plugins.
kotlin
// build.gradle.kts (project level)
plugins {
    id("com.google.dagger.hilt.android") version "2.60.1" apply false
    id("com.google.devtools.ksp") version "2.3.12" apply false
}

// build.gradle.kts (app level)
plugins {
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.60.1")
    ksp("com.google.dagger:hilt-android-compiler:2.60.1")

    // hiltViewModel() for Compose, without the navigation dependency
    implementation("androidx.hilt:hilt-lifecycle-viewmodel-compose:1.4.0")
}

// Application class
@HiltAndroidApp
class MyApplication : Application()

// The one entry point a Compose app needs
@AndroidEntryPoint
class MainActivity : ComponentActivity()

Modules: Telling Hilt How to Provide Dependencies

Hilt can construct any class whose constructor is annotated with @Inject. A module covers the cases where that is not possible. The first case is an interface. The guide: "If AnalyticsService is an interface, then you cannot constructor-inject it. Instead, provide Hilt with the binding information by creating an abstract function annotated with @Binds inside a Hilt module." The function's return type is the interface; its parameter is the implementation. The second is a class you do not own, such as a Retrofit service or an HTTP client, or anything that needs configuration to build. That is @Provides: a function whose body creates the instance. Use @Binds when all you are saying is which implementation stands behind an interface, and @Provides when there is construction logic to write.
kotlin
// Binding interfaces to implementations
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

    @Binds
    @Singleton
    abstract fun bindUserRepository(
        impl: UserRepositoryImpl
    ): UserRepository
}

// Providing objects you cannot annotate
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient =
        OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()

    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit =
        Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(
                Json.asConverterFactory(
                    "application/json".toMediaType()
                )
            )
            .build()

    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService =
        retrofit.create(ApiService::class.java)
}

How long does a Hilt binding live?

By default, not at all. "By default, all bindings in Hilt are unscoped. This means that each time your app requests the binding, Hilt creates a new instance of the needed type." A scope annotation changes that to one instance per component.
Android classComponentScope
ApplicationSingletonComponent@Singleton
Activity (across rotation)ActivityRetainedComponent@ActivityRetainedScoped
ViewModelViewModelComponent@ViewModelScoped
ActivityActivityComponent@ActivityScoped
ServiceServiceComponent@ServiceScoped
That is the table in the Android guide today. Dagger's own component reference also lists FragmentComponent with @FragmentScoped and ViewComponent with @ViewScoped, for apps that still have fragments and custom views. Scope only what must be shared. A network client and a database are singletons because two of them would be a bug. A use case that holds no state does not need a scope at all: an unscoped binding is created when asked for and collected when its owner goes away. Making everything @Singleton keeps objects alive for the whole process that one screen needed for a minute.
kotlin
// Unscoped: a new instance each time it is requested.
// It holds no state, so it needs no scope.
class GetUserProfileUseCase @Inject constructor(
    private val userRepo: UserRepository,
    private val settingsRepo: SettingsRepository
) {
    suspend operator fun invoke(userId: String): UserProfile {
        val user = userRepo.getUser(userId)
        val settings = settingsRepo.getUserSettings(userId)
        return UserProfile(user, settings)
    }
}

@HiltViewModel
class ProfileViewModel @Inject constructor(
    private val getUserProfile: GetUserProfileUseCase,
    savedStateHandle: SavedStateHandle
) : ViewModel() {

    private val userId: String = checkNotNull(savedStateHandle["userId"])

    val profile: StateFlow<UserProfile?> = flow {
        emit(getUserProfile(userId))
    }.stateIn(viewModelScope, SharingStarted.Lazily, null)
}

Qualifiers: Multiple Bindings of the Same Type

When two bindings share a type, Hilt needs to be told which one you mean. "A qualifier is an annotation that you use to identify a specific binding for a type when that type has multiple bindings defined." Hilt ships two: "as you might need the Context class from either the application or the activity, Hilt provides the @ApplicationContext and @ActivityContext qualifiers." You write your own for everything else. Coroutine dispatchers are the standard case, because Android's coroutine guidance is "Don't hardcode Dispatchers when creating new coroutines or calling withContext" (Android). A qualified dispatcher is how that rule is followed in a Hilt app.
kotlin
// Define qualifiers
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class IoDispatcher

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class DefaultDispatcher

// Provide qualified instances
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {

    @IoDispatcher
    @Provides
    fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO

    @DefaultDispatcher
    @Provides
    fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
}

// Ask for the one you need
class UserRepositoryImpl @Inject constructor(
    private val api: ApiService,
    private val dao: UserDao,
    @IoDispatcher private val ioDispatcher: CoroutineDispatcher
) : UserRepository {

    override suspend fun getUser(id: String): User =
        withContext(ioDispatcher) {
            dao.getUser(id) ?: api.fetchUser(id).also {
                dao.insertUser(it)
            }
        }
}

Testing with Hilt

Start with the sentence that saves the most time: "Hilt isn't necessary for unit tests, since when testing a class that uses constructor injection, you don't need to use Hilt to instantiate that class" (Android). Call the constructor and pass fakes. The unit test that used to be here could not pass. It read viewModel.profile.first(), and the first value of a stateIn flow is its initial value, null. The test now waits for the first non-null value, and replaces the main dispatcher because viewModelScope runs on it. The reasoning is in the testing section of the coroutines deep dive. For instrumented tests there are two ways to swap a binding, and the guide states a preference: - @TestInstallIn replaces a module for every test in the source set. - @UninstallModules with @BindValue replaces it for one test class. The guide warns that it "can significantly impact unit test build times" and says to "prefer using @TestInstallIn when the bindings need to be replaced in all test classes". In Kotlin a @BindValue field also needs @JvmField, as the guide's own sample shows. The earlier version of this article left it out.
kotlin
// Unit test: no Hilt, just the constructor
@OptIn(ExperimentalCoroutinesApi::class)
class ProfileViewModelTest {

    @Before
    fun setup() {
        Dispatchers.setMain(UnconfinedTestDispatcher())
    }

    @After
    fun tearDown() {
        Dispatchers.resetMain()
    }

    @Test
    fun `profile loads successfully`() = runTest {
        val userRepo = FakeUserRepository().apply {
            addUser(User("1", "Jane"))
        }
        val settingsRepo = FakeSettingsRepository().apply {
            addSettings(Settings("1", darkMode = true))
        }

        val viewModel = ProfileViewModel(
            getUserProfile = GetUserProfileUseCase(userRepo, settingsRepo),
            savedStateHandle = SavedStateHandle(mapOf("userId" to "1"))
        )

        // The first value is the initial null. Wait for the real one.
        val profile = viewModel.profile.first { it != null }
        assertEquals("Jane", profile?.user?.name)
    }
}

// Instrumented test: replace one module for this class
@UninstallModules(NetworkModule::class)
@HiltAndroidTest
class ProfileScreenTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @BindValue
    @JvmField
    val api: ApiService = FakeApiService()

    @Before
    fun setup() {
        hiltRule.inject()
    }
}
MOD · TAKEAWAYS6 POINTSSUMMARY

Key Takeaways

  1. 1Hilt is Jetpack's recommended DI library. It generates the wiring at compile time on top of Dagger.
  2. 2Use @Binds to map an interface to an implementation and @Provides when there is construction logic.
  3. 3Bindings are unscoped by default: a new instance per request. Scope only what must be shared.
  4. 4hiltViewModel() now lives in androidx.hilt:hilt-lifecycle-viewmodel-compose, not the navigation artifact.
  5. 5Unit tests do not need Hilt. Call the constructor with fakes.
  6. 6Prefer @TestInstallIn to replace a binding everywhere; @UninstallModules with @BindValue and @JvmField for one test class.
MOD · FAQ3 ENTRIESANSWERED

Frequently Asked

Should I use Hilt or manual DI?

Hilt, for most production apps. Android's documentation calls it Jetpack's recommended library and describes manual injection as constructing every class and its dependencies by hand. Manual DI stays reasonable in a very small app or a library module, where a few constructor calls are easier to read than generated components.

Do I need @AndroidEntryPoint on every composable?

No. The Hilt guide says that in Compose you annotate the root ComponentActivity with @AndroidEntryPoint and nothing else. That activity is the single entry point for the whole UI hierarchy. Composables then obtain a ViewModel annotated with @HiltViewModel by calling hiltViewModel(), which comes from the hilt-lifecycle-viewmodel-compose artifact.

Do I need Hilt in unit tests?

No. The testing guide states that Hilt is not necessary for unit tests: a class that uses constructor injection can be created directly with fake dependencies. Keep Hilt for instrumented tests, where @TestInstallIn replaces a module for all tests and @UninstallModules with @BindValue replaces it for a single test class.

MOD · BUILD · NEXTS/N · AX-CTA-0001READY

Ready to architect your next Android app?

ANDROID-ARCHITECT generates production-ready Kotlin code, architecture blueprints, and CI/CD configurations from plain-language descriptions. Start building for free.

ANDROID-ARCHITECT · CONSOLE
S/N · AX-1A-00001
STRATEGIA-X · ENGINEERED · IN · CALIFORNIA