Skip to content
Commitwise
Menu
Android Development

Dependency injection with Hilt

Wire an Android app together with Hilt, using constructor injection, modules for third-party types and interfaces, scopes that match how long objects should live, @HiltViewModel for screens, and test replacements without changing production code.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The orders screen’s ViewModel creates its own repository, which creates its own Retrofit instance, which creates its own OkHttp client. So does the profile screen. Now the app has five HTTP clients, five connection pools, and no way to test a ViewModel without a real server. Every class knows how to build everything it uses.

Dependency injectionDependency injectionGiving an object the things it needs from outside, instead of letting it create them itself.Open in glossary → turns that around: classes receive what they need, and one place decides how things are built and shared. Hilt is Android’s standard DI library, built on Dagger. It generates that wiring at compile time, so a missing dependency is a build error, not a crash.

New to some of these? Open what you need, or skip ahead.

Basics:What is dependency injection?

A class’s dependencies are the other objects it needs to do its job: a repository needs an API client and a database. With dependency injection, the class doesn’t create them; it asks for them in its constructor:

class OrdersRepository(private val api: ShopApi, private val dao: OrderDao)

Someone else creates the objects and passes them in. In tests, that someone can pass a fake API instead of the real one. The Dependency injection pattern lesson explains the idea in depth.

Basics:What is a ViewModel?

A ViewModel holds a screen’s state and logic, separate from the UI code, and survives rotation. The ViewModel and unidirectional data flow tutorial covers it.

Part Not started

Constructor injection

A restaurant kitchen doesn’t make its own knives and pans; the restaurant supplies them. A cook just says “I need a pan” and gets one. With Hilt, a class says what it needs by listing it in its constructor and marking that constructor with @Inject. Hilt sees the whole list at compile time, figures out the order to create things in, and hands each class what it asked for.

Hilt is Dagger with a fixed set of Android components (SingletonComponent, ActivityRetainedComponent, ViewModelComponent, ActivityComponent, …) and generated entry points for framework classes. Constructor injection (@Inject constructor) is the default for your own types; Dagger generates factories and validates the full graph (missing bindings, cycles, scope mismatches) at build time, with no reflection at runtime. KSP processing is supported and much faster than kapt.

@HiltAndroidApp
class ShopApp : Application()                      // creates the app-wide container

@AndroidEntryPoint
class MainActivity : ComponentActivity() {         // lets Hilt inject into this activity and its screens
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { ShopNavHost() }
    }
}

class OrdersRepository @Inject constructor(       // Hilt knows how to build it
    private val api: ShopApi,
    private val dao: OrderDao,
)

@HiltViewModel
class OrdersViewModel @Inject constructor(
    private val repository: OrdersRepository,
    savedStateHandle: SavedStateHandle,            // provided by Hilt automatically
) : ViewModel() { /* … */ }

@Composable
fun OrdersScreen(viewModel: OrdersViewModel = hiltViewModel()) {
    val orders by viewModel.orders.collectAsStateWithLifecycle()
    OrdersList(orders)
}
  • @HiltAndroidApp on the Application class (registered in the manifest) starts Hilt.
  • @AndroidEntryPoint on each activity that uses injection.
  • @Inject constructor on your own classes: no extra code needed to tell Hilt how to build them.
  • @HiltViewModel + hiltViewModel() in Compose: Hilt creates the ViewModel with its dependencies, scoped to the screen’s back stack entry.
  • If something can’t be built (say, ShopApi isn’t provided anywhere yet), the build fails with a message naming the missing type.
  • @HiltAndroidApp, @AndroidEntryPoint, @HiltViewModel + hiltViewModel(); constructor injection for own types.
  • Graph validated at compile time (missing bindings, cycles, scope errors); no runtime reflection. Use KSP.
  • SavedStateHandle is injectable into @HiltViewModels; assisted injection (@AssistedInject + @HiltViewModel(assistedFactory = …)) for runtime arguments.

Check this part

  1. How does a class tell Hilt what it needs?

  2. When do you find out a dependency can't be created?

@HiltAndroidApp on the Application, @AndroidEntryPoint on activities, @HiltViewModel with an @Inject constructor on ViewModels, and @Inject constructor on your own classes. Hilt builds the graph at compile time and creates everything for you.

Part Not started

Modules and scopes

You can’t add @Inject to Retrofit’s constructor; you didn’t write Retrofit. For such types, you write a module: a class with functions that say “here’s how to make one”. And when a class asks for an interface (ShopApi, Clock), a module tells Hilt which implementation to use.

A scope says how long an object lives. Without one, Hilt creates a new object every time someone asks. With @Singleton, there’s one for the whole app, shared by everyone.

@Provides methods construct types you don’t own; @Binds (abstract, cheaper) maps an interface to an injectable implementation. @InstallIn chooses the component, which defines both visibility and the maximum lifetime of scoped bindings in it: @Singleton in SingletonComponent, @ActivityRetainedScoped, @ViewModelScoped, @ActivityScoped. Unscoped is the right default for cheap, stateless objects; scope only what must be shared (clients, databases, caches, in-memory state), and qualifiers (@Named or custom @Qualifiers) distinguish multiple bindings of the same type.

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides @Singleton
    fun okHttp(tokens: TokenStore): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor(tokens))
        .callTimeout(30, TimeUnit.SECONDS)
        .build()

    @Provides @Singleton
    fun shopApi(client: OkHttpClient, json: Json): ShopApi = Retrofit.Builder()
        .baseUrl(BuildConfig.API_URL)
        .client(client)
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()
        .create(ShopApi::class.java)

    @Provides @Singleton
    fun json(): Json = Json { ignoreUnknownKeys = true }
}

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    @Provides @Singleton
    fun database(@ApplicationContext context: Context): AppDatabase =
        Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()

    @Provides
    fun orderDao(db: AppDatabase): OrderDao = db.orders()      // cheap, no scope needed
}

interface Clock { fun now(): Instant }
class SystemClock @Inject constructor() : Clock { override fun now(): Instant = Instant.now() }

@Module
@InstallIn(SingletonComponent::class)
abstract class ClockModule {
    @Binds
    abstract fun clock(impl: SystemClock): Clock              // "when someone asks for Clock, use SystemClock"
}
  • @Provides functions build things you don’t own. Their parameters are injected too (okHttp needs a TokenStore, shopApi needs the client).
  • @Binds connects an interface to its implementation. In tests, a different module can bind Clock to a fake clock.
  • @ApplicationContext gives you the application’s Context, which lives as long as the app. Never keep an Activity’s context in a singleton.
  • @Singleton on the client and database: exactly one of each in the whole app.
Spot the bugFive HTTP clients

Tap the line with the bug.

@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
    @Provides
    fun okHttp(tokens: TokenStore): OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(AuthInterceptor(tokens))
        .cache(Cache(File("/data/data/com.shop/cache/http"), 50L * 1024 * 1024))
        .build()

    @Provides
    fun shopApi(client: OkHttpClient): ShopApi = buildRetrofit(client).create(ShopApi::class.java)
}

Predict the output

CartRepository is annotated @Singleton and has an @Inject constructor. CartViewModel and CheckoutViewModel both inject it. CartViewModel adds an item to its in-memory list. Does CheckoutViewModel see it?

Exerciseeasy

Swap the clock in tests

A CouponValidator checks whether a coupon is expired, using Instant.now() directly, so tests depend on the real date. Change it so tests can control time with Hilt, using the Clock interface above.

›

Inject Clock through the constructor. In an instrumented test, replace ClockModule with a test module that binds a fake.

SolutionTry it yourself first
class CouponValidator @Inject constructor(private val clock: Clock) {
    fun isValid(coupon: Coupon): Boolean = clock.now() < coupon.expiresAt
}

// In a unit test, no Hilt needed at all:
class FixedClock(var instant: Instant) : Clock { override fun now() = instant }

@Test
fun expiredCouponIsRejected() {
    val clock = FixedClock(Instant.parse("2026-12-01T00:00:00Z"))
    val validator = CouponValidator(clock)
    assertFalse(validator.isValid(Coupon("XMAS", expiresAt = Instant.parse("2026-11-30T23:59:59Z"))))
}

Constructor injection makes the class testable without any framework: a unit test just calls the constructor. Hilt only matters for instrumented tests of whole screens, covered in the next part.

Choosing components and scopes

NeedInstall in / scope
HTTP client, database, app-wide cachesSingletonComponent, @Singleton
State shared by all ViewModels of one activity, surviving rotationActivityRetainedComponent, @ActivityRetainedScoped
A helper shared by one ViewModel’s collaboratorsViewModelComponent, @ViewModelScoped
Something that needs the Activity (permission launcher, window)ActivityComponent, @ActivityScoped
Cheap, stateless mappers and use casesunscoped

Scoping isn’t free: a scoped binding is created behind a double-checked lock and lives as long as its component. @Reusable allows caching without the guarantee of a single instance, for stateless objects that are costly to create.

  • @Provides for foreign types, @Binds for interface → implementation; qualifiers for multiple bindings of one type.
  • Component = visibility + lifetime. @Singleton for clients/DB/caches; @ActivityRetainedScoped, @ViewModelScoped, @ActivityScoped as needed; unscoped by default for stateless types; @Reusable for costly stateless ones.
  • Unscoped providers of stateful objects silently multiply them (clients, caches on one directory).
  • Singletons outlive users: clear or rebuild user-scoped state on logout.

Check this part

  1. What happens with a @Provides function that has no scope?

  2. When do you use @Binds instead of @Provides?

  3. Which scope keeps an object shared across all ViewModels of an activity and alive across rotation?

Types you don't own (Retrofit, Room, OkHttp) are created in @Provides functions inside a @Module; interfaces are mapped to implementations with @Binds. Scopes like @Singleton decide how many instances exist; unscoped bindings create a new instance every time.

Part Not started

Testing with Hilt

The best part of dependency injection shows up in tests. A ViewModel that receives its repository can be tested with a fake repository: no server, no database, no Hilt, just a constructor call. For tests that run the real app on a device, Hilt can swap whole modules, so the app talks to a fake server instead of the real one.

Unit tests construct classes directly with test doublesTest doubleA stand-in for a real dependency in a test, such as a fake payment gateway, so the test is fast, repeatable and safe.Open in glossary →; Hilt adds nothing there. Instrumented and Robolectric tests use @HiltAndroidTest, HiltAndroidRule and a custom test runner using HiltTestApplication. @TestInstallIn(components = …, replaces = [NetworkModule::class]) replaces a module for the whole test source set; @UninstallModules plus nested @Modules or @BindValue fields override per test class, at the cost of a separate generated component per class (slower builds).

// androidTest: every test uses a local MockWebServer instead of the real API
@Module
@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class])
object FakeNetworkModule {
    @Provides @Singleton
    fun server(): MockWebServer = MockWebServer().apply { start() }

    @Provides @Singleton
    fun shopApi(server: MockWebServer, json: Json): ShopApi = Retrofit.Builder()
        .baseUrl(server.url("/"))
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()
        .create(ShopApi::class.java)

    @Provides @Singleton
    fun json(): Json = Json { ignoreUnknownKeys = true }
}

@HiltAndroidTest
class OrdersScreenTest {
    @get:Rule(order = 0) val hilt = HiltAndroidRule(this)
    @get:Rule(order = 1) val compose = createAndroidComposeRule<MainActivity>()

    @Inject lateinit var server: MockWebServer

    @Before fun setUp() = hilt.inject()

    @Test
    fun showsOrdersFromTheServer() {
        server.enqueue(MockResponse().setBody("""[{"id":1,"total_cents":4999,"status":"SHIPPED"}]"""))
        compose.onNodeWithText("Shipped").assertIsDisplayed()
    }
}
  • @TestInstallIn replaces NetworkModule in all instrumented tests; production code doesn’t change at all.
  • MockWebServer is a tiny HTTP server inside the test, so you decide exactly what the “backend” answers, including errors.
  • hilt.inject() fills the test’s own @Inject fields, like the server.
  • For ViewModel unit tests, skip Hilt entirely: OrdersViewModel(FakeOrdersRepository(), SavedStateHandle()).

Production puzzles

Predict the output

An activity declares @Inject lateinit var analytics: Analytics and calls analytics.screenView(HOME) in onCreate before super.onCreate(savedInstanceState). What happens?

Spot the bugThe previous user's orders

Tap the line with the bug.

@Singleton
class OrdersCache @Inject constructor() {
    private val orders = MutableStateFlow<List<Order>>(emptyList())
    fun observe(): StateFlow<List<Order>> = orders
    fun put(list: List<Order>) { orders.value = list }
}

class LogoutUseCase @Inject constructor(private val tokens: TokenStore) {
    suspend operator fun invoke() = tokens.clear()
}

  • Unit tests: constructors and fakes, no Hilt. Instrumented/Robolectric: @HiltAndroidTest, HiltAndroidRule, HiltTestApplication runner.
  • @TestInstallIn for suite-wide replacements (cheap); @UninstallModules/@BindValue per class (separate components, slower builds).
  • Field injection happens in super.onCreate(); prefer constructor injection in ViewModels.
  • App-scoped singletons outlive sessions: multibound UserScopedState cleared on logout, or a custom session component.

Check this part

  1. How do you unit-test a @HiltViewModel?

  2. What does @TestInstallIn do?

Constructor injection makes unit tests framework-free: pass fakes to the constructor. For instrumented tests of whole screens, @HiltAndroidTest with @TestInstallIn or @UninstallModules swaps production modules for fakes.

Key takeaways

  • Classes ask for what they need in an @Inject constructor; Hilt builds and connects them.
  • Modules provide types you don’t own (@Provides) and choose implementations for interfaces (@Binds).
  • @Singleton means one shared instance; unscoped means a new one every time.
  • Unit tests just call constructors with fakes; UI tests swap modules with @TestInstallIn.
  • Compile-time graph validation; constructor injection everywhere you can.
  • Pick components by lifetime; scope only shared, stateful or expensive objects.
  • @TestInstallIn for suite-wide fakes; avoid per-class module overrides.
  • Reset user-scoped singletons on logout, or model a session component.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.