Lesson 26 of 83 intermediate

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

Manual DI is like hand-delivering every package yourself — you control everything but it doesn't scale. Dagger is like building your own automated delivery network from scratch — incredibly powerful, zero magic, but weeks of setup. Hilt is like using FedEx — it's Dagger under the hood but someone already built the trucks, routes, and depots; you just label the packages and they arrive.

What is it?

Manual DI means hand-wiring dependencies in an AppContainer instead of Hilt or Dagger — full control, zero codegen, but every constructor wired by hand.

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

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. 1. @HiltAndroidApp on the Application class is the entry point — Hilt generates the root component here that owns all @Singleton dependencies.
  2. 2. NetworkModule is annotated @InstallIn(SingletonComponent::class) — all @Provides methods here produce @Singleton-scoped objects unless overridden.
  3. 3. provideOkHttpClient receives AuthInterceptor and HttpLoggingInterceptor as parameters — Hilt injects these automatically from other @Provides methods or @Inject constructors.
  4. 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. 5. RepositoryModule is abstract — @Binds only works in abstract classes/objects and generates no runtime code, unlike @Provides which generates a method call.
  6. 6. @Binds abstract fun bindOrderRepository(impl: OrderRepositoryImpl): OrderRepository — Hilt substitutes impl wherever OrderRepository is requested.
  7. 7. OrderRepositoryImpl uses @Inject constructor — all three params (api, dao, mapper) are automatically provided by Hilt without any manual wiring.
  8. 8. @HiltViewModel on OrderViewModel enables the auto-generated factory — viewModels() delegate in the Activity uses this factory without any boilerplate.
  9. 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. 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?
Check the component scope vs dependency scope, missing annotations, context injection, and Activity setup.
Show answer
Bug 1: Retrofit is installed in ActivityComponent but UserRepository is @Singleton — a singleton cannot depend on an activity-scoped dependency because the Activity can be destroyed while the singleton lives on. Fix: move AppModule to @InstallIn(SingletonComponent::class). Bug 2: provideRetrofit() is missing @Singleton annotation — without it, a new Retrofit instance is created every time one is requested. Fix: add @Singleton to provideRetrofit(). Bug 3: Context is injected as a raw type — this will fail or inject the wrong context. Fix: use @ApplicationContext Context for a singleton (application lifetime context) or @ActivityContext Context for activity-scoped dependencies. Hilt provides qualified contexts. Bug 4: MainActivity is missing @AndroidEntryPoint annotation — without it, Hilt does not generate the injection infrastructure for this Activity, so @Inject lateinit var repo will never be populated and will throw UninitializedPropertyAccessException.

Explain like I'm 5

Imagine you run a burger restaurant. Without DI, every chef makes their own ketchup from scratch — that's crazy. Manual DI is the head chef making one big batch and handing it out. Dagger is a robot that reads a recipe book and automatically prepares every ingredient for every chef in the right amount. Hilt is that same robot but pre-programmed specifically for burger restaurants — you just tell it 'I need ketchup' and it knows exactly when and how much to bring.

Fun fact

Dagger was originally created at Square in 2012 as a compile-time alternative to Guice's runtime reflection. Dagger 2 (Google fork, 2014) eliminated all reflection — every dependency is resolved at compile time. Hilt (2020) added Android-specific components on top. The entire Android DI ecosystem traces back to one engineer's frustration with slow startup times from runtime DI.

Hands-on challenge

Take a class that constructs its own Retrofit instance and Room database inside its body (no DI). Refactor it step-by-step: first to Manual DI with an AppContainer, then to Hilt with proper @Module/@Provides/@Binds. Identify the correct scope for each dependency. Write the test double strategy for each approach.

More resources

Open interactive version (quiz + challenge) ← Back to course: Android Interview Mastery