androidengineers.Book a session

Architecture Patterns

DI Choices: Dagger/Hilt/Koin

article25 minHard

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 DIDagger 2HiltKoin
Error detectionCompileCompileCompileRuntime
Code generationNoYes (kapt/ksp)Yes (kapt/ksp)No
BoilerplateHigh (manual)Very highLowVery low
Android awarenessManualManualBuilt-inBuilt-in
PerformanceBestBestBestSlight startup cost
Learning curveLowHighMediumLow
Testing supportManualComplex@UninstallModulesReplace module
MultiplatformYesNoNoYes (koin-core)
Community/JetpackN/ALargeGoogle recommendedActive

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

ConceptSummary
DI goalProvide dependencies from outside; enable substitution for tests
Dagger 2Compile-time, annotation-based, maximum performance, maximum boilerplate
HiltDagger 2 with Android components built-in; Google recommended
@HiltViewModelRemoves need for manual ViewModel factory
KoinRuntime DSL; easy to learn; errors at runtime not compile time
ScopeMatch scope to lifetime: Singleton, ActivityRetained, ViewModel, Fragment

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
DI Choices: Dagger/Hilt/Koin | Android System Design | Android Engineers