Back to Notes

Hilt Dependency Injection Deep Dive: From Basics to Custom Scopes

By Davide Agostini

Dependency Injection (DI) sounds complicated, but it's really just giving your classes what they need to work. Instead of creating their own tools, classes just ask for them.

Hilt is Google's official way to do DI in Android—and it's powerful enough for simple apps but scales to complex ones.

The Problem: Managing Dependencies

Imagine this without DI:

class UserRepository {
    // ❌ Creating dependencies manually = trouble
    private val database = Room.databaseBuilder(...).build()
    private val api = Retrofit.Builder().build()
    private val prefs = SharedPreferences(...)
    
    fun getUser() { ... }
}
 
class UserViewModel {
    // ❌ Hard to test, impossible to swap implementations
    private val repository = UserRepository()
}

Problems:

  • Every class creates its own dependencies
  • Hard to test (can't mock)
  • Hard to change implementations
  • Everything is tightly coupled

The Solution: Dependency Injection

With DI, you declare what a class needs, and the system provides it:

class UserRepository @Inject constructor(
    private val database: AppDatabase,
    private val api: UserApi,
    private val prefs: SharedPreferences
) { ... }
 
class UserViewModel @Inject constructor(
    private val repository: UserRepository
) : ViewModel() { ... }

Now:

  • Dependencies are provided automatically
  • Easy to test with fake implementations
  • Easy to change implementations
  • Clean separation of concerns

Hilt Basics

1. Add Dependencies

// build.gradle.kts
plugins {
    id("com.google.dagger.hilt.android")
    id("com.google.devtools.ksp")
}
 
dependencies {
    implementation("com.google.dagger:hilt-android:2.50")
    ksp("com.google.dagger:hilt-compiler:2.50")
    implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
}

2. Application Class

@HiltAndroidApp
class ClipVaultApplication : Application()

What this does: Tells Hilt to start managing dependencies in this app.

3. Inject into Activity/Fragment

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
    
    // Hilt provides this automatically!
    @Inject lateinit var userRepository: UserRepository
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Use userRepository here
    }
}

Module: Providing Dependencies

Sometimes you can't just @Inject a class—like for Android system services or third-party libraries. That's where Modules come in.

Creating a Module

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    
    @Provides
    @Singleton
    fun provideDatabase(
        @ApplicationContext context: Context
    ): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "clipvault_db"
        ).build()
    }
    
    @Provides
    @Singleton
    fun provideSharedPreferences(
        @ApplicationContext context: Context
    ): SharedPreferences {
        return context.getSharedPreferences("prefs", Context.MODE_PRIVATE)
    }
}

What this does: Tells Hilt how to create these objects.


Scopes: Controlling Lifetime

Default Scopes (For Beginners)

What scope should I use?

🌍 App-wide (entire app):

  • @Singleton → Database, API, Repository
  • One instance for the whole app

📱 Activity:

  • @ActivityRetainedScoped → ViewModels
  • Survives configuration changes within an Activity

🧩 Fragment:

  • @FragmentScoped → Dependencies needed in one screen
  • One instance per Fragment

⚡ ViewModel:

  • @ViewModelScoped → Short-lived data
  • Survives only during ViewModel lifetime

🚪 Entry Point:

  • @EntryPointScoped → Specific use cases
  • For non-Android classes like Workers

Using Scopes

@Module
@InstallIn(SingletonComponent::class)
object DatabaseModule {
    
    @Provides
    @Singleton  // Only one instance for entire app
    fun provideDatabase(...): AppDatabase { ... }
}
 
@ActivityScoped  // One per Activity
class ActivityManager @Inject constructor(
    private val prefs: SharedPreferences
) { ... }

Custom Scopes (For Advanced Use)

Sometimes you need your own scope—like for user sessions:

Define Custom Scope

@Scope
@Retention(AnnotationRetention.RUNTIME)
annotation class UserScope

Create Custom Component

@Component(
    modules = [UserModule::class],
    scope = UserScope::class
)
interface UserComponent {
    fun inject(activity: UserActivity)
}

Use It

@UserScope
class UserSessionManager @Inject constructor(
    private val prefs: SharedPreferences
) {
    var currentUser: User? = null
    
    fun login(credentials: Credentials): User {
        // Login logic
        return user
    }
    
    fun logout() {
        currentUser = null
    }
}

Qualifiers: Multiple Implementations

What if you need two of the same type? Use qualifiers:

Example: Local vs Remote Data Source

// Define qualifiers
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalDataSource
 
@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteDataSource
 
// In module, provide both
@Module
@InstallIn(SingletonComponent::class)
object DataModule {
    
    @Provides
    @Singleton
    @LocalDataSource
    fun provideLocalDataSource(database: AppDatabase): ClipDataSource {
        return LocalClipDataSource(database.clipDao())
    }
    
    @Provides
    @Singleton
    @RemoteDataSource
    fun provideRemoteDataSource(api: ClipApi): ClipDataSource {
        return RemoteClipDataSource(api)
    }
}

Use Qualifiers

class ClipRepository @Inject constructor(
    @LocalDataSource private val local: ClipDataSource,
    @RemoteDataSource private val remote: ClipDataSource
) {
    suspend fun sync() {
        remote.fetchAll().forEach { local.save(it) }
    }
}

Navigation Scope (Compose Navigation)

Hilt works great with Navigation Compose:

Setup

// build.gradle.kts
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")

Use with Navigation

@Composable
fun NavGraph(
    navController: NavHostController,
    startDestination: String = "home"
) {
    NavHost(
        navController = navController,
        startDestination = startDestination
    ) {
        composable("home") {
            // ViewModel is scoped to navigation graph
            val viewModel: HomeViewModel = hiltViewModel()
            HomeScreen(viewModel = viewModel)
        }
        
        composable("detail/{id}") { backStackEntry ->
            val detailId = backStackEntry.arguments?.getString("id")
            val viewModel: DetailViewModel = hiltViewModel(detailId)
            DetailScreen(viewModel = viewModel)
        }
    }
}

Component Dependencies

When you need a scope that's larger than Activity but smaller than Singleton:

@Component(
    modules = [AppModule::class],
    dependencies = [NavigationComponent::class]
)
interface AppComponent {
    fun inject(activity: MainActivity)
}
 
@Component
@FragmentScope
interface NavigationComponent {
    fun navController(): NavController
}

Entry Points: Access Outside Android

Need Hilt in non-Android classes (like workers)?

For WorkManager

@EntryPoint
@InstallIn(ApplicationComponent::class)
interface WorkerEntryPoint {
    fun clipRepository(): ClipRepository
}
 
class SyncWorker(
    context: Context,
    params: WorkerParameters
) : CoroutineWorker(context, params) {
    
    override suspend fun doWork(): Result {
        val entryPoint = EntryPointAccessors.fromApplication(
            applicationContext,
            WorkerEntryPoint::class.java
        )
        val repository = entryPoint.clipRepository()
        
        // Use repository
        return Result.success()
    }
}

Hilt with ViewModel

ViewModel Injection

@HiltViewModel
class ClipListViewModel @Inject constructor(
    private val getClips: GetAllClipsUseCase,
    private val searchClips: SearchClipsUseCase,
    private val deleteClip: DeleteClipUseCase
) : ViewModel() {
    // ViewModel automatically survives config changes
    // and is scoped to the Activity/Fragment
}

Using in Compose

@Composable
fun ClipListScreen(
    viewModel: ClipListViewModel = hiltViewModel()
) {
    val uiState by viewModel.uiState.collectAsState()
    
    // Render UI
}

When to Use Hilt (And When NOT To)

✅ Use Hilt When:

  • Medium to large apps - Clean Architecture benefits
  • Multiple modules - Complex dependency graphs
  • Testing - Need to mock dependencies
  • Team projects - Standard DI pattern

❌ Don't Use Hilt When:

  • Very small apps - Koin is simpler
  • Learning DI - Understand basics first
  • Simple utilities - Manual DI might be enough

Quick Decision Guide

🎯 Which scope to use?

  • App-wide data (database, API) → @Singleton
  • Screen-specific@ViewModelScoped or no scope
  • Activity-specific@ActivityRetainedScoped
  • User session → Custom @UserScope

🔧 Which annotation?

  • Class creates objects@Module
  • Function provides object@Provides
  • Class needs injection@Inject constructor
  • Android class needs Hilt@AndroidEntryPoint
  • App needs Hilt@HiltAndroidApp

Key Takeaway

Start with @Singleton for app-wide dependencies and @Inject constructor for your classes. Only add custom scopes or qualifiers when you have a real need (like user sessions). Hilt's defaults work for 90% of apps—don't overengineer!