By lesson 64 FeeTracker and RegisterSync had a database, a repository, a sync worker, use cases and ViewModels. Every one of those objects needs other objects to work, and someone has to create them in the right order and hand them over. So far you did that by hand in an AppContainer (lesson 30). That works for a small app. As the object graph grows, the manual wiring becomes long, repetitive and easy to get wrong.
Dependency injection (DI) is the technique of giving a class the things it needs instead of letting it create them. Hilt is Google's recommended DI library for Android: it generates the wiring code at build time. This lesson also covers modularisation, splitting an app into Gradle modules so that the dependency rules of the previous lessons are enforced by the build.
It completes the Modern Android App Architecture programme (https://developer.android.com/courses/android-architecture), drawing on https://developer.android.com/training/dependency-injection, the Hilt guide (https://developer.android.com/training/dependency-injection/hilt-android) and the modularisation guide (https://developer.android.com/topic/modularization).
By the end you will be able to:
- Explain dependency injection and inversion of control in plain words.
- Set up Hilt with KSP, and use
@HiltAndroidApp,@AndroidEntryPoint,@HiltViewModel,@Inject. - Write modules with
@Providesand@Binds, choose scopes and qualifiers. - Inject dispatchers, Room, Retrofit and WorkManager workers.
- Replace dependencies in tests with
@TestInstallIn. - Design a multi-module project (app, feature and core modules), use
apiversusimplementation, and meet convention plugins.
Big idea: a class should say what it needs in its constructor and not care where it comes from. A DI framework is just a machine that builds the object graph for you.
Honesty note: Hilt and AndroidX Hilt artifacts are renamed and reorganised from time to time (for example, where
hiltViewModel()lives). Version numbers below are placeholders. Always check the Hilt setup page for the current coordinates.
- Layers and the dependency rule (lessons 60 to 63).
- Repositories and data sources (lesson 62).
- The manual app container (lesson 30).
- Interfaces and fakes (lesson 60).
- Gradle Kotlin DSL and version catalogs (lessons 3 and 34).
- WorkManager (lesson 64).
Without DI
class FeeViewModel : ViewModel() {
private val repository = DefaultPaymentsRepository( // the class builds its own dependency
local = RoomPaymentsLocalDataSource(Room.databaseBuilder(...).build().paymentDao()),
remote = RetrofitPaymentsRemoteDataSource(Retrofit.Builder().build().create(PaymentApi::class.java))
)
}
Problems: the ViewModel knows how to build a database and a network client; two ViewModels will build two databases; and you cannot test it with a fake.
With DI (by hand)
class FeeViewModel(private val repository: PaymentsRepository) : ViewModel()
The class declares what it needs. Someone else, the container, decides which implementation to give: the real one in production, a fake in tests. That change of responsibility is called inversion of control.
Why a framework?
For a few classes, manual injection (lesson 30's container) is fine and has no magic. Frameworks pay off when:
- there are many classes and several scopes (app, screen, request);
- you need per-screen lifetimes, such as one ViewModel dependency per ViewModel;
- you want compile-time checking (a missing binding is a build error, not a crash);
- you need test replacement of whole parts of the graph.
Hilt in one picture
@HiltAndroidApp Application
│ creates
▼
SingletonComponent (lives as long as the app)
├─ Database, Retrofit, Repositories (@Singleton)
└─ ActivityRetainedComponent
└─ ViewModelComponent
└─ @HiltViewModel classes get their constructor parameters here
Hilt builds a component for each Android class that has its own lifetime (application, activity, ViewModel, fragment, service) and puts your bindings in the right one.
Version catalog
[versions]
hilt = "<latest stable>"
ksp = "<matches your Kotlin version>"
hiltNavigationCompose = "<latest stable>"
[libraries]
hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" }
hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hilt" }
hilt-navigation-compose = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationCompose" }
hilt-android-testing = { group = "com.google.dagger", name = "hilt-android-testing", version.ref = "hilt" }
[plugins]
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
Hilt supports KSP (the same processor used for Room in lesson 34), so you do not need KAPT. (Check the Hilt page: very old guides still show KAPT.)
Build files
Project-level build.gradle.kts:
plugins {
alias(libs.plugins.hilt) apply false
alias(libs.plugins.ksp) apply false
}
App-level build.gradle.kts:
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.hilt.navigation.compose) // hiltViewModel() for Compose; where this lives may change, check docs
}
The application class
Every Hilt app needs one annotated Application:
@HiltAndroidApp
class FeeApplication : Application()
Register it with android:name=".FeeApplication" in the manifest. This annotation generates the base component and triggers code generation; without it nothing works.
Entry points
Android classes that Hilt can inject into are annotated with @AndroidEntryPoint:
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { AppTheme { FeeNavHost() } }
}
}
Supported entry points include Application, Activity (a ComponentActivity subclass), Fragment, View, Service and BroadcastReceiver. In a Compose app you usually need only the Activity.
@Inject constructor
If Hilt can create a class by calling its constructor, just mark the constructor:
class CalculateFeeSummary @Inject constructor() {
operator fun invoke(payments: List<Payment>): FeeSummary { /* ... */ }
}
class GetStudentBalancesUseCase @Inject constructor(
private val studentsRepository: StudentsRepository,
private val paymentsRepository: PaymentsRepository,
@DefaultDispatcher private val defaultDispatcher: CoroutineDispatcher
) { /* ... */ }
Hilt now knows how to build both. Their own dependencies (StudentsRepository, dispatcher) must be provided somewhere (Build 3).
@HiltViewModel
@HiltViewModel
class StudentsViewModel @Inject constructor(
getBalances: GetStudentBalancesUseCase,
private val savedStateHandle: SavedStateHandle // provided automatically
) : ViewModel() {
// as in lesson 61
}
Hilt supplies a SavedStateHandle for any @HiltViewModel. No factory class is needed any more; compare it with the FeeViewModelFactory of lesson 60.
In Compose
import androidx.hilt.navigation.compose.hiltViewModel
@Composable
fun StudentsRoute(viewModel: StudentsViewModel = hiltViewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
StudentsScreen(state)
}
hiltViewModel() creates (or reuses) the ViewModel scoped to the current navigation back-stack entry, so each screen gets its own, and it is cleared when the screen leaves the stack. For a ViewModel shared by a whole nested graph, pass the parent entry as in lesson 55: hiltViewModel(parentEntry).
Hilt cannot construct everything with @Inject. You cannot annotate Room's database builder or Retrofit, and an interface needs an implementation chosen. For that you write modules.
@Binds: an interface to an implementation
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindPaymentsRepository(impl: DefaultPaymentsRepository): PaymentsRepository
@Binds
@Singleton
abstract fun bindStudentsRepository(impl: DefaultStudentsRepository): StudentsRepository
}
DefaultPaymentsRepository has an @Inject constructor; @Binds says "when someone asks for PaymentsRepository, give them that class". @Binds is an abstract function in an abstract class and generates less code than @Provides, so prefer it when you only choose an implementation.
@Provides: objects you build yourself
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): FeeDatabase =
Room.databaseBuilder<FeeDatabase>(context, "fee_database")
.setDriver(AndroidSQLiteDriver())
.build()
@Provides
fun providePaymentDao(db: FeeDatabase): PaymentDao = db.paymentDao()
}
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttp(): OkHttpClient = OkHttpClient.Builder()
.callTimeout(30, TimeUnit.SECONDS)
.build()
@Provides
@Singleton
fun providePaymentApi(client: OkHttpClient): PaymentApi =
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.client(client)
.addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
.build()
.create(PaymentApi::class.java)
}
Notes:
@ApplicationContextis a built-in qualifier that gives the application context (no leak risk).@InstallIn(SingletonComponent::class)puts the binding in the application-wide component.@Provides fun providePaymentDao(db)has no scope: the DAO is cheap, and it asks for the database, which is a singleton.- (Check the Room 3 builder syntax against lesson 34; for Room 2.x use
Room.databaseBuilder(context, FeeDatabase::class.java, "fee_database").)
Scopes
| Scope annotation | Component | Lifetime |
|---|---|---|
@Singleton |
SingletonComponent |
The whole app process |
@ActivityRetainedScoped |
ActivityRetainedComponent |
Survives configuration changes of an activity |
@ViewModelScoped |
ViewModelComponent |
Same instance within one ViewModel |
@ActivityScoped, @FragmentScoped |
Activity / Fragment | Their lifetimes |
Scope only what must be shared or is expensive (database, HTTP client, repositories that hold state). An unscoped binding creates a new instance every time it is injected, which is fine for stateless helpers. Scoping everything keeps objects alive longer than needed.
Qualifiers: two bindings of the same type
You often need several CoroutineDispatchers. A qualifier tells Hilt which one:
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class IoDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class DefaultDispatcher
@Qualifier @Retention(AnnotationRetention.BINARY) annotation class ApplicationScope
@Module
@InstallIn(SingletonComponent::class)
object CoroutinesModule {
@Provides @IoDispatcher
fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@Provides @DefaultDispatcher
fun provideDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@Provides @Singleton @ApplicationScope
fun provideApplicationScope(@DefaultDispatcher dispatcher: CoroutineDispatcher): CoroutineScope =
CoroutineScope(SupervisorJob() + dispatcher)
}
Now repositories ask for @IoDispatcher ioDispatcher: CoroutineDispatcher, which is exactly the injectable dispatcher pattern recommended since lesson 60, and the application scope from lesson 62 comes from one place.
Hilt and WorkManager
SyncWorker from lesson 64 needed a hand-written factory. Hilt offers @HiltWorker:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repository: AttendanceRepository
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result =
if (repository.syncNow().isSuccess) Result.success() else Result.retry()
}
and the application supplies the factory:
@HiltAndroidApp
class FeeApplication : Application(), Configuration.Provider {
@Inject lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}
This needs the androidx.hilt:hilt-work dependency and its KSP compiler. (The exact property name in Configuration.Provider has changed between WorkManager versions: older code uses a getWorkManagerConfiguration() function. Check the current WorkManager documentation. Also disable the default initializer in the manifest, as the guide shows.)
Injecting where Hilt cannot reach: entry points
For classes Hilt does not manage (a ContentProvider, third-party code), use @EntryPoint with EntryPointAccessors. It is an advanced tool; if you can use @AndroidEntryPoint instead, do so.
Assisted injection for runtime parameters
A class that needs both injected dependencies and a runtime value (for example a student id picked on screen) can use @AssistedInject and @AssistedFactory. In a ViewModel, the usual alternative is simpler: read the value from SavedStateHandle, which holds navigation arguments (lesson 55).
DI exists so tests can swap parts. Hilt supports this for instrumented tests.
@Module
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
abstract class FakeRepositoryModule {
@Binds @Singleton
abstract fun bindPayments(fake: FakePaymentsRepository): PaymentsRepository
}
Create a custom test runner that uses Hilt's test application:
class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}
Set testInstrumentationRunner = "com.example.fees.HiltTestRunner" in the module's defaultConfig, then write the test:
@HiltAndroidTest
class StudentsScreenTest {
@get:Rule(order = 0) val hiltRule = HiltAndroidRule(this)
@get:Rule(order = 1) val composeRule = createAndroidComposeRule<MainActivity>()
@Before fun setUp() { hiltRule.inject() }
@Test
fun shows_students_from_fake_repository() {
composeRule.onNodeWithText("Adaeze Okoye").assertExists()
}
}
Hilt replaces RepositoryModule with FakeRepositoryModule for tests only, so the real screen runs against fake data, with no network. For plain unit tests (JUnit on the JVM), you do not need Hilt at all: call constructors directly with fakes, as in lessons 60 to 63. That is the best proof of good DI: your classes work without the framework.
Why split into modules?
As an app grows, one Gradle module (:app) becomes slow to build and easy to tangle. The modularisation guide gives these benefits:
| Benefit | Meaning |
|---|---|
| Reusability | Share a module across apps or form factors |
| Strict visibility control | internal and implementation hide details |
| Customisable delivery | Dynamic feature delivery on Play |
| Scalability and ownership | Teams own modules |
| Faster builds | Gradle builds only changed modules, and in parallel |
| Enforced architecture | The build prevents illegal dependencies |
There are costs: more build files, more configuration, harder refactors across modules. Do not modularise a tiny app. A good trigger is when builds are slow, several developers collide, or you need to reuse code in a second app or target.
A typical structure
:app assembles the app; navigation graph; Hilt application
:feature:students screen, ViewModel (UI layer for one feature)
:feature:payments
:core:model plain Kotlin data models shared everywhere
:core:domain use cases (plain Kotlin / Android library)
:core:data repositories (implementation)
:core:database Room database, DAOs, entities
:core:network Retrofit services, DTOs
:core:ui shared composables, theme
:core:testing fakes and test utilities
Dependency rules:
:app ──► :feature:* ──► :core:domain ──► :core:data ──► :core:database, :core:network
└─► :core:ui └─► :core:model (everyone may use)
- Feature modules depend on core modules, never on each other. Navigation between features goes through routes known to
:appor a small:core:navigationmodule. - Core modules never depend on feature modules.
- The data layer modules know nothing of the UI.
implementation versus api
// :core:data/build.gradle.kts
dependencies {
implementation(project(":core:database")) // hidden from modules that depend on :core:data
implementation(project(":core:network"))
api(project(":core:model")) // exposed: :core:data's public API returns model classes
}
implementation: the dependency is an internal detail. Modules that depend on:core:datado not see:core:database. This speeds builds (changes recompile fewer modules) and enforces the rule that the UI never touches DAOs.api: the dependency is part of your public API (your function signatures expose its types), so consumers see it too. Use sparingly.
Hilt across modules
Each module may contain its own @Module @InstallIn(SingletonComponent::class) classes; Hilt aggregates them in :app. The :app module must be able to see (depend on) all modules that contribute bindings, which is why :app depends on every feature and core module it assembles. Interfaces live in a lower module (for example PaymentsRepository in :core:data's API or in :core:domain), with implementations hidden using internal.
Convention plugins (an introduction)
Ten modules means ten near-identical build.gradle.kts files: compile SDK, Kotlin options, Compose, Hilt. Instead of copying, put the shared setup into convention plugins in an included build (commonly named build-logic) and apply a one-liner in each module:
// :feature:students/build.gradle.kts
plugins {
alias(libs.plugins.myapp.android.feature) // applies library + Compose + Hilt + test setup
}
dependencies {
implementation(project(":core:domain"))
implementation(project(":core:ui"))
}
Writing convention plugins is an advanced Gradle topic. For now, know why they exist, and look at the official Now in Android sample (https://github.com/android/nowinandroid), which uses a modular, offline-first architecture with Hilt, as a real-world reference.
Exercise 1: @Binds or @Provides?
Choose for each: (a) mapping PaymentsRepository to DefaultPaymentsRepository; (b) building a Retrofit instance; (c) providing Dispatchers.IO; (d) exposing a DAO from a database.
(a) @Binds (just an implementation choice). (b) @Provides (you run builder code). (c) @Provides with a qualifier. (d) @Provides, calling db.paymentDao().
Exercise 2: Fix the scope
A teammate annotated every repository, use case and mapper with @Singleton. Explain the downside and what to scope instead.
Singletons live as long as the process, so stateless helpers stay in memory and hidden shared state can leak between screens. Scope only what must be shared or is costly to create: the database, HTTP client and repositories that hold state or caches. Leave use cases, mappers and formatters unscoped (a new light instance each time).
Exercise 3: Module rules
:feature:payments needs a list of students for a drop-down, and a developer adds implementation(project(":feature:students")). What is wrong and what is the better design?
Feature modules should not depend on each other, because that creates tangles and cycles and defeats parallel builds and ownership. Move the shared piece to a core module: for data, expose it through a repository in :core:data (and a model in :core:model); for a shared UI component, put it in :core:ui. Both features then depend on the core module.
Mistake #1: Forgetting @HiltAndroidApp. Fix: annotate the Application and register it in the manifest.
Mistake #2: Forgetting @AndroidEntryPoint on the Activity. Fix: add it; the error is "ViewModel not injected".
Mistake #3: Using KAPT for new projects. Fix: use KSP.
Mistake #4: Scoping everything as @Singleton. Fix: scope only what needs it.
Mistake #5: Two bindings of one type without a qualifier. Fix: add qualifiers.
Mistake #6: Passing Context into repositories. Fix: use @ApplicationContext only where truly needed (databases), not in domain classes.
Mistake #7: Making classes depend on Hilt in unit tests. Fix: inject through constructors; call constructors in tests.
Mistake #8: Feature modules depending on each other. Fix: shared code goes in core modules.
Mistake #9: Using api everywhere. Fix: default to implementation.
Mistake #10: Modularising a tiny app. Fix: wait for a real pain point.
Question 1. What is inversion of control?
A class declares the dependencies it needs, and someone else decides which implementations to provide.
Question 2. What does @HiltViewModel plus @Inject constructor replace?
The hand-written ViewModelProvider.Factory.
Question 3. What is the difference between @Binds and @Provides?
@Binds (abstract) maps an interface to an injectable implementation; @Provides runs code to create an object you cannot construct by annotation.
Question 4. What is the difference between implementation and api?
implementation hides the dependency from consumers; api exposes it as part of your public API.
Question 5. How does Hilt help testing?
@TestInstallIn replaces real modules with fake ones in instrumented tests, so the real app code runs against fake dependencies.
Go deeper: official resources
- Modern Android App Architecture programme
- Dependency injection with Hilt
- Hilt testing guide
- Guide to Android app modularisation
Project goal: Replace the manual container with Hilt and split the project into modules.
Requirements:
- Add Hilt with KSP; annotate the Application and the Activity.
- Convert ViewModels to
@HiltViewModeland usehiltViewModel()in routes. - Write
RepositoryModule(@Binds),DatabaseModule,NetworkModuleandCoroutinesModulewith qualified dispatchers. - Move the sync worker to
@HiltWorker. - Split into at least
:app,:core:model,:core:dataand:feature:students, following the dependency rules, usingimplementationby default. - Write one
@HiltAndroidTestUI test with a@TestInstallInfake repository module. - Keep your plain JVM unit tests passing without Hilt.
- Write a short note drawing your module graph and saying why each
api(if any) is necessary.
Stretch goal: create a :core:ui module and a first convention plugin that applies the Android library and Compose setup to feature modules.
The module graph and one build file:
:app
├─ :feature:students ──► :core:data ──► :core:model (api)
│ └► :core:ui
└─ :feature:payments ──► :core:data
// :core:data/build.gradle.kts
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.hilt)
alias(libs.plugins.ksp)
}
android {
namespace = "com.example.fees.core.data"
compileSdk = 36
}
dependencies {
api(project(":core:model")) // repository functions return model classes
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.kotlinx.coroutines.android)
}
A feature route that still does not know any Hilt module:
@Composable
fun StudentsRoute(
onOpenStudent: (Int) -> Unit,
viewModel: StudentsViewModel = hiltViewModel()
) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
StudentsScreen(state = state, onOpenStudent = onOpenStudent)
}
Because :feature:students sees only repository interfaces and model classes, a developer who tries to import PaymentDao there gets a compile error. The architecture is now enforced by the build, which is what the previous five lessons were building towards.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 65 project”.
On github.com click New repository, name it android-lesson-65-dependency-injection-with-hilt-and-modular, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 65 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: What Is Dependency Injection?
- Step-by-Step Build 1: Set Up Hilt
- Step-by-Step Build 2: Constructor Injection and ViewModels
- Step-by-Step Build 3: Modules, `@Provides` and `@Binds`
- Step-by-Step Build 4: Workers, Navigation and Other Entry Points
- Step-by-Step Build 5: Testing with Hilt
- Step-by-Step Build 6: Modularisation
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check