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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

  • @HiltAndroidApp on the Application class creates the app-wide container.
  • @AndroidEntryPoint on activities lets Hilt inject them.
  • @HiltViewModel plus an @Inject constructor makes ViewModels injectable; hiltViewModel() gets one in Compose.
  • @Module + @InstallIn(SingletonComponent::class) + @Provides tells Hilt how to build types you don't own (Retrofit, Room).
  • @Binds maps 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:

Constructor injection and fakes
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 Context into long-lived objects. Use @ApplicationContext to 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 @Provides and @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.

Was this page helpful?