πŸ›οΈ Android Architecture and Clean Code

Dependency Injection with Hilt

Dependency injection sounds abstract until you try to test a ViewModel that creates its own Retrofit client. Hilt, Android's recommended DI library built on Dagger, wires your object graph at compile time with very little code. Here's how to set it up and use it well.

✍️ Yunok Tech πŸ“… Updated ⏱ 3 min read 🎯 Intermediate
On this page
  1. Why DI, and why Hilt
  2. Setup
  3. How Hilt builds the graph
  4. Modules: teaching Hilt to create things
  5. Qualifiers: two of the same type
  6. ViewModels in Compose
  7. Testing
  8. Practical guidance
  9. Common mistakes
  10. Frequently asked questions
  11. Conclusion

Dependency injection (DI) means objects receive their dependencies instead of creating them. A ViewModel that receives a WeatherRepository can be given a fake in tests and the real one in the app. Hilt automates the wiring and manages object lifetimes tied to Android components.

Why DI, and why Hilt

Without DI: hard to test and changeKotlin
class WeatherViewModel : ViewModel() {
    private val repository = WeatherRepositoryImpl(
        Retrofit.Builder().baseUrl(BASE_URL).build().create(WeatherApi::class.java),
        Room.databaseBuilder(/* needs a Context... */).build().weatherDao(),
    )
}
With DI: dependencies are parametersKotlin
@HiltViewModel
class WeatherViewModel @Inject constructor(
    private val repository: WeatherRepository,
) : ViewModel()

Hilt generates the Dagger code at compile time, so wiring errors are caught at build time and there's no reflection at runtime. It also understands Android lifecycles: Application, Activity, ViewModel, Fragment and Service.

Setup

build.gradle.kts (app)Gradle KTS
plugins {
    id("com.google.devtools.ksp")
    id("com.google.dagger.hilt.android")
}
dependencies {
    implementation("com.google.dagger:hilt-android:<hilt-version>")
    ksp("com.google.dagger:hilt-compiler:<hilt-version>")
    implementation("androidx.hilt:hilt-navigation-compose:<version>")   // hiltViewModel() in Compose
}
App.kt and MainActivity.ktKotlin
@HiltAndroidApp
class App : Application()

@AndroidEntryPoint
class MainActivity : ComponentActivity() { /* ... */ }

How Hilt builds the graph

Hilt component hierarchy and scopes SingletonComponent lives as long as the application and holds singletons such as Retrofit, OkHttp, the Room database and repositories. ActivityRetainedComponent survives configuration changes. ViewModelComponent creates ViewModel dependencies. ActivityComponent and FragmentComponent are tied to UI lifecycles. SingletonComponent @SingletonOkHttp, Retrofit, Room, repositories ActivityRetainedComponent @ActivityRetainedScoped ViewModelComponent@ViewModelScoped, SavedStateHandle ActivityComponent β†’ Fragment…@ActivityScoped, UI-bound objects
Child components can use anything from their parents. Scope an object to the shortest lifetime that makes sense.

Modules: teaching Hilt to create things

Classes you own get @Inject constructor. For interfaces and third-party classes, write modules:

di/NetworkModule.ktKotlin
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {

    @Provides @Singleton
    fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
        .connectTimeout(15, TimeUnit.SECONDS)
        .build()

    @Provides @Singleton
    fun provideRetrofit(client: OkHttpClient, json: Json): Retrofit = Retrofit.Builder()
        .baseUrl("https://api.example.com/")
        .client(client)
        .addConverterFactory(json.asConverterFactory("application/json".toMediaType()))
        .build()

    @Provides @Singleton
    fun provideJson(): Json = Json { ignoreUnknownKeys = true }

    @Provides
    fun provideWeatherApi(retrofit: Retrofit): WeatherApi = retrofit.create(WeatherApi::class.java)
}

@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
    @Binds @Singleton
    abstract fun bindWeatherRepository(impl: DefaultWeatherRepository): WeatherRepository
}
@Binds vs @Provides

Use @Binds to map an interface to an implementation that has an @Inject constructor. It's less code and more efficient. Use @Provides when you must call builders or factories.

Qualifiers: two of the same type

Dispatcher qualifiersKotlin
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher

@Module @InstallIn(SingletonComponent::class)
object DispatchersModule {
    @Provides @IoDispatcher fun io(): CoroutineDispatcher = Dispatchers.IO
    @Provides @DefaultDispatcher fun default(): CoroutineDispatcher = Dispatchers.Default
}

class ImageRepository @Inject constructor(@IoDispatcher private val io: CoroutineDispatcher)

ViewModels in Compose

ScreenKotlin
@Composable
fun WeatherRoute(viewModel: WeatherViewModel = hiltViewModel()) { /* ... */ }

For runtime arguments that aren't in SavedStateHandle, Hilt supports assisted injection for ViewModels with @AssistedInject and an assisted factory. Check the Hilt docs for the current Compose API.

Testing

Replace bindings in instrumented testsKotlin
@UninstallModules(RepositoryModule::class)
@HiltAndroidTest
class WeatherScreenTest {
    @get:Rule(order = 0) val hilt = HiltAndroidRule(this)
    @get:Rule(order = 1) val compose = createAndroidComposeRule<MainActivity>()

    @BindValue @JvmField val repo: WeatherRepository = FakeWeatherRepository()

    @Test fun showsForecast() { /* ... */ }
}

For plain unit tests you often don't need Hilt at all: construct the ViewModel directly with fakes. That's the main benefit of constructor injection.

Practical guidance

Small app

Hilt with a few modules: network, database, repositories. Most classes use @Inject constructor.

Multi-module app

Each module contributes its own Hilt modules, and the :app module assembles the graph.

KMP or lighter needs

Consider Koin or manual DI. Hilt is Android-only and annotation-based.

Common mistakes

  • Scoping everything @Singleton, which keeps objects alive forever. Scope only what must be shared.
  • Injecting Activity Context into singletons, which leaks. Use @ApplicationContext.
  • Field injection everywhere. Prefer constructor injection, and use field injection only in Android classes Hilt instantiates.
  • Huge modules providing everything. Split them by feature or layer.

Frequently asked questions

Hilt or Koin?

Hilt does compile-time validation and is Google's recommendation for Android. Koin is a lightweight runtime DSL that works on Kotlin Multiplatform. Both are viable, so choose based on platform and team preference.

Does Hilt slow down builds?

Annotation processing adds build time. Using KSP instead of kapt helps, and modularisation limits recompilation.

Is manual DI ever better?

For very small apps or libraries, a simple manual container can be enough. Hilt pays off as the graph grows.

Conclusion

Hilt gives Android apps compile-time-checked dependency injection with lifecycle-aware scopes. Use constructor injection, @Binds for interfaces, @Provides for builders, qualifiers for duplicate types, and the shortest sensible scope.

Next step: decide where business logic lives in Use Cases and Domain Layers.

Official references

πŸ“± Apps built by Yunok Tech

Real Android apps on Google Play, free to download.

See all our apps β†’

Ready to build real Android applications?

Architecture makes the most sense when you apply it to a real project and see why each boundary exists. Learn how maintainable Android applications are designed by building one.

Move beyond theory and strengthen your Android development skills with Yunoktech's Android course. Learn core concepts, practical implementation, modern Android development techniques, and the reasoning behind building maintainable applications.

Explore the Android course and take your next step as an Android developer.

Share: LinkedIn X / Twitter WhatsApp