Skip to main content
Technology & EngineeringAndroid Kotlin268 lines

Dependency Injection

Hilt and Dagger dependency injection for managing object creation and scoping in Android apps

Quick Summary25 lines
You are an expert in Hilt and Dagger dependency injection for building Android applications with Kotlin.

## Key Points

- Use constructor injection wherever possible — it is the simplest and most testable approach.
- Reserve `@Provides` for third-party classes and `@Binds` for mapping interfaces to implementations.
- Scope dependencies only when truly necessary — unscoped bindings are created fresh each time, which is often fine and avoids memory leaks.
- Inject `CoroutineDispatcher` via qualifiers instead of hardcoding `Dispatchers.IO` in classes, enabling testability.
- Use `@ViewModelScoped` for dependencies shared between a ViewModel and its dependencies that should not outlive the ViewModel.
- Prefer interface-based dependencies to enable easy faking in tests.
- Forgetting `@AndroidEntryPoint` on an Activity or Fragment, causing a runtime crash when Hilt tries to inject.
- Installing a module in the wrong component (e.g., `@InstallIn(ActivityComponent::class)` for a singleton), causing scoping errors.
- Circular dependencies — Hilt throws a compile-time error. Break the cycle with `@Lazy` injection or redesign the dependency graph.
- Using `@Singleton` on everything, preventing garbage collection and causing memory pressure.
- Not annotating the ViewModel with `@HiltViewModel`, causing `hiltViewModel()` to fail at runtime.
- Testing without `@UninstallModules` or `@BindValue`, accidentally using real implementations in unit tests.

## Quick Example

```kotlin
@HiltAndroidApp
class MyApplication : Application()
```
skilldb get android-kotlin-skills/Dependency InjectionFull skill: 268 lines
Paste into your CLAUDE.md or agent config

Dependency Injection — Android/Kotlin

You are an expert in Hilt and Dagger dependency injection for building Android applications with Kotlin.

Core Philosophy

Dependency injection in Android is about making the implicit explicit. When a class creates its own dependencies internally, those dependencies are hidden -- invisible to tests, invisible to callers, and impossible to swap for different environments. DI makes every dependency visible in the constructor, turning "this class secretly uses the real network" into "this class needs a UserRepository, and here is how to provide one." This transparency is the foundation of testable, modular code.

Hilt's value proposition is reducing the boilerplate of Dagger while keeping its compile-time safety. Dagger validates the entire dependency graph at compile time -- if a dependency is missing, you get a build error, not a runtime crash. Hilt layers Android-specific conventions on top: predefined components tied to Android lifecycle classes, automatic injection into Activities and Fragments, and first-class support for ViewModel injection. The trade-off is less flexibility than raw Dagger, but far less ceremony for the common cases.

Scoping is a tool, not a default. Making everything @Singleton seems safe but causes every object to live for the entire application lifetime, preventing garbage collection and increasing memory pressure. The right scope is the shortest lifetime that satisfies the dependency's needs. An API client that holds no mutable state can be unscoped (created fresh each time) without issue. A database instance should be @Singleton. A user session should be scoped to the authenticated portion of the app, not the entire application.

Anti-Patterns

  • Forgetting @AndroidEntryPoint on an Activity or Fragment: Without this annotation, Hilt does not know to inject dependencies into the class. The app compiles fine, but crashes at runtime when injection is attempted. This is the single most common Hilt setup mistake.

  • Making everything @Singleton: Scoping every dependency as a singleton prevents garbage collection of objects that are only needed temporarily, increases memory usage, and can lead to subtle bugs when stale state persists across different parts of the app. Scope only when the dependency truly needs a shared, long-lived instance.

  • Installing a module in the wrong component: Placing a ViewModel-scoped dependency in SingletonComponent or a singleton dependency in ActivityComponent causes scoping errors or unexpected behavior. Match the module's @InstallIn to the lifecycle that makes sense for its bindings.

  • Circular dependencies in the dependency graph: When Service A depends on Service B which depends on Service A, Dagger throws a compile-time error. This usually signals a design problem. Break the cycle by introducing a protocol/interface at the boundary, splitting one of the services, or using @Lazy injection as a last resort.

  • Using real implementations in unit tests instead of fakes: Without @UninstallModules or @BindValue, Hilt-injected tests use the production dependency graph, meaning tests hit the real network, real database, and real services. Always replace external dependencies with fakes or mocks in test configurations.

Overview

Hilt is the recommended dependency injection framework for Android, built on top of Dagger. It reduces boilerplate by providing predefined components tied to Android lifecycle classes (Application, Activity, ViewModel, etc.). Hilt generates the Dagger component hierarchy automatically, allowing you to focus on defining modules and injection targets.

Core Concepts

Application Setup

@HiltAndroidApp
class MyApplication : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            AppTheme { AppNavHost() }
        }
    }
}

Modules and Provides

A module tells Hilt how to create instances of types that cannot be constructor-injected (interfaces, third-party classes).

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .connectTimeout(30, TimeUnit.SECONDS)
            .readTimeout(30, TimeUnit.SECONDS)
            .build()
    }

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

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

Binds for Interfaces

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {

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

Constructor Injection

class UserRepositoryImpl @Inject constructor(
    private val apiService: ApiService,
    private val userDao: UserDao
) : UserRepository {

    override fun observeUsers(): Flow<List<User>> =
        userDao.observeAll().map { entities -> entities.map { it.toDomain() } }

    override suspend fun refreshUsers() {
        val users = apiService.getUsers()
        userDao.upsertAll(users.map { it.toEntity() })
    }
}

ViewModel Injection

@HiltViewModel
class UserListViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() {

    val uiState: StateFlow<UserListUiState> = repository.observeUsers()
        .map { UserListUiState.Success(it) as UserListUiState }
        .catch { emit(UserListUiState.Error(it.message ?: "Unknown error")) }
        .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UserListUiState.Loading)
}

// In Compose
@Composable
fun UserListScreen(viewModel: UserListViewModel = hiltViewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    // render state
}

Implementation Patterns

Scoping

Hilt provides predefined scopes tied to Android components:

ScopeComponentLifetime
@SingletonSingletonComponentApplication
@ActivityScopedActivityComponentActivity
@ViewModelScopedViewModelComponentViewModel
@FragmentScopedFragmentComponentFragment

Qualifiers

Use qualifiers to distinguish between multiple bindings of the same type.

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

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

@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {

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

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

class ImageProcessor @Inject constructor(
    @DefaultDispatcher private val dispatcher: CoroutineDispatcher
) {
    suspend fun process(bitmap: Bitmap): Bitmap = withContext(dispatcher) {
        applyFilters(bitmap)
    }
}

Assisted Inject

For classes that need both Hilt-provided and runtime parameters.

class PaymentProcessor @AssistedInject constructor(
    private val apiService: ApiService,
    @Assisted private val orderId: String
) {
    suspend fun processPayment(): PaymentResult { /* ... */ }

    @AssistedFactory
    interface Factory {
        fun create(orderId: String): PaymentProcessor
    }
}

// Usage in ViewModel
@HiltViewModel
class CheckoutViewModel @Inject constructor(
    private val paymentProcessorFactory: PaymentProcessor.Factory
) : ViewModel() {
    fun checkout(orderId: String) {
        viewModelScope.launch {
            val processor = paymentProcessorFactory.create(orderId)
            processor.processPayment()
        }
    }
}

Testing with Hilt

@HiltAndroidTest
@RunWith(AndroidJUnit4::class)
class UserListScreenTest {

    @get:Rule
    val hiltRule = HiltAndroidRule(this)

    @BindValue
    val fakeRepository: UserRepository = FakeUserRepository()

    @Before
    fun setup() {
        hiltRule.inject()
    }

    @Test
    fun displaysUsers() {
        // test with fakeRepository injected
    }
}

Best Practices

  • Use constructor injection wherever possible — it is the simplest and most testable approach.
  • Reserve @Provides for third-party classes and @Binds for mapping interfaces to implementations.
  • Scope dependencies only when truly necessary — unscoped bindings are created fresh each time, which is often fine and avoids memory leaks.
  • Inject CoroutineDispatcher via qualifiers instead of hardcoding Dispatchers.IO in classes, enabling testability.
  • Use @ViewModelScoped for dependencies shared between a ViewModel and its dependencies that should not outlive the ViewModel.
  • Prefer interface-based dependencies to enable easy faking in tests.

Common Pitfalls

  • Forgetting @AndroidEntryPoint on an Activity or Fragment, causing a runtime crash when Hilt tries to inject.
  • Installing a module in the wrong component (e.g., @InstallIn(ActivityComponent::class) for a singleton), causing scoping errors.
  • Circular dependencies — Hilt throws a compile-time error. Break the cycle with @Lazy injection or redesign the dependency graph.
  • Using @Singleton on everything, preventing garbage collection and causing memory pressure.
  • Not annotating the ViewModel with @HiltViewModel, causing hiltViewModel() to fail at runtime.
  • Testing without @UninstallModules or @BindValue, accidentally using real implementations in unit tests.

Install this skill directly: skilldb add android-kotlin-skills

Get CLI access →