Dagger Does Not Invert Your Dependencies For You
Dagger and Hilt are excellent tools.
They can build large object graphs, enforce constructor injection, scope dependencies, reduce manual wiring, and make Android apps much easier to assemble.
But they do not decide whether your dependency direction is healthy.
That part is still architecture.
This distinction matters because a codebase can be injectable, mockable, and still tightly coupled to the wrong things.
Mockito can make a dependency easy to fake in a test. Dagger can make it easy to provide at runtime. Neither of them tells you whether the feature should depend on that thing in the first place.
#Mockable Does Not Mean Decoupled
Imagine this ViewModel:
@HiltViewModel
class CheckoutViewModel @Inject constructor(
private val checkoutApi: CheckoutApi,
private val analytics: FirebaseAnalytics,
private val navController: NavController
) : ViewModel() {
fun placeOrder(cart: Cart) {
viewModelScope.launch {
try {
val response = checkoutApi.placeOrder(cart.toRequest())
analytics.logEvent("checkout_succeeded", null)
navController.navigate("checkout/success/${response.orderId}")
} catch (error: Throwable) {
analytics.logEvent("checkout_failed", null)
_state.value = CheckoutUiState.Error("Could not place order.")
}
}
}
}
You can mock CheckoutApi.
You can mock FirebaseAnalytics.
You can probably hide NavController behind another mockable type too.
But the ViewModel still knows too much.
It knows that checkout is an HTTP operation. It knows how to build the request. It knows the analytics events. It knows the app route. It maps errors into UI state. It coordinates the whole feature.
The fact that every dependency is injectable does not change the responsibility problem.
#Dagger Wires the Graph
Dagger answers an important question:
When something asks for this dependency, how do we build it?
That is useful. In a large Android app, it is more than useful. It is often necessary.
But architecture has to answer a different question:
Which parts of the app should know about which decisions?
Those are not the same question.
This code uses Dagger, but the direction is still suspicious:
class ProductDetailsViewModel @Inject constructor(
private val retrofitService: ProductRetrofitService,
private val roomDao: ProductDao,
private val firebaseAnalytics: FirebaseAnalytics
) : ViewModel()
The ViewModel now depends on networking, persistence, and analytics details. Hilt can provide all of them. Mockito can fake all of them. The feature is still coupled to infrastructure choices.
The issue is not how the dependencies are created.
The issue is that presentation code is depending on details it should not need to understand.
#Inversion Is About Direction
Dependency inversion is not "use an interface so Mockito can mock it."
It is about making high-level policy depend on stable abstractions instead of low-level details.
For checkout, the ViewModel should not care whether orders are placed through Retrofit, GraphQL, a local queue, a fake, or a retrying offline implementation.
It can depend on something closer to the feature's language:
interface PlaceOrderOutput {
fun placeOrderDidStart()
fun placeOrderDidSucceed(orderId: OrderId)
fun placeOrderDidFail(reason: CheckoutFailure)
}
class PlaceOrderUseCase @Inject constructor(
private val payments: Payments,
private val orders: Orders
) {
suspend fun execute(cart: Cart, output: PlaceOrderOutput) {
output.placeOrderDidStart()
try {
val payment = payments.authorize(cart.total)
val order = orders.create(cart, payment)
output.placeOrderDidSucceed(order.id)
} catch (error: PaymentDeclined) {
output.placeOrderDidFail(CheckoutFailure.PaymentDeclined)
} catch (error: Throwable) {
output.placeOrderDidFail(CheckoutFailure.Unknown)
}
}
}
The use case speaks in checkout outcomes. It does not expose Retrofit responses or database rows to presentation code.
The ViewModel can map those outcomes into state and screen events:
class CheckoutViewModel :
ViewModel(),
PlaceOrderOutput {
private val _state = MutableStateFlow(CheckoutUiState.Idle)
val state: StateFlow<CheckoutUiState> = _state.asStateFlow()
private val _events = Channel<CheckoutScreenEvent>()
val events: Flow<CheckoutScreenEvent> = _events.receiveAsFlow()
override fun placeOrderDidStart() {
_state.value = CheckoutUiState.Loading
}
override fun placeOrderDidSucceed(orderId: OrderId) {
_state.value = CheckoutUiState.Idle
_events.trySend(CheckoutScreenEvent.OrderPlaced(orderId))
}
override fun placeOrderDidFail(reason: CheckoutFailure) {
_state.value = CheckoutUiState.Error(
message = "Could not place order."
)
}
}
Now the ViewModel does not know about Retrofit, Room, Firebase, or navigation routes.
It knows how to present checkout outcomes.
That is a better boundary.
#Where Does Hilt Fit?
Hilt still matters. It just belongs to wiring, not to the core design decision.
class CheckoutFeatureFactory @Inject constructor(
private val placeOrder: PlaceOrderUseCase
) {
fun create(
onEvent: (CheckoutScreenEvent) -> Unit
): CheckoutRoute {
val viewModel = CheckoutViewModel()
return CheckoutRoute(
viewModel = viewModel,
onPlaceOrder = { cart ->
placeOrder.execute(cart, viewModel)
},
onEvent = onEvent
)
}
}
In a real Android app, you may let Hilt create the ViewModel too. The exact mechanics can vary.
The architectural point is the same: Hilt can assemble the objects, but the feature should still depend on meaningful boundaries.
If Hilt wires a ViewModel directly to a Retrofit service, the code is injectable but still pointing at infrastructure.
If Hilt wires a use case to repositories and the ViewModel only maps outputs into UI state, the dependency direction is healthier.
#"But Mockito Makes This Easy"
Mockito makes some tests easier to write.
That is valuable, but it is not the same as architecture.
A test like this can pass:
@Test
fun `places order successfully`() = runTest {
whenever(api.placeOrder(any())).thenReturn(OrderResponse("123"))
viewModel.placeOrder(cart)
verify(analytics).logEvent("checkout_succeeded", null)
verify(navController).navigate("checkout/success/123")
}
But what did the test protect?
It protected the fact that this ViewModel calls this API, this analytics SDK, and this route string.
That may be useful if those are truly the ViewModel's promises. But often they are implementation details that leaked into the test because the ViewModel owns too much.
A healthier test can focus on the ViewModel's actual presentation promise:
@Test
fun `maps successful checkout to screen event`() = runTest {
val viewModel = CheckoutViewModel()
viewModel.placeOrderDidSucceed(OrderId("123"))
assertEquals(CheckoutUiState.Idle, viewModel.state.value)
assertEquals(
CheckoutScreenEvent.OrderPlaced(OrderId("123")),
viewModel.events.first()
)
}
The use case can be tested separately:
@Test
fun `creates order after authorizing payment`() = runTest {
val output = RecordingPlaceOrderOutput()
val useCase = PlaceOrderUseCase(
payments = SuccessfulPayments(),
orders = InMemoryOrders()
)
useCase.execute(cart, output)
assertEquals(
listOf("started", "succeeded"),
output.recordedEvents
)
}
Now tests follow responsibilities instead of freezing one giant interaction script.
#Dagger Modules Can Hide Coupling Too
Sometimes a Dagger module gives a dependency a nicer name without changing the design:
@Provides
fun provideCheckoutApi(retrofit: Retrofit): CheckoutApi {
return retrofit.create(CheckoutApi::class.java)
}
That is fine as wiring. But it does not create an application boundary.
If presentation code depends on CheckoutApi, the feature still speaks in backend terms. A repository or use case can translate that detail into app language:
class RemoteOrders @Inject constructor(
private val api: CheckoutApi
) : Orders {
override suspend fun create(
cart: Cart,
payment: PaymentAuthorization
): Order {
val response = api.placeOrder(
PlaceOrderRequest.from(cart, payment)
)
return Order(id = OrderId(response.orderId))
}
}
Now Retrofit belongs behind the Orders boundary. The rest of the checkout feature does not need to know the transport shape.
#What You Should Remember
Dagger and Hilt are composition tools. Mockito is a testing tool.
They are useful, but they do not automatically give you dependency inversion.
Dependency inversion is about direction:
Feature policy -> app boundary -> infrastructure detail
not:
ViewModel -> Retrofit
ViewModel -> Room
ViewModel -> Firebase
ViewModel -> NavController
If a dependency is easy to mock but still leaks infrastructure, routing, or SDK decisions into presentation code, the test may be convenient while the architecture remains expensive to change.
Use Dagger to wire the graph.
Use architecture to decide what the graph should mean.