Android with Kotlin and Jetpack Compose · Production: Dependency Injection, Background Work, Testing and Release
Dependency Injection with Hilt
Why dependency injection matters for testable apps, and how Hilt provides repositories, API clients and databases to ViewModels with a few annotations.
What you'll build
The wiring for the app: one Retrofit client, one Room database and one repository shared across screens, injected into ViewModels, and swapped for fakes in tests.
The idea
Dependency injection means a class receives what it needs (its dependencies) through its constructor instead of creating them. The benefits: one shared instance where you want one (one database, one HTTP client), and easy replacement with fakes in tests.
Hilt is Android's recommended DI library, built on Dagger. It generates the wiring at compile time:
@HiltAndroidAppon theApplicationclass creates the app-wide container.@AndroidEntryPointon activities lets Hilt inject them.@HiltViewModelplus an@Inject constructormakes ViewModels injectable;hiltViewModel()gets one in Compose.@Module+@InstallIn(SingletonComponent::class)+@Providestells Hilt how to build types you don't own (Retrofit, Room).@Bindsmaps an interface to its implementation.
The code
@HiltAndroidApp
class ShopApp : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* setContent { ShopNavigation() } */ }
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
@Provides @Singleton
fun database(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "shop.db").build()
@Provides
fun noteDao(db: AppDatabase): NoteDao = db.noteDao()
@Provides @Singleton
fun api(): GitHubApi = Retrofit.Builder()
.baseUrl("https://api.github.com/")
.addConverterFactory(Json { ignoreUnknownKeys = true }.asConverterFactory("application/json".toMediaType()))
.build().create(GitHubApi::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds abstract fun repoRepository(impl: NetworkRepoRepository): RepoRepository
}
@HiltViewModel
class RepoViewModel @Inject constructor(private val repository: RepoRepository) : ViewModel() { /* ... */ }
@Composable
fun RepoRoute(viewModel: RepoViewModel = hiltViewModel()) { /* ... */ }
Dependencies: the com.google.dagger.hilt.android Gradle plugin and com.google.dagger:hilt-android:2.60.1 with ksp("com.google.dagger:hilt-compiler:2.60.1"), plus androidx.hilt:hilt-lifecycle-viewmodel-compose for hiltViewModel().
Try the logic
Hilt generates a container like the one below. Writing it by hand shows what DI gives you: shared instances, and a test that swaps the real API for a fake:
interface RepoRepository { suspend fun topRepos(): List<String> }
class NetworkRepoRepository(private val baseUrl: String) : RepoRepository {
override suspend fun topRepos() = listOf("(would call $baseUrl)")
}
class FakeRepoRepository(private val repos: List<String>) : RepoRepository {
var calls = 0
override suspend fun topRepos(): List<String> { calls++; return repos }
}
class RepoViewModel(private val repository: RepoRepository) {
suspend fun title() = repository.topRepos().let { "Repositories (${it.size}): ${it.joinToString()}" }
}
class AppContainer { // what Hilt's SingletonComponent does for you
val repository: RepoRepository by lazy { NetworkRepoRepository("https://api.github.com/") }
fun repoViewModel() = RepoViewModel(repository)
}
suspend fun main() {
val app = AppContainer()
println(app.repoViewModel().title())
println("same repository for every screen: ${app.repository === app.repository}")
val fake = FakeRepoRepository(listOf("nowinandroid", "compose-samples"))
val underTest = RepoViewModel(fake) // in a test: no network, no Android
println(underTest.title())
println("fake called ${fake.calls} time(s)")
}
Output
Repositories (1): (would call https://api.github.com/) same repository for every screen: true Repositories (2): nowinandroid, compose-samples fake called 1 time(s)
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Creating dependencies inside classes.
val api = Retrofit.Builder()...inside a ViewModel makes it untestable and creates duplicates. - Making everything a singleton. Scope only what must be shared (database, HTTP client); unscoped types are cheaper and simpler.
- Injecting an Activity
Contextinto long-lived objects. Use@ApplicationContextto avoid leaks. - Field injection everywhere. Prefer constructor injection; it makes dependencies explicit.
Key points
- Dependency injection passes dependencies in through constructors.
- Hilt generates the wiring:
@HiltAndroidApp,@AndroidEntryPoint,@HiltViewModel, modules with@Providesand@Binds. - Scope shared resources as
@Singleton; keep the rest unscoped. - DI is what makes ViewModels testable with fakes.
Further reading: Dependency injection with Hilt, Hilt and Jetpack integrations.
Practice questions
What's the difference between @Provides and @Binds in Hilt?
Answer
@Provides is a function that builds an instance, used for types you don't own or that need configuration (Retrofit, Room). @Binds is an abstract function that tells Hilt to use an implementation when an interface is requested; it generates less code.
Why is constructor injection preferred for ViewModels and repositories?
Answer
Dependencies are explicit in the constructor, the class can't exist without them, and tests can pass fakes directly without any framework. Hilt can still build the class automatically from its @Inject constructor.
Open in the app to mark lessons complete, take notes and track your progress.