Why Dependency Injection?
The Problem Without DI
Without DI, classes create their own dependencies inside constructors or methods. This tightly couples code — UserViewModel directly instantiates RetrofitUserRepository, which directly creates Retrofit.Builder(). Testing becomes impossible because you can't swap in a fake repository.
What DI Solves
DI inverts this: the class declares what it needs, and an external container (the DI framework) provides it. UserViewModel declares class UserViewModel(private val repository: UserRepository). At runtime, Hilt creates the real RetrofitUserRepository. In tests, you pass a fake.
Hilt vs Manual DI vs Dagger
- Manual DI: You write a
ServiceLocatoror pass dependencies through constructors. Works for small projects but becomes boilerplate-heavy. - Dagger: Compile-time DI framework for Android. Powerful but verbose — requires
@Component,@Module,@Subcomponentwith lots of ceremony. - Hilt: Built on top of Dagger. Reduces boilerplate with Android-specific annotations (
@AndroidEntryPoint,@HiltViewModel). Google's recommended DI solution for Android.
Setup
// build.gradle.kts
plugins {
id("com.google.dagger.hilt.android")
id("com.google.devtools.ksp")
}
dependencies {
implementation("com.google.dagger:hilt-android:2.51")
ksp("com.google.dagger:hilt-compiler:2.51")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
}
// Application class
@HiltAndroidApp
class MyApplication : Application()
// Activity
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
// Hilt injects dependencies here
}
Modules and Scopes
Hilt Modules
Modules tell Hilt how to create dependencies. There are two main annotations:
- @Provides: When you need to call a constructor or builder yourself (e.g., Retrofit, Room).
- @Binds: When you have an interface and a concrete implementation — Hilt figures out the rest.
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.addConverterFactory(GsonConverterFactory.create())
.build()
}
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi {
return retrofit.create(UserApi::class.java)
}
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(
impl: RetrofitUserRepository
): UserRepository
}
Scopes
Scopes control how long a dependency instance lives:
| Scope | Component | Lifecycle |
|---|---|---|
| @Singleton | SingletonComponent | Entire app |
| @ActivityScoped | ActivityComponent | One Activity |
| @ViewModelScoped | ViewModelComponent | One ViewModel |
| @FragmentScoped | FragmentComponent | One Fragment |
| No scope | ViewComponent | Recreated every injection |
// Created once for the entire app lifetime
@Singleton
class SessionManager @Inject constructor(
private val tokenStorage: TokenStorage
)
// Created once per ViewModel
@ViewModelScoped
class CartUseCase @Inject constructor(
private val cartRepository: CartRepository
)
// Created fresh every time it's injected
class DateFormatter @Inject constructor()
@Inject Constructor
When you own the class, use @Inject constructor directly — no module needed:
@ViewModelScoped
class ProductViewModel @Inject constructor(
private val getProductUseCase: GetProductUseCase,
private val addToCartUseCase: AddToCartUseCase
) : ViewModel()
Hilt sees the @Inject constructor, knows how to create all parameters (because they're also provided by Hilt), and creates the ViewModel automatically.
Injecting into Android Components
@AndroidEntryPoint
Annotate Activities, Fragments, Services, and BroadcastReceivers with @AndroidEntryPoint to enable field injection:
@AndroidEntryPoint
class ProductFragment : Fragment() {
// Hilt injects this before onCreate
@Inject lateinit var analyticsTracker: AnalyticsTracker
// ViewModel injected via hiltViewModel()
private val viewModel: ProductViewModel by viewModels()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
analyticsTracker.trackScreen("product_detail")
}
}
ViewModel Injection
Hilt provides a dedicated annotation for ViewModels:
@HiltViewModel
class ProductViewModel @Inject constructor(
private val getProductUseCase: GetProductUseCase
) : ViewModel() {
private val _state = MutableStateFlow(ProductUiState())
val state: StateFlow<ProductUiState> = _state.asStateFlow()
fun loadProduct(id: String) {
viewModelScope.launch {
_state.value = ProductUiState(isLoading = true)
val product = getProductUseCase(id)
_state.value = ProductUiState(product = product)
}
}
}
In Compose:
@Composable
fun ProductScreen(viewModel: ProductViewModel = hiltViewModel()) {
val state by viewModel.state.collectAsStateWithLifecycle()
// render...
}
Testing with Hilt
Swap real implementations for fakes in tests:
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {
@Binds
abstract fun bindUserRepository(
fake: FakeUserRepository
): UserRepository
}
Hilt swaps the module automatically in test builds — no code changes needed in the ViewModel or Activity.
Quiz
1. What is the difference between @Provides and @Binds in Hilt modules?
2. What does @Singleton scope guarantee in Hilt?
3. How do you inject a ViewModel in a Composable with Hilt?
4. What annotation must the Application class have for Hilt to work?
Flashcards
Question
What does @Inject constructor do in Hilt?
Click to reveal answer
Answer
Tells Hilt how to create the class. Hilt sees all constructor parameters and resolves them from its container automatically — no module needed.
Question
What is the difference between @Singleton and @ViewModelScoped?
Click to reveal answer
Answer
@Singleton creates one instance for the entire app. @ViewModelScoped creates one instance per ViewModel — a new instance is created when the ViewModel is created.
Question
When should you use @Provides vs @Binds?
Click to reveal answer
Answer
@Provides when you need to call a builder or factory yourself (Retrofit, Room). @Binds when mapping an interface to its concrete implementation.
Question
How do you swap real dependencies for fakes in Hilt tests?
Click to reveal answer
Answer
Use @TestInstallIn on a test module that @Binds the interface to a fake implementation. Hilt automatically uses the test module instead of the production module.
Revision Notes
Key Takeaways
- 1. Hilt reduces Dagger boilerplate with Android-specific annotations like @HiltViewModel and @AndroidEntryPoint
- 2. @Provides is for building dependencies yourself, @Binds is for interface-to-implementation mapping
- 3. Scopes control instance lifetime — @Singleton for app-wide, @ViewModelScoped for ViewModel-lifetime
- 4. Use @TestInstallIn to swap real dependencies for fakes in tests without changing production code
Interview Tips
- • Explain DI as inverting control — the class declares what it needs, the container provides it
- • Discuss why you'd choose Hilt over manual DI or Koin for larger projects
- • Know the difference between @Provides and @Binds and when to use each
- • Be ready to explain how testing works with Hilt by swapping modules
Cheat Sheet
Hilt DI Cheat Sheet
Setup:
@HiltAndroidApp
class MyApp : Application()
@AndroidEntryPoint
class MainActivity : ComponentActivity()
Modules:
@Provides: Call constructor/builder yourself@Binds: Map interface → implementation@InstallIn(SingletonComponent::class)for app-wide
Scopes:
| Annotation | Lifetime |
|---|---|
| @Singleton | Entire app |
| @ActivityScoped | One Activity |
| @ViewModelScoped | One ViewModel |
ViewModel Injection:
@HiltViewModel
class MyVM @Inject constructor(
private val repo: Repository
) : ViewModel()
// In Composable:
val vm: MyVM = hiltViewModel()
Testing:
@Module
@TestInstallIn(...)
abstract class FakeModule {
@Binds abstract fun bind(repo: FakeRepo): Repository
}