Dependency Injection in Android — A Practical Guide to Hilt
How to remove boilerplate, decouple your classes, and build testable Android apps

Dependency Injection in Android — A Practical Guide to Hilt
How to remove boilerplate, decouple your classes, and build testable Android apps
Every Android developer eventually hits the same wall. A ViewModel needs a Repository. The Repository needs an API service and a local database. The API service needs a configured Retrofit instance, which needs an OkHttpClient, which needs a logging interceptor and an auth token provider. Multiply that by every screen in your app, and you end up with a web of manually constructed objects that is fragile, repetitive, and painful to test.
This is the exact problem Dependency Injection was designed to solve, and it is why frameworks like Hilt exist. In this article, we will walk through what Dependency Injection actually means, why manual DI does not scale, and how Hilt lets you wire your entire Android app with a handful of annotations instead of hundreds of lines of factory code. By the end, you will have a clear mental model of Hilt and a working example you can adapt to your own projects.
What Dependency Injection Actually Means
Dependency Injection, or DI, is a design pattern where a class receives the objects it depends on from an external source instead of creating them itself. Those external objects are its dependencies, and the class it depends on them for is, unsurprisingly, the dependent.
Consider a simple example without DI:
class UserRepository {
private val apiService = ApiService()
private val database = AppDatabase()
fun getUser(id: String) = apiService.fetchUser(id)
}
UserRepository is responsible for two things it should not care about: how to build an ApiService and how to build an AppDatabase. If either constructor changes, or if you want to swap the real ApiService for a fake one during testing, you have to modify UserRepository itself. The class is tightly coupled to its dependencies.
Now compare that to the same class using constructor injection:
class UserRepository(
private val apiService: ApiService,
private val database: AppDatabase
) {
fun getUser(id: String) = apiService.fetchUser(id)
}
UserRepository no longer knows how to build an ApiService or an AppDatabase. It simply declares that it needs them, and something else is responsible for providing them. This single change makes the class easier to read, easier to reuse, and trivial to test, since you can pass in mocked or fake implementations during unit tests.
The Problem With Manual Dependency Injection
Constructor injection is a great pattern in isolation, but someone still has to build the full object graph. In a small app, you might do this by hand in a single place, often called a ServiceLocator or a manually written AppContainer:
class AppContainer {
private val okHttpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
val apiService: ApiService = retrofit.create(ApiService::class.java)
val database: AppDatabase = Room.databaseBuilder(
context, AppDatabase::class.java, "app.db"
).build()
val userRepository = UserRepository(apiService, database)
}
This works for a while, but it does not scale correctly. As your app grows, you have to manage object lifetimes yourself, decide which instances should be singletons versus scoped to a single screen, and manually thread dependencies through every Activity, Fragment, and ViewModel that needs them. On larger codebases, this container becomes a bottleneck that every feature team has to touch, and subtle bugs creep in around object lifecycles and memory leaks.
This is exactly the gap Hilt is built to close.
Why Hilt
Hilt is Google’s recommended Dependency Injection library for Android. It is built on top of Dagger, which handles the underlying compile-time dependency graph, but it removes most of Dagger’s boilerplate by defining standard components that map directly to Android’s own lifecycle: Application, Activity, Fragment, ViewModel, and more.
Because the dependency graph is generated at compile time rather than resolved through reflection at runtime, Hilt catches missing or misconfigured dependencies as build errors instead of runtime crashes. It also integrates natively with Jetpack libraries, including ViewModel, WorkManager, and Navigation, which means far less custom wiring than plain Dagger requires.
Setting Up Hilt
Start by adding the Hilt Gradle plugin and dependencies to your project.
In your project level build.gradle.kts:
plugins {
id("com.google.dagger.hilt.android") version "2.51.1" apply false
}
In your module level build.gradle.kts:
plugins {
id("com.google.dagger.hilt.android")
kotlin("kapt")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-android-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}
Next, annotate your Application class with @HiltAndroidApp. This triggers Hilt's code generation and creates the top level dependency container that the rest of the app hangs off of.
@HiltAndroidApp
class MyApplication : Application()
Do not forget to register this class in your AndroidManifest.xml:
<application
android:name=".MyApplication"
...>
</application>
Providing Dependencies With Modules
Hilt needs to know how to build the objects you request. For classes you own, like UserRepository, you can usually annotate the constructor directly with @Inject, and Hilt figures out how to build it automatically as long as it also knows how to build its dependencies.
class UserRepository @Inject constructor(
private val apiService: ApiService,
private val database: AppDatabase
) {
suspend fun getUser(id: String) = apiService.fetchUser(id)
}
For classes you do not own, such as Retrofit, OkHttpClient, or a Room database, you need to tell Hilt explicitly how to construct them. This is done through a Hilt module, which is an abstract class or object annotated with @Module and @InstallIn, the latter specifying which Hilt component the bindings belong to.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(AuthInterceptor())
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideApiService(retrofit: Retrofit): ApiService {
return retrofit.create(ApiService::class.java)
}
}
@InstallIn(SingleComponent::class) tells Hilt that these bindings live in the application level container, so they are created once and reused for the entire lifetime of the app. Hilt exposes several components tied to Android lifecycle owners, including ActivityComponent, FragmentComponent, and ViewModelComponent, each with a matching scope annotation such as @ActivityScoped or @ViewModelScoped.
For the local database, a similar module works well:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
return Room.databaseBuilder(
context,
AppDatabase::class.java,
"app.db"
).build()
}
}
Notice the @ApplicationContext qualifier. Hilt provides the application Context out of the box, which avoids the common mistake of leaking an Activity context into a long lived singleton.
Injecting Into Android Classes
Once your bindings are in place, injecting dependencies into an Activity, Fragment, or ViewModel requires very little code. Annotate the class with @AndroidEntryPoint, and Hilt handles the rest.
@AndroidEntryPoint
class UserProfileActivity : AppCompatActivity() {
@Inject
lateinit var userRepository: UserRepository
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// userRepository is already initialized here
}
}
ViewModels get their own annotation, @HiltViewModel, which works together with @AndroidEntryPoint on the hosting Activity or Fragment and with the hiltViewModel() composable function when you are working in Jetpack Compose.
@HiltViewModel
class UserProfileViewModel @Inject constructor(
private val userRepository: UserRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<UserUiState>(UserUiState.Loading)
val uiState: StateFlow<UserUiState> = _uiState.asStateFlow()
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = try {
UserUiState.Success(userRepository.getUser(id))
} catch (e: IOException) {
UserUiState.Error(e.message)
}
}
}
}
In a Compose screen, retrieving this ViewModel is a single line, with no manual factory required:
@Composable
fun UserProfileScreen(
viewModel: UserProfileViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// render uiState
}
Compare this to the manual container from earlier. There is no AppContainer to thread through every screen, no factory classes to hand write, and no risk of forgetting to pass a dependency down through three layers of constructors. Hilt generates all of that wiring for you at compile time.
Binding Interfaces to Implementations
A common pattern is depending on an interface rather than a concrete class, which keeps your code decoupled and makes swapping implementations for testing straightforward. Hilt supports this through @Binds, used inside an abstract module.
interface UserRepository {
suspend fun getUser(id: String): User
}
class UserRepositoryImpl @Inject constructor(
private val apiService: ApiService
) : UserRepository {
override suspend fun getUser(id: String) = apiService.fetchUser(id)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
abstract fun bindUserRepository(
impl: UserRepositoryImpl
): UserRepository
}
Anywhere you now inject UserRepository, Hilt automatically supplies UserRepositoryImpl. In tests, you can create a separate test module that binds a fake implementation instead, without touching any production code.
Why This Matters for Testing
This is where Dependency Injection pays for itself. Because your classes depend on abstractions supplied from the outside, you can substitute real implementations with fakes or mocks in unit tests without any framework involvement at all.
class FakeUserRepository : UserRepository {
override suspend fun getUser(id: String) = User(id, "Test User")
}
class UserProfileViewModelTest {
@Test
fun `loadUser updates state to Success`() = runTest {
val viewModel = UserProfileViewModel(FakeUserRepository())
viewModel.loadUser("123")
assertTrue(viewModel.uiState.value is UserUiState.Success)
}
}
No Hilt annotations are needed in this test at all, because UserProfileViewModel only depends on the UserRepository interface. For instrumented tests where you do want Hilt to manage the graph, the library ships hilt-android-testing, which provides @HiltAndroidTest and a custom test runner so you can swap in test modules while still exercising the real Android lifecycle.
Common Mistakes
A few mistakes come up repeatedly when teams adopt Hilt:
- Injecting an Activity context into a class scoped to SingletonComponent causes memory leaks, since the singleton will outlive the Activity. Always use @ApplicationContext for anything living at the application scope.
- Forgetting @InstallIn on a module produces a compile error, but forgetting the correct component, such as installing an Activity scoped binding into SingletonComponent, produces a working build with the wrong lifetime, which is harder to catch.
- Overusing @Singleton for objects that do not need to survive the whole app lifecycle wastes memory and can cause stale data to persist longer than expected. Reach for @ActivityScoped or @ViewModelScoped when the object's lifetime should match a narrower scope.
Conclusion
Dependency Injection is not about adding a library to your project. It is a discipline, classes declare what they need instead of constructing it themselves, which makes your codebase more modular, easier to reason about, and far easier to test. Hilt takes that discipline and removes almost all of the ceremony required to apply it consistently across an Android app, from a single Activity up to a codebase with hundreds of screens.
Once your modules and entry points are set up, adding a new dependency becomes as simple as adding a parameter to a constructor. That small shift, repeated across every class in your app, is what separates a codebase that scales cleanly from one that collapses under its own wiring. If you are still manually constructing your Repositories and ViewModels, migrating to Hilt is one of the highest leverage changes you can make to your Android architecture.
Thanks for reading!
메타데이터
- post_id
- a7930830a2a8
- slug
- dependency-injection-in-android-a-practical-guide-to-hilt-a7930830a2a8
- url
- https://medium.com/@aleyvaschz/dependency-injection-in-android-a-practical-guide-to-hilt-a7930830a2a8
- canonical_url
- https://medium.com/@aleyvaschz/dependency-injection-in-android-a-practical-guide-to-hilt-a7930830a2a8
- author_url
- https://medium.com/@aleyvaschz
- status
- ok
- fetched_at
- 2026-08-19 03:19:33