Dependency Injection Guide
This guide covers how dependency injection (DI) works in this template using Dagger Hilt. All examples are based on the actual implementation patterns used throughout the codebase.
Overview
This template uses Dagger Hilt for dependency injection, which is the recommended DI solution for Android. Hilt is built on top of Dagger and provides:
- Simplified setup with convention-based configuration
- Integration with Android components (Activity, ViewModel, Worker, etc.)
- Compile-time safety and performance
- Automatic dependency graph generation
- Scoping strategies for managing object lifecycles
Quick Start
1. Enable Hilt in Your Module
All modules that need DI apply the Hilt convention plugin:
// feature/home/build.gradle.kts
plugins {
alias(libs.plugins.jetpack.ui.library)
alias(libs.plugins.jetpack.dagger.hilt) // Add this
alias(libs.plugins.jetpack.dokka)
}
The jetpack.dagger.hilt convention plugin automatically:
- Applies
com.google.dagger.hilt.androidplugin - Applies
com.google.devtools.kspplugin - Adds Hilt runtime and compiler dependencies
2. Annotate Your Application Class
The Application class must be annotated with @HiltAndroidApp:
@HiltAndroidApp
class JetpackApplication : Application() {
override fun onCreate() {
super.onCreate()
// Hilt is automatically initialized before this
}
}
3. Inject into Android Components
Use @AndroidEntryPoint on Activities, Fragments, etc., and @Inject for field injection:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var networkUtils: NetworkUtils
@Inject
lateinit var crashReporter: CrashReporter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Dependencies are injected before onCreate()
// You can use networkUtils and crashReporter here
}
}
4. Inject into ViewModels
Use @HiltViewModel and constructor injection:
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
// Repository is automatically injected
}
Core Concepts
@Binds vs @Provides
Hilt modules use two different methods for providing dependencies:
@Binds (Preferred for Interfaces)
Use @Binds when you have an interface and a single implementation. It's more efficient than
@Provides because it generates less code.
Pattern:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl
): AuthRepository
}
Real Example from data/src/main/kotlin/dev/atick/data/di/RepositoryModule.kt:
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl,
): AuthRepository
@Binds
@Singleton
internal abstract fun bindHomeRepository(
homeRepositoryImpl: HomeRepositoryImpl,
): HomeRepository
// ... more bindings
}
When to use:
- Binding interfaces to implementations
- When the implementation has
@Inject constructor - When you don't need any custom logic to create the object
@Provides (For Complex Construction)
Use @Provides when you need custom logic to create objects, or when the type can't have @Inject
on its constructor (e.g., third-party libraries, builders).
Real Example from core/room/src/main/kotlin/dev/atick/core/room/di/DatabaseModule.kt:
@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
private const val ROOM_DATABASE_NAME = "dev.atick.jetpack.room"
@Singleton
@Provides
fun provideRoomDatabase(
@ApplicationContext appContext: Context,
): JetpackDatabase {
return Room.databaseBuilder(
appContext,
JetpackDatabase::class.java,
ROOM_DATABASE_NAME,
).fallbackToDestructiveMigration(true).build()
}
}
Real Example
from core/network/src/main/kotlin/dev/atick/core/network/di/retrofit/RetrofitModule.kt:
@Module(
includes = [
OkHttpClientModule::class,
],
)
@InstallIn(SingletonComponent::class)
object RetrofitModule {
@Singleton
@Provides
fun provideRetrofitClient(
converterFactory: Converter.Factory,
okHttpClient: OkHttpClient,
): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.BACKEND_URL)
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
}
}
When to use:
- Creating objects with builders (Retrofit, Room, OkHttp)
- Third-party library types
- Objects requiring complex initialization logic
- When you need to use
@ApplicationContextor other qualifiers
Scoping
Scoping controls the lifecycle of dependencies. This template uses three main scopes:
@Singleton (Application Scope)
Objects live as long as the application process.
Example:
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl,
): AuthRepository
Use for:
- Repositories
- Data sources (Room DAOs, Retrofit services, DataStore)
- Network clients (OkHttp, Retrofit)
- Utilities (CrashReporter, NetworkUtils)
@ViewModelScoped
Objects live as long as the ViewModel. Useful for dependencies that should be recreated when the ViewModel is recreated.
Example:
@ActivityScoped
Objects live as long as the Activity. Less commonly used in this template.
Qualifier Annotations
Qualifiers differentiate between multiple instances of the same type.
Real Example from core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt:
@Module
@InstallIn(SingletonComponent::class)
object DispatcherModule {
@DefaultDispatcher
@Provides
fun providesDefaultDispatcher(): CoroutineDispatcher = Dispatchers.Default
@IoDispatcher
@Provides
fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@MainDispatcher
@Provides
fun providesMainDispatcher(): CoroutineDispatcher = Dispatchers.Main
}
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class DefaultDispatcher
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class IoDispatcher
@Retention(AnnotationRetention.BINARY)
@Qualifier
annotation class MainDispatcher
Usage in Data Source:
class NetworkDataSourceImpl @Inject constructor(
private val restApi: RestApi,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : NetworkDataSource {
override suspend fun getData(): List<JetpackDto> = withContext(ioDispatcher) {
restApi.getData()
}
}
When to create qualifiers:
- Multiple instances of the same type with different configurations
- Different implementations of the same interface for different purposes
- Named instances (like different dispatchers, different databases)
Injection Patterns
Constructor Injection (Preferred)
This is the most common and recommended pattern. Works for:
- ViewModels (
@HiltViewModel) - Repositories
- Data sources
- Any class you control
ViewModel Example:
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
) : ViewModel() {
// Use settingsRepository
}
Repository Example:
internal class AuthRepositoryImpl @Inject constructor(
private val authDataSource: AuthDataSource,
private val userPreferencesDataSource: UserPreferencesDataSource,
) : AuthRepository {
// Use data sources
}
Data Source Example:
class FirebaseDataSourceImpl @Inject constructor(
private val firestore: FirebaseFirestore,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : FirebaseDataSource {
// Use firestore and ioDispatcher
}
Field Injection
Used for Android framework components where you can't control constructor.
Activity Example from app/src/main/kotlin/dev/atick/compose/MainActivity.kt:
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
@Inject
lateinit var networkUtils: NetworkUtils
@Inject
lateinit var crashReporter: CrashReporter
private val viewModel: MainActivityViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// networkUtils and crashReporter are now injected
}
}
Important
Field Injection Requirements:
- Use lateinit var for field injection
- Fields must not be private
- Injection happens before onCreate() for Activities
- For ViewModels, use by viewModels() delegate (Hilt integration)
AssistedInject (For Workers)
WorkManager Workers need special handling because they receive runtime parameters from WorkManager.
Real Example from sync/src/main/kotlin/dev/atick/sync/worker/SyncWorker.kt:
@HiltWorker
class SyncWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParameters: WorkerParameters,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
private val homeRepository: HomeRepository,
) : CoroutineWorker(context, workerParameters) {
override suspend fun doWork(): Result {
// Use injected dependencies
return withContext(ioDispatcher) {
homeRepository.sync()
Result.success()
}
}
}
Key Points:
- Use
@HiltWorkerannotation - Use
@AssistedInjectfor constructor - Mark WorkManager-provided params with
@Assisted - Regular dependencies are injected normally
Note
You must also use DelegatingWorker when enqueuing:
fun startUpSyncWork(): OneTimeWorkRequest {
return OneTimeWorkRequestBuilder<DelegatingWorker>()
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.setConstraints(SyncConstraints)
.setInputData(SyncWorker::class.delegatedData())
.build()
}
Module Organization
Modules are organized by layer and purpose. Each module contains a di/ package with Hilt modules.
Core Module DI Structure
core/android/src/main/kotlin/dev/atick/core/di/DispatcherModule.kt- Coroutine dispatchersCoroutineModule.kt- CoroutineScopeStringDecoderModule.kt- URI decoder
core/network/src/main/kotlin/dev/atick/core/network/di/retrofit/RetrofitModule.kt- Retrofit instanceConverterModule.kt- JSON converter
okhttp/OkHttpClientModule.kt- OkHttp clientInterceptorModule.kt- Interceptors
coil/CoilModule.kt- Image loader
NetworkUtilsModule.kt- Network utilitiesDataSourceModule.kt- Network data source
core/room/src/main/kotlin/dev/atick/core/room/di/DatabaseModule.kt- Room databaseDaoModule.kt- DAOsDataSourceModule.kt- Local data source
core/preferences/src/main/kotlin/dev/atick/core/preferences/di/DatastoreModule.kt- DataStorePreferencesDataSourceModule.kt- Preferences data source
Data Module DI Structure
data/src/main/kotlin/dev/atick/data/di/RepositoryModule.kt- All repository bindings
Firebase Module DI Structure
firebase/analytics/src/main/kotlin/dev/atick/firebase/analytics/di/FirebaseModule.kt- Firebase AnalyticsCrashlyticsModule.kt- CrashReporter
firebase/auth/src/main/kotlin/dev/atick/firebase/auth/di/FirebaseAuthModule.kt- Firebase AuthCredentialManagerModule.kt- Credential ManagerDataSourceModule.kt- Auth data source
firebase/firestore/src/main/kotlin/dev/atick/firebase/firestore/di/FirebaseModule.kt- Firestore instanceDataSourceModule.kt- Firestore data source
Sync Module DI Structure
sync/src/main/kotlin/dev/atick/sync/di/SyncModule.kt- SyncManager binding
Module Inclusion Pattern
Modules can include other modules to establish dependencies:
Example from core/room/di/DaoModule.kt:
@Module(
includes = [
DatabaseModule::class,
],
)
@InstallIn(SingletonComponent::class)
object DaoModule {
@Singleton
@Provides
fun provideJetpackDao(jetpackDatabase: JetpackDatabase) =
jetpackDatabase.getJetpackDao()
}
This ensures DatabaseModule is processed before DaoModule, so the database is available when
creating the DAO.
Common Patterns
Pattern 1: Repository with Multiple Data Sources
Real Example from data/src/main/kotlin/dev/atick/data/repository/auth/AuthRepositoryImpl.kt:
internal class AuthRepositoryImpl @Inject constructor(
private val authDataSource: AuthDataSource,
private val userPreferencesDataSource: UserPreferencesDataSource,
) : AuthRepository {
override suspend fun signInWithEmailAndPassword(
email: String,
password: String,
): Result<Unit> {
return suspendRunCatching {
val user = authDataSource.signInWithEmailAndPassword(email, password)
userPreferencesDataSource.setUserProfile(user.asPreferencesUserProfile())
}
}
}
Binding:
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl,
): AuthRepository
Pattern 2: DataStore with Dispatcher
Real Example from core/preferences/di/DatastoreModule.kt:
@Module
@InstallIn(SingletonComponent::class)
object DatastoreModule {
private const val DATA_STORE_FILE_NAME = "user_preferences.json"
@Singleton
@Provides
fun providePreferencesDataStore(
@ApplicationContext appContext: Context,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
): DataStore<UserDataPreferences> {
return DataStoreFactory.create(
serializer = UserDataSerializer,
produceFile = { appContext.dataStoreFile(DATA_STORE_FILE_NAME) },
scope = CoroutineScope(ioDispatcher + SupervisorJob()),
)
}
}
Key points:
- Uses
@ApplicationContextqualifier for Context - Uses
@IoDispatcherqualifier for CoroutineDispatcher - Creates DataStore with custom scope on IO dispatcher
Pattern 3: Android System Service
Real Example from firebase/auth/di/CredentialManagerModule.kt:
@Module
@InstallIn(SingletonComponent::class)
object CredentialManagerModule {
@Provides
@Singleton
fun provideCredentialManager(@ApplicationContext context: Context): CredentialManager {
return CredentialManager.create(context)
}
}
Pattern 4: Third-Party Library with Builder
Real Example from Retrofit setup:
@Module(includes = [OkHttpClientModule::class])
@InstallIn(SingletonComponent::class)
object RetrofitModule {
@Singleton
@Provides
fun provideRetrofitClient(
converterFactory: Converter.Factory,
okHttpClient: OkHttpClient,
): Retrofit {
return Retrofit.Builder()
.baseUrl(BuildConfig.BACKEND_URL)
.addConverterFactory(converterFactory)
.client(okHttpClient)
.build()
}
}
Convention Plugin Pattern
This template uses a Gradle Convention Plugin to simplify Hilt setup across modules.
Convention Plugin (build-logic/convention/src/main/kotlin/DaggerHiltConventionPlugin.kt):
class DaggerHiltConventionPlugin : Plugin<Project> {
override fun apply(target: Project) {
with(target) {
val libs = extensions.getByType<VersionCatalogsExtension>().named("libs")
with(pluginManager) {
apply("com.google.dagger.hilt.android")
apply("com.google.devtools.ksp")
}
dependencies {
"implementation"(libs.findLibrary("dagger.hilt.android").get())
"ksp"(libs.findLibrary("dagger.hilt.compiler").get())
}
}
}
}
Usage in feature/home/build.gradle.kts:
plugins {
alias(libs.plugins.jetpack.ui.library)
alias(libs.plugins.jetpack.dagger.hilt) // One line to add Hilt
alias(libs.plugins.jetpack.dokka)
}
Benefits:
- Consistent Hilt setup across all modules
- Single source of truth for Hilt configuration
- Easy to update Hilt version across the entire project
- Reduces boilerplate in build files
Testing with Hilt
Unit Testing Repositories and ViewModels
For unit tests, you can use test doubles (fakes or mocks) instead of real implementations.
Example (Not in codebase, but standard pattern):
class AuthRepositoryTest {
private lateinit var repository: AuthRepository
private val fakeAuthDataSource = FakeAuthDataSource()
private val fakePreferencesDataSource = FakeUserPreferencesDataSource()
@Before
fun setup() {
repository = AuthRepositoryImpl(
authDataSource = fakeAuthDataSource,
userPreferencesDataSource = fakePreferencesDataSource
)
}
@Test
fun signIn_success_savesUserToPreferences() = runTest {
// Test implementation
}
}
Key points:
- No Hilt in unit tests
- Use constructor injection (makes testing easier)
- Inject fake/mock implementations manually
Integration Testing with Hilt
For Android instrumentation tests, use Hilt's testing library.
Example (Not in codebase, but standard pattern):
@HiltAndroidTest
@UninstallModules(RepositoryModule::class)
class FeatureIntegrationTest {
@get:Rule
var hiltRule = HiltAndroidRule(this)
@BindValue
@JvmField
val repository: AuthRepository = FakeAuthRepository()
@Test
fun testFeature() {
// Test with fake repository
}
}
Troubleshooting
Common Issues
1. "Cannot find symbol: DaggerApplicationComponent"
Cause: Hilt annotation processor hasn't run yet.
Solution:
- Build the project:
./gradlew build - Clean and rebuild:
./gradlew clean build - Ensure
@HiltAndroidAppis on your Application class
2. "Missing binding for [Type]"
Cause: No Hilt module provides this type.
Solution:
- Create a module with
@Providesor@Bindsfor the type - If using
@Binds, ensure the implementation has@Inject constructor - Check that the module is installed in the correct component (
@InstallIn)
Example:
// Problem: AuthRepository not bound
@Binds
@Singleton
internal abstract fun bindAuthRepository(
authRepositoryImpl: AuthRepositoryImpl,
): AuthRepository
3. "Injected field must not be private"
Cause: Field injection doesn't work with private fields.
Solution:
// ❌ Wrong
@Inject
private lateinit var repository: Repository
// ✅ Correct
@Inject
lateinit var repository: Repository
4. "@Inject constructor required" (when using @Binds)
Cause: @Binds requires the implementation to have @Inject constructor.
Solution:
// ❌ Wrong
class AuthRepositoryImpl(
private val authDataSource: AuthDataSource,
) : AuthRepository
// ✅ Correct
class AuthRepositoryImpl @Inject constructor(
private val authDataSource: AuthDataSource,
) : AuthRepository
5. "Scoping mismatch"
Cause: Dependency has a shorter scope than the class requesting it.
Example of problem:
@Singleton
class Repository @Inject constructor(
@ActivityScoped val activityDep: ActivityDependency // ❌ Wrong
)
Solution: Ensure dependencies have equal or longer scopes:
@Singletoncan depend on@Singleton@ActivityScopedcan depend on@Singletonor@ActivityScoped@ViewModelScopedcan depend on@Singletonor@ViewModelScoped
6. KSP not generating code
Cause: KSP configuration issue or cache corruption.
Solution:
- Ensure convention plugin applies KSP:
apply("com.google.devtools.ksp") - Invalidate caches and restart Android Studio
- Clean build directory:
./gradlew clean - Check KSP version compatibility with Kotlin version
7. Circular dependency error
Cause: Two classes depend on each other.
Example:
Solutions:
- Use
Lazy<T>for one dependency - Use
Provider<T>for one dependency - Refactor to break the cycle (extract common logic)
Example fix:
class A @Inject constructor(val b: Lazy<B>) // Break cycle with Lazy
class B @Inject constructor(val a: A)
Best Practices
Do's
-
Use constructor injection whenever possible
-
Prefer @Binds over @Provides for interface bindings
-
Use qualifiers for multiple instances of the same type
-
Scope appropriately based on lifecycle needs
-
Organize modules by layer (following the existing pattern)
core/*/di/for core infrastructuredata/di/for repositoriesfirebase/*/di/for Firebase integrations
-
Use
internalfor implementation bindings -
Inject dispatchers instead of hardcoding
Dispatchers.IO
Don'ts
-
Don't use field injection when constructor injection is possible
-
Don't inject Android components (Context, Activity, etc.) into Singletons
-
Don't hardcode Dispatchers
-
Don't over-scope (don't make everything
@Singletonunnecessarily) -
Don't create modules in feature packages - follow the
di/package pattern -
Don't mix Hilt with other DI frameworks (like Koin, manual DI)
Advanced Topics
Custom Components (Not Used in This Template)
Hilt allows creating custom components for specific lifecycles. This template doesn't use custom components, but they can be useful for:
- Fragment-specific dependencies
- Service-specific dependencies
- Custom lifecycle scopes
Entry Points
If you need to inject dependencies into a class that Hilt doesn't support, use @EntryPoint.
Example (not in codebase):
@EntryPoint
@InstallIn(SingletonComponent::class)
interface MyEntryPoint {
fun repository(): Repository
}
// In non-Hilt class
val entryPoint = EntryPointAccessors.fromApplication(
context.applicationContext,
MyEntryPoint::class.java
)
val repository = entryPoint.repository()
Multibindings
For providing multiple implementations of the same interface (e.g., list of plugins, interceptors).
Example (not in codebase):
@Module
@InstallIn(SingletonComponent::class)
abstract class InterceptorModule {
@Binds
@IntoSet
abstract fun bindLoggingInterceptor(impl: LoggingInterceptor): Interceptor
@Binds
@IntoSet
abstract fun bindAuthInterceptor(impl: AuthInterceptor): Interceptor
}
// Inject
class Client @Inject constructor(
private val interceptors: Set<@JvmSuppressWildcards Interceptor>
)
Migration Guide
If you're adding Hilt to an existing project or module:
Step 1: Add Convention Plugin
Step 2: Annotate Application Class
Step 3: Create Modules
- Create
di/package in each module - Create Hilt modules for existing dependencies
- Use
@Bindsfor interfaces,@Providesfor complex types
Step 4: Migrate Injection
- Replace manual injection with
@Inject constructor - Annotate Activities with
@AndroidEntryPoint - Annotate ViewModels with
@HiltViewModel
Step 5: Test
- Build the project
- Verify all dependencies are injected
- Run tests to ensure nothing broke
Further Reading
- Dagger Hilt Official Guide
- Hilt Codelab
- Dependency Injection on Android with Hilt
- Hilt Testing Guide
Summary
This template follows these DI principles:
- Convention over configuration - Convention plugin simplifies setup
- Constructor injection first - Easier to test, explicit dependencies
- Organized by layer - Modules follow project structure
- Scoped appropriately - Most dependencies are
@Singleton - Qualifier annotations - Used for dispatchers and multiple instances
- Compile-time safety - Hilt validates dependency graph at build time
By following these patterns, you'll have a maintainable, testable, and efficient DI setup that scales with your project.