Hilt Basics Explained
ARCHITECTURE › Dependency Injection
Dependency injection means a class receives its dependencies from outside instead of constructing them itself. That single design choice is what makes classes easy to test, because you can swap in a fake dependency instead of a real one, and easy to reuse, because swapping implementations doesn't require touching the class's own code.
Hilt is Android's standard DI framework, but it does not invent its own DI engine. It's a thin, opinionated layer on top of Dagger that removes Dagger's Android boilerplate: it defines a matching component for Application, Activity, Fragment, ViewModel, and the rest, wires those components into a parent-child hierarchy, and generates the whole graph at build time. Every annotation you meet in Hilt answers one of two questions: how do I build this type, and how long should the built instance live.
Two annotations turn Hilt on for an app, and the first is @HiltAndroidApp. It goes on your Application subclass exactly once, and it's what triggers Hilt's code generation and creates the app-level container, the SingletonComponent, that every other Hilt component in the app descends from. Without it, none of Hilt's other annotations have a graph to plug into. This is the annotation an interviewer expects you to name first when asked how to set Hilt up in a new project.
@HiltAndroidApp
class MyApp : Application()
The second setup annotation is @AndroidEntryPoint, and it goes on the Android framework classes that want field injection: Activity, Fragment, View, Service, and BroadcastReceiver. Hilt performs the injection by assigning directly to the field after the class is constructed, which is why the field cannot be private. If you mark an @Inject field private, the generated injection code has no way to reach it, and that's a compile-time failure, not a runtime crash. This is a classic Hilt gotcha interviewers like to probe, because it looks like a small style choice but it actually breaks codegen.
@AndroidEntryPoint
class MyFragment : Fragment() {
@Inject private lateinit var repo: Repository
}
There's a second rule bound up with @AndroidEntryPoint that catches people out: a Fragment annotated with @AndroidEntryPoint can only be hosted by an Activity that is also annotated with @AndroidEntryPoint. Hilt builds a component hierarchy where the FragmentComponent is a child of that specific Activity's ActivityComponent, so if the host Activity never opted into Hilt, there's no parent component for the Fragment's injection to attach to, and the app crashes at runtime when the Fragment tries to inject. The fix is simple once you know it: every Activity that ever hosts a Hilt Fragment needs @AndroidEntryPoint too, even if that Activity itself injects nothing.
@AndroidEntryPoint
class MainActivity : AppCompatActivity()
@AndroidEntryPoint
class HomeFragment : Fragment() {
@Inject lateinit var repo: HomeRepository
}
With setup done, Hilt needs to know how to actually build each type it injects. The default and simplest way is constructor injection: annotate a class's constructor with @Inject, and Hilt can construct it and supply its dependencies automatically, no module required. Hilt reads the constructor, sees what types it needs, and recursively works out how to build those the same way. This only works when you own the class and can put an annotation directly on its constructor. The moment you need to provide an interface, a third-party class like Retrofit, or anything that requires builder logic, @Inject on a constructor is off the table, and that's exactly the gap @Module exists to fill.
class UserRepository @Inject constructor(
private val api: UserApi,
private val dao: UserDao
)
For everything you cannot constructor-inject, interfaces, third-party classes, anything needing builder logic, you write a @Module and expose the binding one of two ways. @Provides is a concrete method whose body actually constructs the instance, and it's what you reach for when you don't own the class or need custom construction logic. @Binds is leaner: an abstract method with no body that just maps an interface to an @Inject-annotated implementation you already own. Because @Binds has no method body to run, Hilt generates less code for it than for an equivalent @Provides. The rule interviewers want to hear: if you own the implementation and it already has an @Inject constructor, prefer @Binds; reach for @Provides only when you actually need to construct the instance yourself.
@Module
@InstallIn(SingletonComponent::class)
abstract class RepoModule {
@Binds
abstract fun bindRepository(impl: RepositoryImpl): Repository
}
Every Hilt module needs an @InstallIn annotation, and it's not optional decoration, it's what tells Hilt which generated component hosts the module's bindings. Installing a module @InstallIn(SingletonComponent::class) means those bindings live in the app-level container and are available anywhere; installing it in ActivityComponent means the bindings only exist while that Activity is alive, and only injection points within that Activity's subgraph can see them. The component you pick also determines the lifetime a binding is eligible to be scoped to, you cannot mark a binding @ActivityScoped if its module is installed in SingletonComponent. So @InstallIn is really answering two questions at once: where can this binding be used, and what's the longest it's allowed to live.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides @Singleton
fun provideDatabase(app: Application): AppDatabase =
Room.databaseBuilder(app, AppDatabase::class.java, "app.db").build()
}
Sometimes two different bindings share the same type. A common case is needing two OkHttpClient instances, one that attaches an auth header and one that doesn't. Hilt can't tell them apart by type alone, so you define a custom @Qualifier annotation and apply it to both the binding and the injection site that wants that specific one. Get the qualifier on only one side and Hilt can't resolve the injection correctly, so it always has to go on both. Hilt also ships two built-in qualifiers for Context: @ApplicationContext, which lives for the whole app, and @ActivityContext, tied to one Activity. Reaching for the wrong one of those two is its own classic scoping mistake.
@Qualifier @Retention(AnnotationRetention.BINARY)
annotation class AuthClient
@AuthClient @Provides
fun provideAuthClient(): OkHttpClient = OkHttpClient.Builder().build()
class ApiService @Inject constructor(@AuthClient val client: OkHttpClient)
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 constructs a brand new instance at every single injection point that asks for it. Name a scope on a binding and Hilt instead caches one instance for the lifetime of that scope's component, handing the same object to every injection point within it. @ViewModelScoped is one of these: it's provided by ViewModelComponent, and it keeps exactly one instance alive for as long as that particular ViewModel instance exists, shared across everything that ViewModel injects, but a different instance for a different ViewModel.
@ViewModelScoped
class CartRepository @Inject constructor(private val api: CartApi)
@HiltViewModel
class CartViewModel @Inject constructor(
private val repo: CartRepository
) : ViewModel()
Two more scopes matter for a different reason: what survives a configuration change like rotation. @ActivityScoped and @FragmentScoped bindings are rebuilt every time their Activity or Fragment is recreated, which includes every rotation, so they do not survive it. @ActivityRetainedScoped lives in ActivityRetainedComponent, a component Hilt deliberately keeps alive across configuration changes even though the Activity instance is destroyed and recreated, so one instance persists through rotation without needing the app-wide lifetime of @Singleton. This is the scope to reach for when you want state to outlive a screen rotation but still want it cleaned up when the user actually leaves that flow, not kept for the whole app process.
@ActivityRetainedScoped
class UserSessionManager @Inject constructor(
private val prefs: SharedPreferences
)
ViewModels get their own dedicated annotation, @HiltViewModel, paired with an @Inject constructor, and Hilt will automatically supply a SavedStateHandle if the constructor asks for one. How you retrieve that ViewModel depends on where you are. In a classic Activity or Fragment, you use the by viewModels() property delegate. Inside a composable, the equivalent is the hiltViewModel() function, which looks up or creates the ViewModel scoped to the current navigation destination. Plain viewModel(), without the Hilt prefix, knows nothing about Hilt's graph at all, so swapping it in for hiltViewModel() silently breaks injection.
@Composable
fun HomeScreen(
viewModel: HomeViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsState()
}
Step back and everything above is really just friendlier syntax over Dagger. At build time, Hilt generates one Dagger component per supported Android class, SingletonComponent for Application, ActivityComponent for each Activity, and so on, and wires them into a parent-child hierarchy matching how those Android classes actually nest at runtime. That generation is paired with full graph validation: if a dependency is missing, or two bindings depend on each other in a cycle Dagger can't resolve, the build fails outright. That's the real payoff of the whole system: you find a broken dependency graph at compile time, on your machine, instead of a user hitting a crash in production months later.
That code generation happens as an annotation processing step during the build, and on a modern Android project it should be running through KSP, the Kotlin Symbol Processing API, rather than the older kapt. Dagger and Hilt both support KSP, and it's meaningfully faster because it works directly against Kotlin's compiler symbols instead of generating a Java stub layer first the way kapt does. None of this happens at R8 or minify time, and it isn't reflection either, it's plain compile-time code generation, just running through a faster processor than a few years ago. If an interviewer asks how Hilt's codegen fits into a modern build, KSP is the answer they're checking for.
plugins {
id("com.google.devtools.ksp")
id("com.google.dagger.hilt.android")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.52")
ksp("com.google.dagger:hilt-compiler:2.52")
}
@AndroidEntryPoint only supports a fixed list of Android classes, Activity, Fragment, View, Service, and BroadcastReceiver. Anything outside that list can't use field injection directly, and the most common example that trips people up is ContentProvider, which Hilt deliberately does not support with @AndroidEntryPoint. The escape hatch is @EntryPoint: you define an interface listing exactly the bindings you need, install it in the component that has them, and then pull them out manually at the point you need them using EntryPointAccessors. It's the one place in Hilt where you reach into the graph imperatively instead of letting Hilt inject for you, and it exists specifically for classes the framework can't reach any other way.
@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyProviderEntryPoint {
fun repository(): MyRepository
}
class MyContentProvider : ContentProvider() {
override fun query(...): Cursor? {
val ep = EntryPointAccessors.fromApplication(
context!!, MyProviderEntryPoint::class.java
)
val repo = ep.repository()
}
}
Hilt tests run against a special HiltTestApplication, wired up by HiltAndroidRule inside every class marked @HiltAndroidTest, so the real dependency graph exists exactly as it does in production, just made swappable. @UninstallModules removes a real module for a single test class, and @BindValue hands Hilt a fake instance directly, no extra module boilerplate needed. When a fake should apply to every test in the module rather than just one class, @TestInstallIn replaces a production module across the whole test source set instead. The distinction interviewers listen for: @BindValue for one test's fake, @TestInstallIn for a fake every test should share.
@UninstallModules(NetworkModule::class)
@HiltAndroidTest
class LoginTest {
@BindValue @JvmField val api: LoginApi = FakeLoginApi()
@get:Rule val hiltRule = HiltAndroidRule(this)
}
WorkManager constructs its own Worker instances internally, off the path Hilt normally injects through, so plain @AndroidEntryPoint doesn't apply to a Worker at all. The fix combines two things from earlier in this lesson in a new way: @HiltWorker marks the class, and @AssistedInject with @Assisted splits the constructor between what Hilt supplies, ordinary dependencies, and what WorkManager supplies at runtime, the Context and WorkerParameters. One more step is required to wire it up: the 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.
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repo: SyncRepository
) : CoroutineWorker(appContext, params)
class MyApp : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration get() =
Configuration.Builder().setWorkerFactory(workerFactory).build()
}