Dependency Injection: Manual DI vs Hilt vs Dagger
Why DI matters, how Hilt simplifies Dagger, and what every senior Android dev must know about scopes
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Dependency Injection is the practice of providing objects' dependencies from the outside rather than letting them construct their own. Manual DI is hand-wiring an AppContainer. Dagger 2 is a compile-time annotation processor that generates the wiring code. Hilt is Google's opinionated Dagger wrapper for Android that provides standard components (Application, Activity, ViewModel) so you only write business logic — not infrastructure.
Real-world relevance
At Payback (fintech), the token refresh interceptor, the encrypted Room database, and the analytics tracker all need to be singletons shared across the app. Hilt's @Singleton scope guarantees exactly one instance. The AuthRepository is @Singleton but the SessionManager is @ActivityScoped — it's destroyed when the auth Activity is destroyed, preventing stale session state from leaking across Activities. In tests, the real AuthService is replaced with a FakeAuthService via @TestInstallIn without touching any production code.
Key points
- Why Dependency Injection? — DI decouples object creation from object use. Without DI, classes construct their own dependencies (new Retrofit(), new Database()) making them untestable, hard to swap, and tightly coupled. With DI, dependencies are provided from outside — you can inject fakes in tests, swap implementations without changing callers.
- Manual DI pattern — Create an AppContainer class that constructs and holds all dependencies. Pass sub-containers to Activities/Fragments via constructor. No framework needed. Perfect for learning and small apps (< 5 screens). Breaks down when the dependency graph grows — you end up with massive constructor chains and manual lifecycle management.
- @HiltAndroidApp — Annotates the Application class. Triggers Hilt code generation — creates the application-level component (AppComponent) that survives the app's lifetime. Required once per app. Under the hood generates a Hilt_MyApplication base class that MyApplication extends.
- @AndroidEntryPoint — Annotates Activity, Fragment, View, Service, or BroadcastReceiver to enable field injection. Hilt generates a component for each annotated class with the correct scope. Without this annotation, @Inject fields will be null — common mistake in interviews.
- @Inject constructor — Marks a class's constructor for Hilt to use when constructing it. If all constructor parameters are also @Inject-able or provided via @Module, Hilt wires it automatically. Prefer constructor injection over field injection — it makes dependencies explicit and enables easier testing.
- @Module and @InstallIn — @Module annotates a class that tells Hilt how to provide types it can't construct directly (interfaces, third-party classes like Retrofit, Room). @InstallIn(SingletonComponent::class) scopes the module to the app. Other options: ActivityComponent, ViewModelComponent, FragmentComponent.
- @Provides vs @Binds — @Provides is a function that returns the dependency — used for Retrofit, Room, OkHttp (anything needing construction logic). @Binds is an abstract function that binds an interface to its implementation — compile-time only, zero runtime overhead vs @Provides. Prefer @Binds whenever you are binding interface to impl.
- Hilt scopes — @Singleton — one instance per app. @ActivityScoped — one per Activity. @FragmentScoped — one per Fragment. @ViewModelScoped — one per ViewModel. Wrong scope = memory leaks or unexpected shared instances. Rule: scope to the shortest lifetime that satisfies the use case.
- @HiltViewModel — Annotates a ViewModel to enable Hilt injection into it. Use with viewModels() delegate in Activity/Fragment. Hilt manages the ViewModel factory automatically — no more ViewModelProvider.Factory boilerplate. Dependencies injected into the ViewModel follow @ViewModelScoped or @Singleton scopes.
- Dagger basics for legacy — Hilt is built on Dagger 2. In legacy codebases you will see: @Component (defines the dependency graph), @Subcomponent (child component with narrower scope), @Module (same as Hilt), @Scope annotations (custom like @PerActivity). Understanding Dagger helps you debug Hilt's generated code and work in pre-Hilt projects.
- Testing with Hilt — Replace production modules in tests using @UninstallModules + @BindValue or @TestInstallIn. HiltAndroidTest + HiltTestRunner for instrumented tests. For unit tests, do NOT use Hilt — just construct the class under test directly with fakes/mocks (constructor injection makes this trivial).
- DI interview answer structure — Answer: (1) Problem DI solves — testability, loose coupling, single source of truth for dependencies. (2) Manual DI — how you'd do it without a framework. (3) Why Hilt/Dagger — compile-time validation, scope management, no manual container wiring. (4) Scoping — knowing @Singleton vs @ViewModelScoped. Shows progression from first principles.
Code example
// 1. Application setup
@HiltAndroidApp
class FieldOpsApp : Application()
// 2. Network Module — third-party classes need @Provides
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
loggingInterceptor: HttpLoggingInterceptor
): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(loggingInterceptor)
.connectTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun provideRetrofit(okHttpClient: OkHttpClient): Retrofit =
Retrofit.Builder()
.baseUrl(BuildConfig.API_BASE_URL)
.client(okHttpClient)
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideOrderApiService(retrofit: Retrofit): OrderApiService =
retrofit.create(OrderApiService::class.java)
}
// 3. Repository binding — interface to implementation
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
// @Binds is abstract, zero runtime cost — preferred over @Provides for bindings
@Binds
@Singleton
abstract fun bindOrderRepository(
impl: OrderRepositoryImpl
): OrderRepository
}
// 4. Constructor injection — Hilt wires all params automatically
@Singleton
class OrderRepositoryImpl @Inject constructor(
private val api: OrderApiService,
private val dao: OrderDao,
private val mapper: OrderMapper
) : OrderRepository {
override fun getOrders(): Flow<List<Order>> =
dao.observeAll().map { it.map(mapper::toDomain) }
}
// 5. ViewModel with Hilt
@HiltViewModel
class OrderViewModel @Inject constructor(
private val getOrders: GetOrdersUseCase,
private val syncOrders: SyncOrdersUseCase
) : ViewModel() {
val orders = getOrders().stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = emptyList()
)
}
// 6. Activity — just annotate + delegate
@AndroidEntryPoint
class OrderActivity : AppCompatActivity() {
private val viewModel: OrderViewModel by viewModels()
// Hilt injects viewModel with all its dependencies
}
// 7. Test — swap real module for fake
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class OrderViewModelTest {
@BindValue
val orderRepository: OrderRepository = FakeOrderRepository()
// test proceeds with fake
}Line-by-line walkthrough
- 1. @HiltAndroidApp on the Application class is the entry point — Hilt generates the root component here that owns all @Singleton dependencies.
- 2. NetworkModule is annotated @InstallIn(SingletonComponent::class) — all @Provides methods here produce @Singleton-scoped objects unless overridden.
- 3. provideOkHttpClient receives AuthInterceptor and HttpLoggingInterceptor as parameters — Hilt injects these automatically from other @Provides methods or @Inject constructors.
- 4. provideOrderApiService creates the Retrofit service interface implementation — Retrofit.create() uses a proxy under the hood, so it must be provided by @Provides, not @Inject.
- 5. RepositoryModule is abstract — @Binds only works in abstract classes/objects and generates no runtime code, unlike @Provides which generates a method call.
- 6. @Binds abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository — Hilt substitutes impl wherever OrderRepository is requested.
- 7. OrderRepositoryImpl uses @Inject constructor — all three params (api, dao, mapper) are automatically provided by Hilt without any manual wiring.
- 8. @HiltViewModel on OrderViewModel enables the auto-generated factory — viewModels() delegate in the Activity uses this factory without any boilerplate.
- 9. stateIn with SharingStarted.WhileSubscribed(5_000) — the Flow only collects while the UI is active, stops after 5 seconds in background to save resources.
- 10. @UninstallModules(RepositoryModule::class) in the test class removes the real binding, then @BindValue injects FakeOrderRepository — clean swap with zero production code changes.
Spot the bug
// Find 4 bugs in this Hilt setup
// AppModule.kt
@Module
@InstallIn(ActivityComponent::class) // Bug 1
object AppModule {
@Provides
fun provideRetrofit(): Retrofit = // Bug 2
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
}
// UserRepository.kt
@Singleton
class UserRepository @Inject constructor(
private val api: ApiService,
private val context: Context // Bug 3
)
// MainActivity.kt
class MainActivity : AppCompatActivity() { // Bug 4
@Inject lateinit var repo: UserRepository
override fun onCreate(...) {
super.onCreate(savedInstanceState)
println(repo.toString())
}
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- Hilt for Android — Official Guide (Android Developers)
- Dependency Injection in Android — Manual DI first (Android Developers)
- Hilt Testing Guide (Android Developers)
- @Binds vs @Provides — When to use each (ProAndroidDev)
- Dagger 2 — User's Guide (for legacy codebases) (dagger.dev)