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?

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

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