Your ViewModel needs a Repository. The Repository needs an API client and a database. The API client needs an OkHttpClient with interceptors. Every class depends on other classes, and you need to create and connect them all. Dependency Injection (DI) means classes declare what they need, and a framework provides it. Hilt is Android’s recommended DI framework — built on Dagger, it generates all wiring code at compile time, catches errors before your app runs, and integrates with Android lifecycles. This guide covers Hilt from mental model to internals.
The Mental Model — What Is Dependency Injection?
// Think of it like a RESTAURANT KITCHEN:
//
// WITHOUT DI (doing it yourself):
// Chef (ViewModel) goes to market, buys tomatoes (creates Retrofit)
// Goes to another market, buys cheese (creates Room database)
// Spends all day SHOPPING instead of COOKING
//
// WITH DI (someone delivers):
// A SUPPLIER (Hilt) delivers all ingredients to the kitchen
// Chef says: "I need tomatoes and cheese"
// Supplier shows up with everything — chef just cooks
// In code terms:
// WITHOUT DI:
class ArticleViewModel {
private val okHttp = OkHttpClient.Builder().build()
private val retrofit = Retrofit.Builder().client(okHttp).build()
private val api = retrofit.create(ArticleApi::class.java)
private val database = Room.databaseBuilder(...).build()
private val repository = ArticleRepository(api, database.articleDao())
// ViewModel creates EVERYTHING itself — untestable, rigid, duplicated
}
// WITH DI:
class ArticleViewModel @Inject constructor(
private val repository: ArticleRepository
// "I need a repository — someone give me one"
// Hilt creates it with all ITS dependencies, automatically
) : ViewModel()
How Hilt Works Internally — Compile-Time Code Generation
This is what most tutorials skip. Understanding HOW Hilt works makes everything else make sense:
// Hilt is built on DAGGER, which generates code at COMPILE TIME
// This is the KEY difference from frameworks like Koin (runtime)
//
// When you build your app:
//
// 1. KSP reads your annotations (@Inject, @Module, @Provides, @HiltViewModel)
//
// 2. Dagger's code generator creates REAL Kotlin/Java classes:
// - Factory classes (one per @Inject constructor)
// - Module adapters (one per @Module)
// - Component implementations (one per Hilt component)
//
// 3. These generated classes are COMPILED into your APK
// - No reflection at runtime
// - No runtime annotation scanning
// - No runtime errors from missing bindings
//
// What gets generated for a simple @Inject class:
// YOUR CODE:
class ArticleRepository @Inject constructor(
private val api: ArticleApi,
private val dao: ArticleDao
)
// DAGGER GENERATES (simplified):
class ArticleRepository_Factory(
private val apiProvider: Provider<ArticleApi>,
private val daoProvider: Provider<ArticleDao>
) : Factory<ArticleRepository> {
override fun get(): ArticleRepository {
return ArticleRepository(apiProvider.get(), daoProvider.get())
}
}
// A FACTORY class that knows how to create ArticleRepository
// apiProvider.get() creates/retrieves the ArticleApi
// daoProvider.get() creates/retrieves the ArticleDao
// This is GENERATED code — you never write it, but it exists in your build
// For a @Module with @Provides:
// YOUR CODE:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton
fun provideRetrofit(okHttp: OkHttpClient): Retrofit {
return Retrofit.Builder().client(okHttp).baseUrl("...").build()
}
}
// DAGGER GENERATES (simplified):
class NetworkModule_ProvideRetrofitFactory(
private val okHttpProvider: Provider<OkHttpClient>
) : Factory<Retrofit> {
override fun get(): Retrofit {
return NetworkModule.provideRetrofit(okHttpProvider.get())
}
}
// Calls YOUR provideRetrofit() function with the OkHttpClient from its provider
// For the COMPONENT (the central hub that wires everything):
// Dagger generates a class like:
class DaggerMyApplication_HiltComponents_SingletonC implements SingletonComponent {
private final Lazy<Retrofit> retrofitProvider;
private final Lazy<ArticleApi> articleApiProvider;
private final Lazy<AppDatabase> databaseProvider;
// ... one field per @Singleton dependency
// Initialization: creates all providers, wires them together
// Each Lazy creates the object only on first access
// @Singleton objects are wrapped in DoubleCheck (thread-safe lazy singleton)
}
// The ENTIRE dependency graph is resolved at COMPILE TIME
// If you forget a binding → COMPILE ERROR (not runtime crash!)
// Example: if ArticleApi has no @Provides → build fails with:
// "ArticleApi cannot be provided without an @Provides-annotated method"
Compile-time vs runtime DI — pros and cons
// ┌─────────────────────┬──────────────────────┬──────────────────────┐
// │ │ Hilt/Dagger │ Koin │
// │ │ (COMPILE-TIME) │ (RUNTIME) │
// ├─────────────────────┼──────────────────────┼──────────────────────┤
// │ Error detection │ ✅ Compile error │ ❌ Runtime crash │
// │ │ "Missing binding" │ "No definition found"│
// │ │ Caught BEFORE run │ Crashes in production│
// │ │ │ │
// │ Performance │ ✅ Zero reflection │ ⚠️ Uses reflection │
// │ │ Generated factories │ Slower startup │
// │ │ ~0ms injection cost │ ~50-200ms on large │
// │ │ │ apps │
// │ │ │ │
// │ Build time │ ❌ Slower builds │ ✅ Faster builds │
// │ │ Code generation adds │ No code generation │
// │ │ 5-30s to build │ │
// │ │ │ │
// │ APK size │ ❌ Larger │ ✅ Smaller │
// │ │ Generated classes │ No generated code │
// │ │ add ~100-500 KB │ │
// │ │ │ │
// │ Learning curve │ ❌ Steeper │ ✅ Simpler │
// │ │ Many annotations │ DSL-based, Kotlin │
// │ │ Dagger concepts │ familiar syntax │
// │ │ │ │
// │ Android integration │ ✅ First-class │ ⚠️ Manual lifecycle │
// │ │ Built for Android │ handling │
// │ │ Activity/Fragment/VM │ │
// │ │ scoping automatic │ │
// │ │ │ │
// │ Google recommends │ ✅ Official │ ❌ Not official │
// │ Multi-module │ ✅ Great support │ ⚠️ Possible but │
// │ │ │ manual │
// └─────────────────────┴──────────────────────┴──────────────────────┘
//
// Bottom line:
// Hilt → safer (compile-time checks), faster (no reflection), more complex setup
// Koin → simpler (DSL), faster builds, but errors only found at runtime
// For production apps → Hilt (Google recommends, catch errors at compile time)
// For prototypes/small apps → Koin is fine (simpler, faster setup)
Setup
// build.gradle.kts (project root)
plugins {
id("com.google.dagger.hilt.android") version "2.51.1" apply false
id("com.google.devtools.ksp") version "2.0.21-1.0.27" apply false
}
// build.gradle.kts (app module)
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.51.1")
// hilt-android — runtime library
ksp("com.google.dagger:hilt-android-compiler:2.51.1")
// hilt-android-compiler — generates factories and components via KSP
// Compose ViewModel injection:
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// WorkManager injection:
implementation("androidx.hilt:hilt-work:1.2.0")
ksp("androidx.hilt:hilt-compiler:1.2.0")
}
Step 1 — Application and Activity
@HiltAndroidApp
// @HiltAndroidApp is an ANNOTATION from dagger.hilt.android
// Triggers Hilt's code generation — creates the root component (SingletonComponent)
// MUST be on your Application class — everything starts here
class MyApplication : Application()
@AndroidEntryPoint
// @AndroidEntryPoint is an ANNOTATION from dagger.hilt.android
// Enables injection in this Activity
// Generates a Hilt-aware subclass under the hood
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { MyAppTheme { AppNavigation() } }
}
}
// ⚠️ IMPORTANT for Compose:
// @AndroidEntryPoint is ONLY needed on the Activity (the host)
// Individual @Composable screens do NOT need @AndroidEntryPoint
// Composables get dependencies through hiltViewModel()
// hiltViewModel() works because the HOST Activity has @AndroidEntryPoint
Step 2 — Constructor Injection (@Inject)
// The MOST COMMON pattern — put @Inject on the constructor
class ArticleRepository @Inject constructor(
// @Inject is an ANNOTATION from javax.inject
// On a constructor: "Hilt, you can create instances of this class"
// Hilt reads the parameters and provides each one automatically
private val api: ArticleApi,
private val dao: ArticleDao
) {
fun getArticlesFlow(): Flow<List<Article>> = dao.getAllArticles().map { /* ... */ }
}
class GetArticlesUseCase @Inject constructor(
private val repository: ArticleRepository
// Hilt sees ArticleRepository has @Inject constructor → creates it
// ArticleRepository needs ArticleApi → Hilt checks @Provides → creates it
// The ENTIRE chain resolves automatically
) {
operator fun invoke() = repository.getArticlesFlow()
}
// What happens at compile time:
// Dagger generates ArticleRepository_Factory
// Dagger generates GetArticlesUseCase_Factory
// Both are wired into the component implementation
// At RUNTIME: Hilt calls the factory → creates the object → injects it
// Zero reflection, zero annotation scanning — just calling generated code
Step 3 — Modules (@Provides and @Binds)
@Provides — for library classes you can’t modify
@Module
// @Module is an ANNOTATION from Dagger — contains dependency creation instructions
@InstallIn(SingletonComponent::class)
// @InstallIn is an ANNOTATION from Hilt — which component this module belongs to
// SingletonComponent → dependencies live for the ENTIRE app lifetime
object NetworkModule {
@Provides
// @Provides is an ANNOTATION from Dagger
// "Call this function to create the return type"
@Singleton
// @Singleton is an ANNOTATION from javax.inject
// ONE instance for the entire app — cached after first creation
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.addInterceptor(HttpLoggingInterceptor().apply {
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BODY
else HttpLoggingInterceptor.Level.NONE
})
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit {
// Hilt provides OkHttpClient from the function above
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideArticleApi(retrofit: Retrofit): ArticleApi {
return retrofit.create(ArticleApi::class.java)
}
}
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
// @ApplicationContext is an ANNOTATION from dagger.hilt.android.qualifiers
// Hilt provides the Application context automatically
return Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
}
@Provides
fun provideArticleDao(database: AppDatabase): ArticleDao {
return database.articleDao()
}
}
@Binds — for interface → implementation mapping
// Domain layer defines the interface:
interface ArticleRepository {
fun getArticlesFlow(): Flow<List<Article>>
suspend fun refreshArticles()
}
// Data layer implements it:
class ArticleRepositoryImpl @Inject constructor(
private val api: ArticleApi,
private val dao: ArticleDao
) : ArticleRepository {
override fun getArticlesFlow() = dao.getAllArticles().map { /* ... */ }
override suspend fun refreshArticles() { /* ... */ }
}
// Tell Hilt which implementation to use:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
// MUST be abstract class for @Binds (not object)
@Binds
// @Binds is an ANNOTATION from Dagger
// Maps interface → implementation — NO function body needed
// More efficient than @Provides (no wrapper code generated)
@Singleton
abstract fun bindArticleRepository(
impl: ArticleRepositoryImpl // parameter = implementation
): ArticleRepository // return = interface
}
// @Binds vs @Provides:
// @Binds → interface → implementation (abstract, no body, less generated code)
// @Provides → creates instances with code (concrete, has body)
// Rule: use @Binds for interfaces, @Provides for library classes
Step 4 — ViewModel Injection
@HiltViewModel
// @HiltViewModel is an ANNOTATION from dagger.hilt.android.lifecycle
// Tells Hilt: "generate a ViewModelProvider.Factory for this ViewModel"
// The generated factory knows how to create this ViewModel with its dependencies
class ArticleViewModel @Inject constructor(
private val getArticlesUseCase: GetArticlesUseCase,
private val savedStateHandle: SavedStateHandle
// SavedStateHandle is auto-provided by Hilt for @HiltViewModel
// Contains: navigation arguments, saved state
) : ViewModel() {
val articles: StateFlow<List<Article>> = getArticlesUseCase()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
}
Compose screen injection — how hiltViewModel() works
// In Compose, you get ViewModels with hiltViewModel():
@Composable
fun ArticleScreen(
viewModel: ArticleViewModel = hiltViewModel()
// hiltViewModel() is a COMPOSABLE FUNCTION from hilt-navigation-compose
) {
val articles by viewModel.articles.collectAsStateWithLifecycle()
ArticleList(articles)
}
// WHAT hiltViewModel() DOES INTERNALLY:
// 1. Gets the current NavBackStackEntry (from LocalViewModelStoreOwner)
// 2. Gets or creates a ViewModelStoreOwner for this destination
// 3. Uses Hilt's generated ViewModelProvider.Factory
// 4. Creates the ViewModel with all dependencies injected
// 5. Returns the SAME instance on recomposition (cached in ViewModelStore)
// KEY POINTS about Compose + Hilt:
//
// ✅ hiltViewModel() scopes to the NAVIGATION DESTINATION (NavBackStackEntry)
// Each composable gets its own ViewModel instance
// Navigate to same route twice → TWO different ViewModel instances
//
// ✅ NO @AndroidEntryPoint needed on composable screens
// Only the HOST Activity needs @AndroidEntryPoint
// hiltViewModel() works because it accesses Hilt through the Activity
//
// ✅ You CANNOT inject dependencies into regular @Composable functions
// Only ViewModels get constructor injection
// Composables get data from ViewModels via StateFlow
//
// ❌ This does NOT work:
// @Composable
// fun ArticleCard(@Inject repository: ArticleRepository) // ❌ impossible!
//
// ✅ This is the correct pattern:
// @Composable fun ArticleScreen(viewModel: ArticleViewModel = hiltViewModel())
// ViewModel has dependencies → exposes state via StateFlow → Composable observes
// SHARED ViewModel in nested navigation graph:
composable<Cart> { entry ->
val parentEntry = remember(entry) { navController.getBackStackEntry(CheckoutGraph) }
val viewModel: CheckoutViewModel = hiltViewModel(parentEntry)
// hiltViewModel(viewModelStoreOwner) — uses the PARENT's ViewModelStore
// All screens in CheckoutGraph get the SAME CheckoutViewModel instance
}
// CompositionLocal as alternative to DI in Compose:
// For truly cross-cutting concerns (theme, analytics, locale),
// CompositionLocal can provide values through the tree:
val LocalAnalytics = staticCompositionLocalOf<Analytics> { error("Not provided") }
// BUT: prefer ViewModel + Hilt for screen-level dependencies
// CompositionLocal is for FRAMEWORK concerns, not business logic
Hilt Component Hierarchy — Scoping and Lifetime
// Hilt has PREDEFINED components matching Android lifecycles:
//
// SingletonComponent (@Singleton)
// │ Created: Application.onCreate()
// │ Destroyed: Application terminated
// │ Use for: Retrofit, Room, OkHttp, Repositories
// │
// ├── ActivityRetainedComponent (@ActivityRetainedScoped)
// │ │ Survives configuration changes (rotation)
// │ │
// │ ├── ViewModelComponent (@ViewModelScoped)
// │ │ Created: ViewModel first accessed
// │ │ Destroyed: ViewModel.onCleared()
// │ │
// │ └── ActivityComponent (@ActivityScoped)
// │ │ Dies on rotation!
// │ │
// │ └── FragmentComponent (@FragmentScoped)
// │
// └── ServiceComponent (@ServiceScoped)
// WHAT THE GENERATED COMPONENT LOOKS LIKE (simplified):
// Dagger generates a class for each component:
class SingletonC implements SingletonComponent {
// @Singleton dependencies — created once, cached forever
private Lazy<Retrofit> retrofit; // created on first access
private Lazy<AppDatabase> database; // created on first access
private Lazy<ArticleRepository> repository; // created on first access
// Non-scoped dependencies — new instance every time
// GetArticlesUseCase is NOT @Singleton → new Factory call each time
ArticleRepository getArticleRepository() {
return repository.get(); // always returns the SAME instance
}
GetArticlesUseCase getGetArticlesUseCase() {
return new GetArticlesUseCase(getArticleRepository());
// NEW instance each time, but uses the SAME repository
}
}
// In practice, you mostly use:
// @Singleton → app-level (expensive/shared: Retrofit, Room, Repositories)
// No scope → new instance each time (cheap/stateless: Use Cases, Mappers)
// @ViewModelScoped → rare (shared within one ViewModel's dependency tree)
Scoping — when to use and when to skip
// ❌ OVER-SCOPING — making everything @Singleton
@Singleton class ArticleMapper @Inject constructor()
@Singleton class DateFormatter @Inject constructor()
@Singleton class GetArticlesUseCase @Inject constructor(...)
// These are stateless! No need to keep one instance in memory forever
// Each @Singleton adds to app startup time and memory usage
// ✅ CORRECT scoping:
// @Singleton (expensive to create, holds state, shared):
@Singleton class ArticleRepositoryImpl @Inject constructor(...)
// Repository wraps DB + API — create once, share everywhere
// NO scope (cheap, stateless, no shared state):
class GetArticlesUseCase @Inject constructor(...)
class ArticleMapper @Inject constructor()
// New instance each time — cheap, no side effects
// @ViewModelScoped (shared within one ViewModel's tree):
@ViewModelScoped
class ScreenAnalytics @Inject constructor(...)
// Multiple Use Cases in the same ViewModel share ONE instance
// Different ViewModels get different instances
Provider<T> vs Lazy<T>
// Sometimes you DON'T want Hilt to create the dependency immediately
// Provider<T> — get a NEW instance every time you call .get()
class ArticleProcessor @Inject constructor(
private val mapperProvider: Provider<ArticleMapper>
// Provider is an INTERFACE from javax.inject
// Each call to mapperProvider.get() creates a NEW ArticleMapper
// Useful when you need multiple instances on demand
) {
fun processAll(articles: List<Article>) {
articles.forEach { article ->
val mapper = mapperProvider.get() // new instance each time
mapper.map(article)
}
}
}
// Lazy<T> — create ONE instance, but delay until first .get()
class HeavyFeature @Inject constructor(
private val heavyDependency: Lazy<ExpensiveService>
// Lazy is an INTERFACE from dagger
// ExpensiveService is NOT created during HeavyFeature construction
// Created ONLY when heavyDependency.get() is first called
// Subsequent .get() calls return the SAME instance
) {
fun doWork() {
// ExpensiveService created HERE, not at injection time
heavyDependency.get().execute()
}
}
// When to use:
// Provider<T> → need multiple instances on demand, or break circular dependency
// Lazy<T> → delay expensive creation until actually needed
// Neither → most cases (just inject directly)
Qualifiers — Multiple Instances of the Same Type
// PROBLEM: two OkHttpClients — authenticated and public
// Both are OkHttpClient type. How does Hilt distinguish?
@Qualifier
// @Qualifier is an ANNOTATION from javax.inject — creates a label
@Retention(AnnotationRetention.BINARY)
annotation class AuthenticatedClient
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class PublicClient
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides @Singleton @AuthenticatedClient
fun provideAuthClient(authInterceptor: AuthInterceptor): OkHttpClient {
return OkHttpClient.Builder().addInterceptor(authInterceptor).build()
}
@Provides @Singleton @PublicClient
fun providePublicClient(): OkHttpClient {
return OkHttpClient.Builder().build()
}
@Provides @Singleton
fun provideRetrofit(@AuthenticatedClient client: OkHttpClient): Retrofit {
// @AuthenticatedClient tells Hilt WHICH OkHttpClient to provide
return Retrofit.Builder().client(client).baseUrl("...").build()
}
}
// Built-in qualifiers:
// @ApplicationContext — Application context
// @ActivityContext — Activity context (only in ActivityComponent and below)
@EntryPoint — Injecting Where Hilt Doesn’t Reach
// Hilt manages: Application, Activity, Fragment, ViewModel, Service, BroadcastReceiver
// But some classes are created by ANDROID, not by Hilt:
// ContentProvider, custom Views, third-party library callbacks
// For these, use @EntryPoint
// PROBLEM: ContentProvider is created BEFORE Application.onCreate()
// Hilt isn't ready yet — can't use @AndroidEntryPoint
@EntryPoint
// @EntryPoint is an ANNOTATION from dagger.hilt.android
// Defines an access point into the Hilt dependency graph
// For classes that Hilt doesn't manage directly
@InstallIn(SingletonComponent::class)
interface DatabaseEntryPoint {
// Declare the dependencies you need as functions
fun articleDao(): ArticleDao
}
// In your ContentProvider:
class ArticleContentProvider : ContentProvider() {
override fun onCreate(): Boolean {
val entryPoint = EntryPointAccessors.fromApplication(
context!!.applicationContext,
DatabaseEntryPoint::class.java
)
// EntryPointAccessors is a CLASS from Hilt
// fromApplication() is a STATIC FUNCTION — gets the entry point from the app component
val dao = entryPoint.articleDao()
// Now you have the DAO without @AndroidEntryPoint!
return true
}
}
// Other @EntryPoint use cases:
// - Custom Views that need dependencies
// - Third-party library callbacks
// - WorkManager initializer (before Hilt-Work was available)
// - Any class Android creates that Hilt can't manage
Multibindings — Collecting Multiple Implementations
// Sometimes you want to collect ALL implementations of an interface
// Example: multiple analytics providers, multiple interceptors
// @IntoSet — collect into a Set
interface AnalyticsProvider {
fun logEvent(name: String, params: Map<String, Any>)
}
class FirebaseAnalyticsProvider @Inject constructor() : AnalyticsProvider {
override fun logEvent(name: String, params: Map<String, Any>) { /* Firebase */ }
}
class MixpanelAnalyticsProvider @Inject constructor() : AnalyticsProvider {
override fun logEvent(name: String, params: Map<String, Any>) { /* Mixpanel */ }
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AnalyticsModule {
@Binds @IntoSet
// @IntoSet is an ANNOTATION from Dagger — adds this binding to a Set
abstract fun bindFirebase(impl: FirebaseAnalyticsProvider): AnalyticsProvider
@Binds @IntoSet
abstract fun bindMixpanel(impl: MixpanelAnalyticsProvider): AnalyticsProvider
}
// Now inject the Set:
class AnalyticsManager @Inject constructor(
private val providers: Set<@JvmSuppressWildcards AnalyticsProvider>
// Hilt provides a Set containing BOTH Firebase and Mixpanel providers
// @JvmSuppressWildcards is needed for Kotlin generics compatibility with Dagger
) {
fun logEvent(name: String, params: Map<String, Any>) {
providers.forEach { it.logEvent(name, params) }
// Logs to ALL providers at once
}
}
// @IntoMap — collect into a Map (keyed):
@MapKey
// @MapKey is an ANNOTATION from Dagger — defines the Map key type
annotation class AnalyticsKey(val value: String)
@Binds @IntoMap @AnalyticsKey("firebase")
abstract fun bindFirebase(impl: FirebaseAnalyticsProvider): AnalyticsProvider
// Inject as Map<String, AnalyticsProvider>
Testing with Hilt
// UNIT TESTS — don't even need Hilt!
// Just use constructor injection with fakes:
class ArticleViewModelTest {
private val fakeRepository = FakeArticleRepository()
private val viewModel = ArticleViewModel(
getArticlesUseCase = GetArticlesUseCase(fakeRepository),
savedStateHandle = SavedStateHandle()
)
// Directly create with fakes — fast, simple, no Hilt overhead
}
// INTEGRATION/UI TESTS — use Hilt to swap modules:
@HiltAndroidTest
// @HiltAndroidTest is an ANNOTATION — sets up Hilt for the test
@UninstallModules(RepositoryModule::class)
// @UninstallModules is an ANNOTATION — removes the REAL module
// Now you can provide a fake binding instead
class ArticleScreenTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@Module
@InstallIn(SingletonComponent::class)
abstract class FakeModule {
@Binds @Singleton
abstract fun bindRepo(fake: FakeArticleRepository): ArticleRepository
}
@Before
fun setup() { hiltRule.inject() }
@Test
fun showsArticles() { /* test with fake data */ }
}
Complete Dependency Graph Trace
// ArticleViewModel needs:
// → GetArticlesUseCase (@Inject constructor)
// → ArticleRepository (interface, @Binds → ArticleRepositoryImpl)
// → ArticleRepositoryImpl (@Inject constructor)
// → ArticleApi (@Provides from NetworkModule)
// → Retrofit (@Provides, @Singleton)
// → OkHttpClient (@Provides, @Singleton)
// → ArticleDao (@Provides from DatabaseModule)
// → AppDatabase (@Provides, @Singleton)
// → Context (@ApplicationContext — auto-provided)
// → SavedStateHandle (auto-provided for @HiltViewModel)
//
// At COMPILE TIME, Dagger:
// 1. Reads all @Inject, @Module, @Provides, @Binds annotations
// 2. Builds a complete graph of all dependencies
// 3. Verifies everything resolves (missing binding → compile error!)
// 4. Generates Factory classes for each dependency
// 5. Generates Component class that wires all factories together
//
// At RUNTIME:
// 1. @HiltAndroidApp creates the SingletonComponent
// 2. hiltViewModel() accesses the ViewModelComponent
// 3. Factories are called to create objects — no reflection!
// 4. @Singleton objects are cached — subsequent requests get same instance
Common Mistakes to Avoid
Mistake 1: Missing @Inject on constructor
// ❌ Compile error: "cannot be provided without @Inject constructor or @Provides"
class ArticleRepository(private val api: ArticleApi)
// ✅ Add @Inject
class ArticleRepository @Inject constructor(private val api: ArticleApi)
Mistake 2: @Provides in abstract class (or @Binds in object)
// ❌ @Provides needs a body → can't be abstract
abstract class Module { @Provides abstract fun provide(): Retrofit }
// ❌ @Binds has no body → can't be in object (concrete)
object Module { @Binds abstract fun bind(impl: Impl): Interface }
// ✅ @Provides → object, @Binds → abstract class
object NetworkModule { @Provides fun provide(): Retrofit { ... } }
abstract class RepoModule { @Binds abstract fun bind(impl: Impl): Interface }
Mistake 3: @Singleton on everything
// ❌ Wastes memory, slows startup, hides bugs
@Singleton class ArticleMapper @Inject constructor() // stateless — why singleton?
// ✅ Only scope expensive/shared objects
class ArticleMapper @Inject constructor() // no scope — new each time, fine!
Mistake 4: Thinking Composables can have @Inject fields
// ❌ Composable functions CANNOT receive injected dependencies
@Composable
fun ArticleCard(@Inject repo: ArticleRepository) { } // impossible!
// ✅ Dependencies go through ViewModel → StateFlow → Composable observes
@Composable
fun ArticleScreen(viewModel: ArticleViewModel = hiltViewModel()) {
val data by viewModel.articles.collectAsStateWithLifecycle()
}
Mistake 5: Every module needs the Hilt plugin in multi-module
// ❌ Feature module uses @Inject but doesn't have the Hilt plugin → build fails
// :feature:articles/build.gradle.kts — missing plugins!
// ✅ Every module using Hilt annotations needs:
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.51.1")
ksp("com.google.dagger:hilt-android-compiler:2.51.1")
}
Summary
- Hilt is built on Dagger and generates all DI code at compile time — no reflection, no runtime errors from missing bindings
- Dagger generates Factory classes for each @Inject constructor and Component classes that wire everything together
- Compile-time DI catches missing bindings before the app runs — Koin (runtime DI) catches them only when the code executes
- Trade-off: Hilt has slower builds (code generation) and larger APK (generated classes) but faster runtime (no reflection)
- @Inject constructor — tells Hilt how to create a class; @Provides — creates library instances; @Binds — maps interface to implementation
- @Singleton creates one instance for the app — use for expensive/shared objects only, not for stateless classes
- hiltViewModel() in Compose is scoped to the NavBackStackEntry — no @AndroidEntryPoint needed on composable screens
- Composables cannot receive injected dependencies directly — only ViewModels get constructor injection; composables observe StateFlow
- Provider<T> (interface from javax.inject) creates a new instance on each
.get(); Lazy<T> (interface from Dagger) delays creation until first.get() - @Qualifier annotations distinguish multiple instances of the same type (e.g., two OkHttpClients)
- @EntryPoint (annotation from Hilt) provides access to the dependency graph from classes Hilt doesn’t manage (ContentProvider, custom Views)
- @IntoSet / @IntoMap (annotations from Dagger) collect multiple implementations into Set or Map
- Unit tests don’t need Hilt — just create objects with fake constructors; integration tests use @HiltAndroidTest + @UninstallModules
- In multi-module, every module using Hilt annotations needs the Hilt plugin and compiler dependency
Hilt does one thing exceptionally well: it turns your dependency declarations into a complete, validated, efficient wiring system — all at compile time. You declare what you need, Hilt generates the factories, validates the graph, and provides everything automatically. No runtime surprises, no reflection overhead, no manual wiring. Understand the generated code, scope correctly, and let Hilt do the work.
Happy coding!
Comments (0)