MyFamilyTracker
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
A practical guide to Hilt DI in Android — how scopes work, how to structure modules, common mistakes, and how to write tests against Hilt-injected code without fighting the framework.
On this page
Hilt is the standard dependency injection framework for Android. It's built on Dagger but removes most of the boilerplate — component generation, component relationships, and Android lifecycle integration all happen automatically.
After using Hilt across 22 apps, here's the complete picture: scopes, module design, and testing.
Hilt generates Dagger components for you. You declare what things need injection (
@Inject@Module@Provides@Binds@Singleton@ActivityScopedThe entry points are annotated:
@HiltAndroidApp
class MyApplication : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity()
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: ItemRepository
) : ViewModel()@HiltAndroidApp@AndroidEntryPoint@HiltViewModelScope determines how long a dependency lives and whether it's shared.
| Scope | Annotation | Lifetime |
|---|---|---|
| App-wide singleton | code | App process lifetime |
| Activity-scoped | code | Activity lifetime |
| Fragment-scoped | code | Fragment lifetime |
| ViewModel-scoped | code | ViewModel lifetime |
| Unscoped | (no annotation) | New instance per injection |
Injecting an
@ActivityScoped@SingletonSingletonComponent
└── ActivityRetainedComponent
└── ViewModelComponent
└── ActivityComponent
└── FragmentComponentEach component can only inject dependencies from its own scope or any parent scope. Never from a child scope.
Use
@SingletonOkHttpClientRetrofitRoomDatabaseDon't use
@Singleton@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(BuildConfig.BASE_URL)
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService =
retrofit.create(ApiService::class.java)
}If a dependency should live exactly as long as the ViewModel and isn't shared across ViewModels, use
@ViewModelScoped@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {
@Provides
@ViewModelScoped
fun providePagingConfig(): PagingConfig =
PagingConfig(pageSize = 20, enablePlaceholders = false)
}Use
@Provides@Binds// @Provides — when you control construction
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db")
.fallbackToDestructiveMigration()
.build()
// @Binds — when binding an implementation to an interface
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindItemRepository(
impl: ItemRepositoryImpl
): ItemRepository
}@Binds@Provides@Module
@InstallIn(SingletonComponent::class)
abstract class DataModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
companion object {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
@Provides
fun provideItemDao(database: AppDatabase): ItemDao = database.itemDao()
}
}Hilt provides these qualifiers for injecting Context without coupling to a specific Activity:
class ItemLocalDataSource @Inject constructor(
@ApplicationContext private val context: Context,
private val dao: ItemDao
)Never inject
ActivityContext@Singleton@ApplicationContextIf you need two different instances of the same type, use a qualifier annotation:
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class AuthenticatedClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PublicClient
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
@AuthenticatedClient
fun provideAuthenticatedClient(authInterceptor: AuthInterceptor): OkHttpClient =
OkHttpClient.Builder().addInterceptor(authInterceptor).build()
@Provides
@Singleton
@PublicClient
fun providePublicClient(): OkHttpClient =
OkHttpClient.Builder().build()
}
// Usage
class ApiService @Inject constructor(
@AuthenticatedClient private val client: OkHttpClient
)The standard approach: create a fake module that replaces the real one in tests.
// Production module
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindItemRepository(impl: ItemRepositoryImpl): ItemRepository
}
// Test replacement
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {
@Binds
abstract fun bindItemRepository(impl: FakeItemRepository): ItemRepository
}@TestInstallInclass FakeItemRepository @Inject constructor() : ItemRepository {
var items = mutableListOf<Item>()
var shouldThrowError = false
override suspend fun getItems(): Result<List<Item>> =
if (shouldThrowError) Result.failure(Exception("Test error"))
else Result.success(items.toList())
}@HiltAndroidTest
class HomeViewModelTest {
@get:Rule(order = 0)
val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1)
val mainDispatcherRule = MainDispatcherRule()
@Inject
lateinit var repository: FakeItemRepository
private lateinit var viewModel: HomeViewModel
@Before
fun setUp() {
hiltRule.inject()
viewModel = HomeViewModel(repository)
}
@Test
fun `loading items emits success state`() = runTest {
repository.items = mutableListOf(Item("1", "Test Item"))
viewModel.load()
val state = viewModel.uiState.value
assertThat(state).isInstanceOf(HomeUiState.Success::class.java)
assertThat((state as HomeUiState.Success).items).hasSize(1)
}
@Test
fun `repository error emits error state`() = runTest {
repository.shouldThrowError = true
viewModel.load()
assertThat(viewModel.uiState.value).isInstanceOf(HomeUiState.Error::class.java)
}
}class MainDispatcherRule(
private val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()
) : TestWatcher() {
override fun starting(description: Description) {
Dispatchers.setMain(dispatcher)
}
override fun finished(description: Description) {
Dispatchers.resetMain()
dispatcher.cleanupTestCoroutines()
}
}Injecting into non-Hilt classes: Constructor injection only works on classes Hilt knows about. If you new up a class manually (
val x = MyClass()@AndroidEntryPointCircular dependencies: A → B → A. Hilt will fail at compile time. Break the cycle by extracting the shared logic into a third class that neither depends on the other.
Using field injection when constructor injection works: Field injection (
@Inject lateinit var x: X// Prefer this
class ItemRepository @Inject constructor(
private val dao: ItemDao,
private val api: ApiService
)
// Over this (only use for Activities/Fragments/etc.)
class ItemRepository {
@Inject lateinit var dao: ItemDao
@Inject lateinit var api: ApiService
}Across 22 apps, this module organization has been the most maintainable:
di/
├── NetworkModule.kt — OkHttpClient, Retrofit, ApiService
├── DatabaseModule.kt — RoomDatabase, DAOs
├── RepositoryModule.kt — @Binds repository implementations
└── UseCaseModule.kt — @Binds use case implementations (if needed)Each module in
SingletonComponentThe test equivalents live under
src/test/@TestInstallInSudarshan Chaudhari
AI Systems Builder / Product Engineer
Bangkok, Thailand
Solo Android developer with 13+ years in QA, building Android apps, AI automation systems, and developer tools at SudarshanTechLabs.
Related Posts
Related Apps
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
Building something? Available for Android dev and QA consulting.
Work with meComments — powered by Giscus
Real-time family location sharing — Firebase Realtime DB for sub-second propagation, WorkManager + ForegroundService for OS-compliant background collection, geofencing via Google Maps API.
ReadPrivate dream journal — structured entry capture, pattern tagging, and optional Claude-powered insight generation. All data stays on-device by default.
ReadWorkout tracker — exercise logging with set/rep/weight history, goal progression, and local Room DB persistence. No account, no cloud sync required.
Read