Dependency Injection (Hilt & Dagger Deep Dive)
Implement scalable dependency injection in Android applications using Dagger 2 and Jetpack Hilt.
Module 12: Dependency Injection (Hilt & Dagger Deep Dive)
Learning Objectives
By the end of this module, you’ll understand:
- What a dependency really is
- Tight coupling vs loose coupling
- Inversion of Control (IoC)
- Dependency Injection
- Constructor Injection
- Field Injection
- Method Injection
- Service Locator vs DI
- Why Dagger exists
- Compile-time dependency graphs
- Hilt architecture
- Components
- Scopes
- Modules
- Providers
- Bindings
- Qualifiers
- Entry Points
- Assisted Injection
- Best practices
Part 1 — What Is a Dependency?
Suppose we have:
class LoginRepository(
private val api: ApiService
)
Question:
What is the dependency?
Answer:
ApiService
Because the repository depends on it.
General definition:
A dependency is anything another class requires in order to perform its work.
Examples:
Repository
↓
Retrofit
↓
Room
↓
SharedPreferences
↓
Logger
↓
Firebase
2. Object Creation Problem
Imagine:
class LoginViewModel {
val repository = LoginRepository()
}
Seems harmless.
But then:
LoginRepository
↓
Retrofit
↓
OkHttp
↓
Logger
↓
Database
↓
Preferences
Who creates all of these?
Soon:
ViewModel
↓
Repository
↓
Retrofit
↓
OkHttp
↓
CertificatePinner
↓
Cache
↓
Database
↓
DAO
The ViewModel is now responsible for building the entire object graph.
That’s a design smell.
3. Tight Coupling
Suppose:
val repository = LoginRepository()
Now the ViewModel knows:
- Which implementation to use
- How to construct it
- When to construct it
They’re tightly coupled.
Imagine changing:
Retrofit
↓
Ktor
Every place that constructs the repository may need updates.
Testing becomes difficult too.
4. Loose Coupling
Instead:
ViewModel
↓
Repository Interface
↓
Implementation
The ViewModel depends only on an abstraction.
It doesn’t care whether the implementation uses:
- Retrofit
- Mock data
- Room
- GraphQL
This follows the Dependency Inversion Principle from Module 10.
Part 2 — Inversion of Control (IoC)
Without IoC:
ViewModel
↓
Creates Repository
With IoC:
ViewModel
↓
Receives Repository
Notice the difference.
The ViewModel no longer controls object creation.
Something else does.
That “something else” is often a DI container.
5. Dependency Injection
Dependency Injection means:
Instead of creating dependencies yourself, they are provided from outside.
Example:
Instead of:
val repository = LoginRepository()
Use:
class LoginViewModel(
private val repository: LoginRepository
)
Now the ViewModel simply says:
“I need a LoginRepository.”
It doesn’t say:
“I’ll build one.”
That’s a huge architectural improvement.
Part 3 — Types of Injection
Constructor Injection (Preferred)
class UserRepository(
private val api: ApiService
)
Advantages:
- Immutable dependencies
- Easy testing
- Dependencies are obvious
- Works well with Hilt
This is Google’s preferred approach.
Field Injection
@Inject
lateinit var repository: UserRepository
Useful in Android framework classes like:
- Activity
- Fragment
- Service
Less ideal for regular Kotlin classes because the dependency isn’t obvious from the constructor.
Method Injection
fun initialize(
logger: Logger
)
Used less frequently.
Helpful when a dependency is only needed for a specific operation.
Part 4 — Service Locator vs DI
Some older codebases use a Service Locator.
ViewModel
↓
ServiceLocator
↓
Repository
The ViewModel asks for dependencies.
With DI:
DI Container
↓
ViewModel
↓
Repository
The container provides them automatically.
DI makes dependencies more explicit and improves testability.
Part 5 — Why Dagger Exists
Imagine:
100 classes.
Each needs:
- Repository
- API
- Database
- Logger
- Analytics
- Preferences
Manually wiring everything becomes tedious.
MainActivity
↓
ViewModel
↓
Repository
↓
Api
↓
Client
↓
Cache
↓
Database
Someone has to build this graph.
Dagger automates it.
6. Dependency Graph
Imagine:
LoginViewModel
↓
LoginRepository
↓
ApiService
↓
Retrofit
↓
OkHttpClient
This is a dependency graph.
Dagger generates code to build it.
Not at runtime.
At compile time.
That’s one reason Dagger is fast.
Part 6 — Hilt
Dagger is powerful.
But historically it required lots of setup.
Hilt is Google’s opinionated layer on top of Dagger.
Think:
Dagger
↓
Hilt
Hilt removes much of the boilerplate while still using Dagger under the hood.
7. Hilt Flow
Imagine:
Activity
↓
ViewModel
↓
Repository
↓
Api
Without Hilt:
You manually build everything.
With Hilt:
Activity
↓
@Inject
↓
Automatically Provided
The dependency graph is generated for you.
Part 7 — Components
One of the hardest Hilt concepts.
A component is a container that owns objects for a certain lifetime.
Imagine a hierarchy:
SingletonComponent
│
▼
ActivityRetainedComponent
│
▼
ViewModelComponent
│
▼
ActivityComponent
│
▼
FragmentComponent
│
▼
ViewComponent
Each component lives for a different duration.
SingletonComponent
Lives as long as the application.
Perfect for:
- Retrofit
- Room Database
- Analytics
- SharedPreferences
- Repositories (when appropriate)
Only one instance exists.
ActivityComponent
Lives as long as one Activity instance.
Destroyed when the Activity is destroyed.
Useful for Activity-scoped dependencies.
ViewModelComponent
Lives as long as the ViewModel.
Perfect for:
- Use Cases
- Screen-specific state holders
- Screen repositories if appropriate
FragmentComponent
Lives with a Fragment.
Destroyed when the Fragment is destroyed.
Why Scopes Matter
Suppose Retrofit is unscoped.
Every injection creates:
Retrofit
↓
New OkHttp
↓
New Cache
Wasteful.
Instead:
Singleton
↓
One Retrofit
↓
Shared Everywhere
Efficient.
Part 8 — Modules
Some classes cannot use constructor injection.
Example:
Retrofit.
You don’t own its constructor configuration.
Instead:
@Module
tells Hilt:
“Here’s how to build this dependency.”
Example (conceptually):
@Module
object NetworkModule {
@Provides
fun provideRetrofit(): Retrofit
}
@Provides
Use when:
You must execute code to create the object.
Example:
Retrofit.Builder()
↓
baseUrl()
↓
converterFactory()
↓
build()
You can’t annotate Retrofit’s constructor.
So you provide it.
@Binds
Suppose:
UserRepository
↓
UserRepositoryImpl
Hilt needs to know:
Which implementation satisfies the interface?
Use:
@Binds
It maps:
Interface
↓
Implementation
without manually creating the object.
@Inject Constructor
If you own the class:
class UserRepository @Inject constructor(
private val api: ApiService
)
No module required.
Hilt can construct it automatically.
Rule of thumb:
- Own the class? → Prefer
@Injectconstructor. - Don’t own the class? → Use
@Provides.
Part 9 — Qualifiers
Suppose:
Two Retrofit instances.
Public API
Private API
Both have type:
Retrofit
How does Hilt know which one you want?
Use qualifiers.
Example:
@PublicApi
@PrivateApi
Now injections are unambiguous.
Part 10 — Assisted Injection
Sometimes not every parameter comes from DI.
Example:
Repository
↓
Injected
ProductId
↓
Runtime Value
The runtime value isn’t known beforehand.
Assisted injection allows mixing injected dependencies with runtime parameters.
You’ll commonly encounter this with ViewModels that need runtime arguments or factories for complex objects.
Part 11 — Entry Points
Most Android classes support Hilt directly.
Some don’t.
Example:
- ContentProvider
- Some third-party callbacks
Entry Points allow those classes to access the dependency graph.
They’re an advanced feature used when normal injection isn’t available.
Part 12 — Hilt in a Modern App
Typical dependency graph:
App
│
▼
SingletonComponent
│
├── Retrofit
├── OkHttpClient
├── RoomDatabase
├── Preferences
└── UserRepository
│
▼
LoginViewModel
│
▼
LoginScreen
Notice the direction:
Dependencies flow downward.
No class manually creates the objects it depends on.
Real-World Example
Imagine a weather app.
WeatherScreen
│
▼
WeatherViewModel
│
▼
WeatherRepository
┌─┴───────────┐
▼ ▼
WeatherApi WeatherDatabase
│ │
└──────┬──────┘
▼
Weather Data
With Hilt:
WeatherApiis provided by aNetworkModule.WeatherDatabaseis provided by aDatabaseModule.WeatherRepositoryuses constructor injection.WeatherViewModelreceives the repository automatically.WeatherScreenobtains the ViewModel using Hilt integration.
No manual wiring.
Common Mistakes
❌ Creating dependencies inside ViewModels
val repository = UserRepository()
This defeats DI.
❌ Making everything a Singleton
Singletons should represent application-wide state or expensive shared resources.
Don’t use them indiscriminately.
❌ Using field injection everywhere
Prefer constructor injection whenever possible.
❌ Putting business logic inside DI modules
Modules should construct objects, not implement business rules.
❌ Ignoring interfaces
Depending directly on concrete implementations reduces flexibility and makes testing harder.
Mental Model
Think of a movie production.
Without DI:
Actor
↓
Builds Camera
↓
Builds Lights
↓
Builds Microphone
↓
Acts
Ridiculous.
With DI:
Production Team
↓
Provides Camera
↓
Provides Lights
↓
Provides Microphone
↓
Actor Acts
The actor focuses on acting.
The production team provides the tools.
Your ViewModel should be the actor—not the equipment manager.
Best Practices
- Prefer constructor injection.
- Keep dependencies immutable (
val). - Use interfaces for abstractions.
- Scope expensive objects appropriately.
- Use
@Injectconstructors whenever you own the class. - Use
@Providesfor third-party libraries. - Use
@Bindsfor interface-to-implementation mappings. - Keep DI modules focused solely on object creation.
- Design dependencies as a directed graph, not a web of cyclic references.
Interview Questions
- What is a dependency?
- What problems does Dependency Injection solve?
- Explain Inversion of Control.
- Compare constructor, field, and method injection.
- Why is constructor injection generally preferred?
- What is the difference between Dagger and Hilt?
- What is a dependency graph?
- Explain the purpose of Hilt Components and Scopes.
- When should you use
@Inject,@Provides, and@Binds? - What problem do qualifiers solve?
Module 12 Summary
You now understand the object wiring strategy behind modern Android applications:
- Dependencies are collaborators that a class needs.
- Dependency Injection separates object creation from object usage.
- Inversion of Control shifts construction responsibility to a container.
- Constructor injection is the preferred pattern.
- Dagger generates dependency graphs at compile time.
- Hilt simplifies Dagger integration with Android lifecycles.
- Scopes control object lifetimes.
- Modules define how to create objects you don’t own.
- Qualifiers resolve ambiguity between multiple implementations.
With Modules 10 (Architecture), 11 (Concurrency), and 12 (Dependency Injection) completed, you now understand the three pillars of a modern Android codebase:
- How it’s organized (MVVM & Clean Architecture).
- How work executes (Coroutines & Flow).
- How objects are created and connected (Hilt & Dagger).
Next Module: Networking (Retrofit, OkHttp, REST APIs & Serialization)
In Module 13, we’ll trace a network request from the moment the user taps a button all the way to receiving JSON from a remote server.
We’ll cover:
- The HTTP protocol (requests, responses, methods, status codes).
- REST APIs and common endpoint design.
- JSON serialization/deserialization.
- Retrofit architecture and internals.
- OkHttp interceptors, authenticators, and connection pooling.
- Error handling and retry strategies.
- Authentication (Bearer tokens, OAuth concepts).
- Logging, timeouts, and caching.
- Best practices for production networking.
This module will connect everything you’ve learned so far—ViewModel, Coroutines, Flow, Repositories, and DI—into a complete end-to-end data flow from the UI to a backend service.