Hilt Basics Explained
ARCHITECTURE › Dependency Injection
Dependency injection means a class receives its dependencies from the outside instead of constructing them itself. That decoupling is what makes classes easy to test (swap in a fake) and easy to reuse (swap in a different implementation) without touching their code.
Hilt is Android's standard DI framework. It doesn't invent its own DI engine, **it's a thin, opinionated layer on top of Dagger** that removes Dagger's boilerplate for Android: it defines a component for every Android class (Application, Activity, Fragment, ViewModel, ...), wires them into a hierarchy, and generates the graph at build time.
Every annotation you'll meet in this lesson answers one of two questions Hilt needs answered: *how do I build this type*, and *how long should the built instance live*.
**Two annotations turn Hilt on.** @HiltAndroidApp goes on your Application subclass, exactly once, it's what triggers Hilt's code generation and creates the app-level container (SingletonComponent) that every other component descends from:
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint goes on the Android classes that want field injection: Activity, Fragment, View, Service, BroadcastReceiver:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject lateinit var repo: UserRepository
}
One rule trips people up in interviews: a @AndroidEntryPoint Fragment can only be hosted by an Activity that is *also* @AndroidEntryPoint, and injected fields must not be private, Hilt assigns them directly.
Hilt's default way to learn how to build a type is **constructor injection**: annotate the constructor with @Inject and Hilt can construct it and supply its dependencies automatically, no module needed:
class UserRepository @Inject constructor(
private val api: UserApi,
private val dao: UserDao
)
Hilt reads the constructor, sees it needs a UserApi and a UserDao, and recursively looks for how to build *those* the same way. This only works when you **own the class** and can put an annotation on its constructor. The moment you need to provide an interface, a third-party class like Retrofit, or anything built with a builder, @Inject on a constructor is off the table, that's the next chunk.
For everything you *can't* constructor-inject, interfaces, third-party classes like Retrofit or OkHttpClient, types needing builder logic, you write a @Module with @InstallIn (which component hosts it) and one of two kinds of method.
@Provides is a concrete method whose body constructs the instance:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
fun provideRetrofit(): Retrofit = Retrofit.Builder().build()
}
@Binds is leaner: an *abstract* method that just maps an interface to an @Inject-annotated implementation you own, no body, no object construction, so Hilt generates less code:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepoModule {
@Binds
abstract fun bindRepository(impl: RepositoryImpl): Repository
}
Sometimes two different bindings share the same type, say two OkHttpClient instances, one with auth, one without. Hilt can't tell them apart by type alone, so you define a @Qualifier annotation and apply it to **both** the binding and the injection site:
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class AuthClient
@AuthClient @Provides
fun provideAuthClient(): OkHttpClient = OkHttpClient.Builder().build()
class ApiService @Inject constructor(
@AuthClient val client: OkHttpClient
)
Hilt ships two qualifiers out of the box for Context: @ApplicationContext (lives for the app) and @ActivityContext (tied to one Activity). Reaching for the wrong one is a classic scoping mistake, more on that in the scopes lesson.
Every annotation so far tells Hilt *how* to build a type; a scope annotation tells it *how long to keep the result*. Leave a binding unscoped (the default) and Hilt builds a fresh instance on every single injection. Name a scope and Hilt caches one instance for that scope's component instead:
@Singleton // one instance, whole app process
class AnalyticsClient @Inject constructor()
@ActivityRetainedScoped // one instance, survives rotation
class FlowCoordinator @Inject constructor()
@ViewModelScoped // one instance, this ViewModel's lifetime
class CartRepository @Inject constructor(private val api: CartApi)
@ActivityScoped and @FragmentScoped exist too, tied to ActivityComponent and FragmentComponent, both rebuilt on every rotation unlike their retained cousin. The full component tree, its visibility rules, and the classic Context-leak pitfalls each scope invites get their own lesson: di-scopes.
ViewModels get their own annotation: @HiltViewModel plus an @Inject constructor, and Hilt automatically supplies a SavedStateHandle if you ask for one:
@HiltViewModel
class CartViewModel @Inject constructor(
private val repo: CartRepository,
private val savedState: SavedStateHandle
) : ViewModel()
In a classic Activity or Fragment, you retrieve it with the by viewModels() delegate. In Compose, the equivalent is the hiltViewModel() composable function, it looks up (or creates) the ViewModel scoped to the current navigation destination:
@Composable
fun CartScreen(viewModel: CartViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsState()
}
Plain viewModel() (without the Hilt prefix) does not know about Hilt's graph at all.
Everything above is really just a friendlier syntax for Dagger. At build time, Hilt generates **one Dagger component per supported Android class** (SingletonComponent for Application, ActivityComponent for each Activity, and so on), wires them into a parent-child hierarchy, and validates the whole graph.
That validation is the real payoff: if a dependency is missing, or two dependencies need each other in a cycle Dagger can't resolve, **the build fails**, you find out at compile time, not when a user hits that code path in production. Modern Hilt or Dagger builds run this as a KSP annotation-processing step (the faster replacement for the older kapt), not reflection, so nothing about it happens at runtime.
One gap: @AndroidEntryPoint only supports a fixed list of Android classes (Activity, Fragment, View, Service, BroadcastReceiver). Anything outside that list, most notably ContentProvider, can't use field injection directly.
The escape hatch is @EntryPoint: define an interface listing the bindings you need, install it in the right component, and pull them out manually with EntryPointAccessors:
@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyEntryPoint {
fun repository(): MyRepository
}
val repo = EntryPointAccessors.fromApplication(
context, MyEntryPoint::class.java
).repository()
That's the whole model: @HiltAndroidApp bootstraps the graph, @Inject or @Module teach Hilt how to build things, qualifiers disambiguate, and @EntryPoint reaches the graph from anywhere else. Every annotation maps to a real piece of the generated Dagger graph, nothing here is magic.
Hilt tests run against a special HiltTestApplication, wired up by HiltAndroidRule in every @HiltAndroidTest class, so the graph exists exactly like production, just swappable. @UninstallModules removes a real module for one test class, and @BindValue hands Hilt a fake instance directly, no module boilerplate:
@UninstallModules(NetworkModule::class)
@HiltAndroidTest
class LoginTest {
@BindValue @JvmField val api: LoginApi = FakeLoginApi()
@get:Rule val hiltRule = HiltAndroidRule(this)
}
When a fake should apply everywhere, not just one test class, @TestInstallIn replaces a production module across the whole test source set:
@TestInstallIn(components = [SingletonComponent::class], replaces = [NetworkModule::class])
@Module
object FakeNetworkModule {
@Provides fun provideApi(): LoginApi = FakeLoginApi()
}
Reach for @BindValue for a single test's fake, @TestInstallIn for one every test should share.
WorkManager constructs its own Worker instances, off the path Hilt normally injects through, so plain @AndroidEntryPoint doesn't apply here. The fix combines two things you've already met: @HiltWorker marks the class, and @AssistedInject/@Assisted split the constructor between what Hilt supplies and what WorkManager supplies at runtime, Context and WorkerParameters:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repo: SyncRepository
) : CoroutineWorker(appContext, params)
One more step wires it up: your Application implements Configuration.Provider, injects a HiltWorkerFactory, and hands it to WorkManager's configuration, so every @HiltWorker gets built through Hilt instead of WorkManager's default reflection-based factory.