03 January 2019
Early in my Android career, I wrote ViewModels that created their own repository instances, repositories that created their own Retrofit services, and services that created their own OkHttpClients. Every class was responsible for building its own dependencies. The code worked, but testing was impossible without running a real server, swapping implementations meant touching dozens of files, and adding a new feature meant tracing through a web of constructor calls to figure out what depended on what. I didnāt know it at the time, but I was experiencing the exact problem that Dependency Injection was designed to solve.
Dependency Injection is a simple concept with a complicated reputation. At its core, it means a class receives its dependencies from the outside instead of creating them internally. Thatās it. The class doesnāt know how to build a UserRepository ā it just declares that it needs one, and something else provides it. This one shift ā from āI create what I needā to āI declare what I needā ā has cascading effects on testability, flexibility, and maintainability.
When a class creates its own dependencies, three things go wrong. First, you canāt test the class in isolation because you canāt substitute a fake dependency. If your OrderViewModel creates a RealOrderRepository that calls a real API, your unit tests hit the network. Second, swapping implementations requires modifying the class itself. If you want to switch from Retrofit to Ktor, you touch every class that creates a Retrofit instance. Third, the dependency graph becomes implicit ā thereās no single place to see what depends on what.
// Without DI ā the ViewModel creates everything it needs
class OrderViewModel : ViewModel() {
// Tightly coupled ā can't swap these for testing
private val httpClient = OkHttpClient.Builder().build()
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.myapp.com")
.client(httpClient)
.build()
private val api = retrofit.create(OrderApi::class.java)
private val repository = OrderRepository(api)
fun loadOrders() {
viewModelScope.launch {
val orders = repository.getOrders()
_uiState.value = UiState.Success(orders)
}
}
}
This ViewModel is impossible to unit test without hitting the real API. Itās also wasteful ā every OrderViewModel creates its own OkHttpClient, Retrofit, and OrderApi instance. In a real app, you want a single shared OkHttpClient with connection pooling, not one per screen.
Before reaching for Dagger or Hilt, itās worth understanding manual DI. Constructor injection ā passing dependencies as constructor parameters ā is DI in its purest form. No framework, no code generation, no magic.
// With manual DI ā dependencies are passed in
class OrderViewModel(
private val repository: OrderRepository
) : ViewModel() {
fun loadOrders() {
viewModelScope.launch {
val orders = repository.getOrders()
_uiState.value = UiState.Success(orders)
}
}
}
// A simple container that wires everything together
class AppContainer(private val context: Context) {
private val httpClient = OkHttpClient.Builder()
.addInterceptor(AuthInterceptor(context))
.build()
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.myapp.com")
.client(httpClient)
.addConverterFactory(MoshiConverterFactory.create())
.build()
private val orderApi = retrofit.create(OrderApi::class.java)
val orderRepository = OrderRepository(orderApi)
}
Now OrderViewModel doesnāt know or care how OrderRepository is built. In tests, you pass a FakeOrderRepository. In production, the AppContainer provides the real one. The ViewModel went from untestable to trivially testable with one change ā moving dependency construction outside the class.
Manual DI works well for small apps with a handful of dependencies. But as your app grows to 50+ classes with complex dependency graphs, manually wiring everything becomes tedious and error-prone. You have to manage lifetimes (should this be a singleton or a new instance?), handle scoping (should the payment flow share a single PaymentManager instance?), and remember the construction order. Thatās where DI frameworks come in.
Before talking about Dagger and Hilt, itās worth distinguishing DI from another pattern itās often confused with: the Service Locator. Both provide dependencies from a central place, but they work differently.
A Service Locator is a registry that classes reach into to get their dependencies. The class actively looks up what it needs. A Dependency Injection container pushes dependencies to the class ā the class passively receives them through its constructor.
// Service Locator ā the class reaches INTO the container
class OrderViewModel : ViewModel() {
private val repository = ServiceLocator.get<OrderRepository>()
}
// Dependency Injection ā the class RECEIVES from outside
class OrderViewModel(
private val repository: OrderRepository // pushed in
) : ViewModel()
The Service Locator pattern has two problems. First, dependencies are hidden ā looking at the classās constructor doesnāt tell you what it needs. You have to read the body to discover that it depends on OrderRepository. Second, testing requires setting up the global locator with test fakes before every test and cleaning up after, which creates shared mutable state between tests. Constructor injection makes dependencies explicit and testing straightforward.
Googleās official Android architecture guidance recommends constructor injection over Service Locator for these reasons.
Dagger is the most widely used DI framework in Android. It generates all the wiring code at compile time through annotation processing, so thereās no runtime reflection and no performance overhead. The tradeoff is that Daggerās API surface is large and its learning curve is steep.
Hilt is Googleās opinionated layer on top of Dagger that provides standard component scopes for Android (Application, Activity, Fragment, ViewModel). It eliminates most of the boilerplate that made Dagger hard to set up and makes the common patterns easy.
@HiltAndroidApp
class MyApplication : Application()
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.build()
}
@Provides
@Singleton
fun provideOrderApi(client: OkHttpClient): OrderApi {
return Retrofit.Builder()
.baseUrl("https://api.myapp.com")
.client(client)
.addConverterFactory(MoshiConverterFactory.create())
.build()
.create(OrderApi::class.java)
}
}
// Repository uses constructor injection ā no annotations on the class
class OrderRepository @Inject constructor(
private val orderApi: OrderApi,
private val orderDao: OrderDao
) {
suspend fun getOrders(): List<Order> {
return try {
val remote = orderApi.fetchOrders()
orderDao.insertAll(remote.map { it.toEntity() })
orderDao.getAllOrders().map { it.toDomain() }
} catch (e: IOException) {
orderDao.getAllOrders().map { it.toDomain() }
}
}
}
// ViewModel ā Hilt provides the dependencies automatically
@HiltViewModel
class OrderViewModel @Inject constructor(
private val repository: OrderRepository
) : ViewModel() {
val orders = flow {
emit(UiState.Loading)
try {
emit(UiState.Success(repository.getOrders()))
} catch (e: Exception) {
emit(UiState.Error(e.message ?: "Unknown error"))
}
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState.Loading)
}
The @Singleton scope means one instance for the entire application lifetime. @InstallIn(SingletonComponent::class) tells Hilt which component owns these bindings. For Activity-scoped or Fragment-scoped dependencies, youād use ActivityComponent or FragmentComponent. Hilt creates and destroys instances according to these scopes automatically.
DI isnāt free. Dagger/Hilt add build time (the annotation processing generates code for every module), increase the learning curve for new team members, and add complexity to your project setup. For a small app with 5-10 classes, manual constructor injection is simpler, faster to build, and easier to understand.
I reach for Hilt when: the app has more than two or three feature modules, the dependency graph has cross-cutting concerns (logging, analytics, auth tokens shared across features), or the team needs standardized patterns that new developers can follow without understanding the full graph.
I stick with manual DI when: the app is small, the team is one or two people, or when Iām building a library (libraries shouldnāt force a DI framework on their consumers).
Hereās what I think most developers get wrong about DI: they focus on the framework instead of the principle. Dagger, Hilt, Koin, manual injection ā these are implementation details. The real value of DI is that it forces you to define clear boundaries between components. When a class declares its dependencies in its constructor, itās documenting its contract with the rest of the system. When you wire dependencies in a module, youāre explicitly defining the shape of your applicationās dependency graph.
The best DI code Iāve worked with didnāt feel like āDI codeā at all. It was just classes with clean constructors, interfaces that defined contracts, and one place (a module or a container) that wired everything together. The worst DI code Iāve seen had so many Dagger annotations, scopes, qualifiers, and multi-bindings that the dependency graph was harder to understand than the business logic it was supposed to simplify.
Start with constructor injection. Move to Hilt when manual wiring gets painful. And remember that the goal isnāt to use a DI framework ā itās to write code where every class is testable, every dependency is explicit, and swapping implementations is trivial.
Thanks for reading!