Skip to content

Kotlin Dependency Injection — Hilt and Koin Guide

DodaTech Updated 2026-06-28 7 min read

In this tutorial, you will learn about Kotlin Dependency Injection. We cover key concepts, practical examples, and best practices to help you master this topic.

Kotlin dependency injection uses Hilt for Android applications with compile-time Dagger integration and Koin for lightweight multiplatform DI, enabling constructor injection, scoped dependencies, and testable code architecture.

What You'll Learn

  • Add Hilt to an Android project
  • Define modules and provide dependencies
  • Inject into ViewModels, activities, and fragments
  • Use scopes for singletons and per-screen instances
  • Set up Koin for multiplatform projects
  • Test with mocked dependencies
  • Understand DI best practices

Why It Matters

Dependency injection decouples object creation from usage, making code testable, modular, and maintainable. Without DI, classes create their own dependencies, leading to tight coupling and difficult testing. Hilt simplifies Dagger's compile-time DI for Android with annotations. Koin provides a simpler runtime DI for multiplatform projects.

Real-World Use

DodaTech uses Hilt for all Android dependencies: repositories, API clients, database instances, and analytics. Modules provide singleton dependencies with clear scopes. The Koin-based multiplatform module shares business logic dependencies across Android and iOS.

Learning Path

flowchart LR
  A[Testing] --> B[Dependency Injection\nYou are here]
  B --> C[Project CLI Tool]
  style B fill:#f90,color:#fff

Hilt Setup

// build.gradle.kts (project level)
plugins {
    id("com.google.dagger.hilt.android") version "2.51.1" apply false
}

// build.gradle.kts (app level)
plugins {
    id("com.google.dagger.hilt.android")
    id("kotlin-kapt")
}

dependencies {
    implementation("com.google.dagger:hilt-android:2.51.1")
    kapt("com.google.dagger:hilt-android-compiler:2.51.1")
    
    // Hilt ViewModel
    implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
    
    // For testing
    testImplementation("com.google.dagger:hilt-android-testing:2.51.1")
    kaptTest("com.google.dagger:hilt-android-compiler:2.51.1")
}

Hilt Application Class

import android.app.Application
import dagger.hilt.android.HiltAndroidApp

@HiltAndroidApp
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        // Application-level initialization
    }
}

Every app with Hilt needs an @HiltAndroidApp Application class.

Hilt Modules

Modules tell Hilt how to provide dependencies.

import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import javax.inject.Singleton

@Module
@InstallIn(SingletonComponent::class)
object AppModule {
    
    @Provides
    @Singleton
    fun provideOkHttpClient(): OkHttpClient {
        return OkHttpClient.Builder()
            .connectTimeout(30, TimeUnit.SECONDS)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            })
            .build()
    }
    
    @Provides
    @Singleton
    fun provideRetrofit(client: OkHttpClient): Retrofit {
        return Retrofit.Builder()
            .baseUrl("https://api.example.com/")
            .client(client)
            .addConverterFactory(MoshiConverterFactory.create())
            .build()
    }
    
    @Provides
    @Singleton
    fun provideApiService(retrofit: Retrofit): ApiService {
        return retrofit.create(ApiService::class.java)
    }
    
    @Provides
    @Singleton
    fun provideDatabase(@ApplicationContext context: Context): AppDatabase {
        return Room.databaseBuilder(
            context,
            AppDatabase::class.java,
            "app_database"
        ).build()
    }
    
    @Provides
    fun provideUserDao(database: AppDatabase): UserDao {
        return database.userDao()
    }
}

Output: Hilt generates the implementation that creates these dependencies once (singletons) and injects them wherever requested.

Injecting into Activities and Fragments

import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject

@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
    @Inject
    lateinit var apiService: ApiService
    
    @Inject
    lateinit var database: AppDatabase
    
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // apiService and database are already injected
    }
}

@AndroidEntryPoint
class HomeFragment : Fragment() {
    private val viewModel: HomeViewModel by viewModels()
    
    // Field injection
    @Inject
    lateinit var analyticsTracker: AnalyticsTracker
}

Hilt injects dependencies into the activity or fragment after onCreate.

Hilt ViewModel Injection

import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val userRepository: UserRepository,
    private val apiService: ApiService,
    private val savedStateHandle: SavedStateHandle
) : ViewModel() {
    
    private val _users = MutableStateFlow<ApiResult<List<User>>>(ApiResult.Loading)
    val users: StateFlow<ApiResult<List<User>>> = _users.asStateFlow()
    
    init {
        loadUsers()
    }
    
    fun loadUsers() {
        viewModelScope.launch {
            _users.value = ApiResult.Loading
            try {
                val users = userRepository.getUsers()
                _users.value = ApiResult.Success(users)
            } catch (e: Exception) {
                _users.value = ApiResult.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// In Compose
@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel()
) {
    // ViewModel is provided by Hilt
    val users by viewModel.users.collectAsState()
    // ...
}

Output: HiltViewModel creates the ViewModel with all its dependencies injected.

Hilt Scopes

Scope Lifetime
@Singleton Application lifetime
@ActivityScoped Per activity
@FragmentScoped Per fragment
@ViewModelScoped Per ViewModel
@ViewScoped Per Android View
@Module
@InstallIn(ActivityComponent::class)
object ActivityModule {
    
    @Provides
    @ActivityScoped
    fun provideActivityScopedService(): ActivityScopedService {
        return ActivityScopedService()
    }
}

@ActivityScoped
class ActivityScopedService @Inject constructor() {
    // One instance per activity
}

Qualifiers

Use qualifiers when multiple implementations of the same type exist.

import javax.inject.Qualifier

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class RemoteDataSource

@Qualifier
@Retention(AnnotationRetention.BINARY)
annotation class LocalDataSource

@Module
@InstallIn(SingletonComponent::class)
object DataSourceModule {
    
    @RemoteDataSource
    @Provides
    @Singleton
    fun provideRemoteDataSource(api: ApiService): UserDataSource {
        return RemoteUserDataSource(api)
    }
    
    @LocalDataSource
    @Provides
    @Singleton
    fun provideLocalDataSource(dao: UserDao): UserDataSource {
        return LocalUserDataSource(dao)
    }
}

class UserRepository @Inject constructor(
    @RemoteDataSource private val remote: UserDataSource,
    @LocalDataSource private val local: UserDataSource
) {
    suspend fun getUsers(): List<User> {
        return try {
            val users = remote.getUsers()
            // Cache locally
            users
        } catch (e: Exception) {
            local.getUsers()
        }
    }
}

Koin for Multiplatform

Koin is a lightweight DI framework that works on all KMP targets.

// build.gradle.kts
implementation("io.insert-koin:koin-core:3.5.6")
implementation("io.insert-koin:koin-android:3.5.6")
implementation("io.insert-koin:koin-androidx-compose:3.5.6")

// Define modules
val appModule = module {
    single { OkHttpClient() }
    single { ApiService(get()) }
    factory { UserRepository(get()) }
    viewModel { UserViewModel(get()) }
}

// Start Koin
class MyApplication : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@MyApplication)
            modules(appModule)
        }
    }
}

// Inject in composables
@Composable
fun HomeScreen(
    viewModel: UserViewModel = koinViewModel()
) {
    // Use viewModel
}

Koin uses single for singletons, Factory for new instances each time, and scoped for scope-bound instances.

Testing with Hilt

@HiltAndroidTest
class UserRepositoryTest {
    @get:Rule
    val hiltRule = HiltAndroidRule(this)
    
    @Inject
    lateinit var repository: UserRepository
    
    @Before
    fun setup() {
        hiltRule.inject()
    }
    
    @Test
    fun testGetUsers() = runTest {
        val users = repository.getUsers()
        assertTrue(users.isNotEmpty())
    }
}

Common Mistakes

  1. Not annotating the Application class: Without @HiltAndroidApp, Hilt cannot initialize. Add it to the Application class before using Hilt.

  2. Forgetting to annotate entry points: Activities and fragments need @AndroidEntryPoint. ViewModels need @HiltViewModel.

  3. Mixing field injection and constructor injection: Prefer constructor injection. Field injection hides dependencies and makes testing harder.

  4. Over-scoping dependencies: Using @Singleton for everything prevents per-screen state. Use the narrowest scope needed.

  5. Circular dependencies: Dagger detects circular dependencies at compile time. Refactor to break the cycle if this occurs.

  6. Not providing Retrofit converters: If @Provides functions miss dependencies, Hilt reports missing bindings at compile time.

Practice Questions

  1. What is the purpose of @InstallIn in Hilt?

Answer: @InstallIn defines the component (scope) where the module is installed. Common options: SingletonComponent (app-wide), ActivityComponent (per activity), FragmentComponent (per fragment).

  1. How does Hilt ViewModel injection work?

Answer: Annotate the ViewModel with @HiltViewModel and use @Inject constructor. In Compose, use hiltViewModel(). In fragments, use by viewModels(). Hilt provides the ViewModel with its dependencies.

  1. What is the difference between @Singleton and @ActivityScoped?

Answer: @Singleton creates one instance for the entire application lifetime. @ActivityScoped creates one instance per activity, recreated when the activity is recreated.

  1. When would you use a @Qualifier?

Answer: When you have multiple implementations of the same interface (e.g., RemoteDataSource and LocalDataSource). Qualifiers distinguish which implementation to inject.

  1. Challenge: Convert an Android app from manual DI to Hilt. The app has three screens, two repositories, a database, and an API service. Identify all components that need modules and injection points.

Answer: Create AppModule for singletons (database, API), RepositoryModule for repositories (scoped per activity), and ViewModelModule for ViewModels. Annotate Application, activities, fragments, and ViewModels. Remove manual factory code.

Mini Project

Refactor the todo list app to use Hilt. Requirements:

  • @HiltAndroidApp Application class
  • AppModule with @Singleton API service, database, Repository
  • @HiltViewModel for ListViewModel and DetailViewModel
  • @AndroidEntryPoint for activities and fragments
  • @Qualifier for RemoteRepository and LocalRepository
  • Test ViewModel with HiltAndroidTest
  • Koin alternative in a separate branch

This project demonstrates DI Migration and both DI frameworks in practice.

FAQ

What is the difference between Hilt and Dagger?

Hilt wraps Dagger with simpler annotations (@HiltAndroidApp, @AndroidEntryPoint) and automatic component management. It reduces boilerplate by 80% compared to raw Dagger.

Can I use Koin with Android?

Yes. Koin works on Android and is simpler than Hilt but uses runtime reflection. Hilt is preferred for Android due to compile-time safety and Google support.

What is the difference between single and factory in Koin?

single creates one instance per application. factory creates a new instance every time it is injected.

How do I test a Hilt ViewModel?

Use @HiltAndroidTest with HiltAndroidRule. Inject the ViewModel through Hilt and test its public functions.

Can I use DI without a framework?

Yes. Manual DI with a ServiceLocator or factory functions works for small apps. Frameworks become necessary as the app grows.

What's Next

After learning DI, build a CLI tool project to apply your knowledge. You can also explore coroutines for advanced async patterns.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro