Why Dependency Injection?
Without DI, every class constructs its own dependencies, making substitution (for testing or configuration) impossible without changing source code. DI inverts this: dependencies are provided from outside.
// Without DI — hard to test
class UserRepository {
private val api = Retrofit.Builder()... // hardcoded construction
private val db = Room.databaseBuilder(context, ...) // needs context
}
// With DI — dependencies provided externally
class UserRepository(
private val api: UserApi,
private val db: AppDatabase
)
Manual DI (Service Locator / Constructor Injection)
Before reaching for a framework, understand manual DI. It clarifies what frameworks automate.
// AppContainer wires up the dependency graph
class AppContainer(context: Context) {
private val retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
val userApi: UserApi = retrofit.create(UserApi::class.java)
val database: AppDatabase = Room.databaseBuilder(
context, AppDatabase::class.java, "app_db"
).build()
// Each dependency knows how to build its children
val userRepository: UserRepository = UserRepositoryImpl(
remoteDataSource = UserRemoteDataSource(userApi),
localDataSource = UserLocalDataSource(database.userDao())
)
val getUserUseCase: GetUserUseCase = GetUserUseCase(userRepository)
}
// Application holds the container
class MyApplication : Application() {
val container: AppContainer by lazy { AppContainer(this) }
}
// Activity reads from container
class UserActivity : AppCompatActivity() {
private val useCase by lazy {
(application as MyApplication).container.getUserUseCase
}
}
Manual DI works fine for small apps but becomes unmanageable as the graph grows. Scoping, lazy creation, and mocking require framework support.
Dagger 2
Dagger 2 generates the dependency graph at compile time using annotation processing. No reflection — fast at runtime, errors caught at build time.
// Define what the component can inject and what modules it uses
@Singleton
@Component(modules = [NetworkModule::class, DatabaseModule::class, RepositoryModule::class])
interface AppComponent {
fun inject(activity: MainActivity)
fun userComponent(): UserComponent.Factory
}
@Module
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.addConverterFactory(GsonConverterFactory.create())
.build()
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
}
@Module
object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
Room.databaseBuilder(context, AppDatabase::class.java, "app_db").build()
}
@Module
abstract class RepositoryModule {
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// Usage in Application
class MyApplication : Application() {
val appComponent: AppComponent = DaggerAppComponent.create()
}
// Usage in Activity
class MainActivity : AppCompatActivity() {
@Inject lateinit var userRepository: UserRepository
override fun onCreate(savedInstanceState: Bundle?) {
(application as MyApplication).appComponent.inject(this)
super.onCreate(savedInstanceState)
}
}
Dagger 2 is powerful but requires significant boilerplate for Android (Activity/Fragment injection, ViewModel factories, scoping).
Hilt
Hilt is built on top of Dagger 2 with Android-specific components pre-configured. It eliminates most Dagger boilerplate for Android.
// Application: just add @HiltAndroidApp
@HiltAndroidApp
class MyApplication : Application()
// Activity: @AndroidEntryPoint enables injection
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// Hilt injects this automatically
@Inject lateinit var analyticsTracker: AnalyticsTracker
}
// Fragment: same annotation
@AndroidEntryPoint
class UserFragment : Fragment() {
// ViewModel injection — no factory needed
private val viewModel: UserViewModel by viewModels()
}
// ViewModel with SavedStateHandle
@HiltViewModel
class UserViewModel @Inject constructor(
private val getUserUseCase: GetUserUseCase,
savedStateHandle: SavedStateHandle // injected automatically by Hilt
) : ViewModel() { ... }
// Module providing dependencies
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideRetrofit(): Retrofit = Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
@Provides
@Singleton
fun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
// @Binds: tells Hilt which implementation to use for an interface
@Binds
@Singleton
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
}
// Scoped to ViewModel lifetime
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {
@Provides
@ViewModelScoped
fun provideUserMapper(): UserMapper = UserMapper()
}
Hilt Component Hierarchy
SingletonComponent (@Singleton) - Application lifetime
ActivityRetainedComponent (@ActivityRetainedScoped) - Survives rotation
ActivityComponent (@ActivityScoped) - Activity lifetime
FragmentComponent (@FragmentScoped) - Fragment lifetime
ViewModelComponent (@ViewModelScoped) - ViewModel lifetime
ServiceComponent (@ServiceScoped) - Service lifetime
Koin
Koin is a runtime DI framework using Kotlin DSL. No code generation — dependencies are resolved at runtime via a service locator. Less ceremony, easier to read, but errors surface at runtime, not compile time.
// build.gradle.kts
dependencies {
implementation("io.insert-koin:koin-android:3.5.0")
implementation("io.insert-koin:koin-androidx-viewmodel:3.5.0")
}
// Define modules with a DSL
val networkModule = module {
single<UserApi> {
Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(UserApi::class.java)
}
}
val databaseModule = module {
single {
Room.databaseBuilder(androidContext(), AppDatabase::class.java, "app_db").build()
}
single { get<AppDatabase>().userDao() }
}
val repositoryModule = module {
// `get()` resolves the dependency from Koin's graph
single<UserRepository> { UserRepositoryImpl(get(), get()) }
}
val useCaseModule = module {
factory { GetUserUseCase(get()) } // factory = new instance each time
}
val viewModelModule = module {
viewModel { UserViewModel(get(), get()) }
// or with SavedStateHandle:
viewModel { params -> UserViewModel(get(), params.get()) }
}
// Application setup
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger()
androidContext(this@MyApplication)
modules(networkModule, databaseModule, repositoryModule, useCaseModule, viewModelModule)
}
}
}
// Fragment/Activity — no annotations needed
class UserFragment : Fragment() {
// Koin's delegate extension
private val viewModel: UserViewModel by viewModel()
// Or inject other dependencies directly
private val tracker: AnalyticsTracker by inject()
}
Koin Testing
// In tests, swap modules easily
class UserViewModelTest : KoinTest {
private val mockRepo: UserRepository = mockk()
@Before
fun setup() {
startKoin {
modules(module {
single<UserRepository> { mockRepo }
viewModel { UserViewModel(get()) }
})
}
}
@After
fun tearDown() = stopKoin()
}
Comparison Table
| Manual DI | Dagger 2 | Hilt | Koin | |
|---|---|---|---|---|
| Error detection | Compile | Compile | Compile | Runtime |
| Code generation | No | Yes (kapt/ksp) | Yes (kapt/ksp) | No |
| Boilerplate | High (manual) | Very high | Low | Very low |
| Android awareness | Manual | Manual | Built-in | Built-in |
| Performance | Best | Best | Best | Slight startup cost |
| Learning curve | Low | High | Medium | Low |
| Testing support | Manual | Complex | @UninstallModules | Replace module |
| Multiplatform | Yes | No | No | Yes (koin-core) |
| Community/Jetpack | N/A | Large | Google recommended | Active |
Recommendation Guidance
Use Hilt when:
- New Android project, Kotlin-first
- Team is familiar with Dagger concepts
- You need compile-time safety
- You're using other Jetpack libraries (Navigation, WorkManager — Hilt has built-in integration)
Use Koin when:
- Kotlin Multiplatform project
- Small team wants fast iteration without annotation processing overhead
- Prototypes or apps where runtime error detection is acceptable
- You're migrating from a service-locator pattern
Stick with Dagger 2 when:
- Large legacy codebase already using Dagger
- You need sub-components and custom scopes beyond what Hilt provides
- Absolute performance-critical scenarios (no runtime overhead)
Key Takeaways
| Concept | Summary |
|---|---|
| DI goal | Provide dependencies from outside; enable substitution for tests |
| Dagger 2 | Compile-time, annotation-based, maximum performance, maximum boilerplate |
| Hilt | Dagger 2 with Android components built-in; Google recommended |
@HiltViewModel | Removes need for manual ViewModel factory |
| Koin | Runtime DSL; easy to learn; errors at runtime not compile time |
| Scope | Match scope to lifetime: Singleton, ActivityRetained, ViewModel, Fragment |