Hilt vs Koin vs manual DI on Android — how do you choose for a real app?
Dependency injection ranges from plain constructor calls to compile-time codegen — Hilt, Koin, and manual DI trade off build speed, runtime safety, and how much magic you accept.
LEARN BY DOINGPut your intuition to work3 steps
Hilt vs Koin vs manual DI on Android — how do you choose for a real app?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#What dependency injection is actually solving
A class that builds its own dependencies — private val repository = UserRepositoryImpl(RetrofitClient.api) sitting inside a ViewModel's constructor — is coupled to one concrete implementation forever. You can't substitute a fake in a test without touching production code, you can't swap the real network client for an offline one in a demo build, and if two classes each construct their own Retrofit instance you've silently duplicated an object that was supposed to be a singleton. Dependency injection just means the opposite: a class declares what it needs as constructor parameters, and something else — a caller, a factory, or a framework — is responsible for building the concrete instance and passing it in. A DI framework's entire job is to automate the "build the whole object graph and hand out the right instance at the right scope" part so you don't hand-wire it everywhere yourself. On Android, the real decision isn't "should I do DI" — constructor injection is close to free — it's "how much automation do I want, and what do I trade for it."
#Manual DI: a container you write and own
The simplest approach has no library at all: one class holds long-lived singletons behind by lazy, and you construct short-lived objects (like a ViewModel) through a small factory that pulls what it needs from that container.
// A hand-rolled container — this is the entire "DI framework."class AppContainer(context: Context) {private val httpClient: OkHttpClient by lazy {OkHttpClient.Builder().build()}private val retrofit: Retrofit by lazy {Retrofit.Builder().baseUrl("https://api.example.com/").client(httpClient).addConverterFactory(MoshiConverterFactory.create()).build()}val userApi: UserApi by lazy { retrofit.create(UserApi::class.java) }val userRepository: UserRepository by lazy { UserRepositoryImpl(userApi) }}class MyApplication : Application() {lateinit var container: AppContaineroverride fun onCreate() {super.onCreate()container = AppContainer(this)}}class UserViewModelFactory(private val repository: UserRepository) : ViewModelProvider.Factory {@Suppress("UNCHECKED_CAST")override fun <T : ViewModel> create(modelClass: Class<T>): T = UserViewModel(repository) as T}
This costs nothing at build time, has zero reflection or generated code, and every dependency is one Ctrl/Cmd-click away from its real implementation — there is no framework layer to learn to "see through." It stops scaling once the graph gets large: the container class turns into a long, ordering-sensitive file, passing dependencies through several constructors ("constructor drilling") gets tedious, and nothing stops two people from accidentally standing up a second singleton somewhere else in the app.
#Hilt: compile-time codegen, Android-aware
Hilt is Google's official DI library, built on top of Dagger, and its defining trait is that the entire object graph is validated at compile time. @Provides/@Binds methods declare how to build each type, @InstallIn pins a module to a component whose lifetime matches an Android concept (SingletonComponent, ActivityComponent, ViewModelComponent, and so on), and if a binding is missing, ambiguous, or scoped incorrectly, the build fails — not a crash three screens deep in production.
@HiltAndroidAppclass MyApp : Application()@Module@InstallIn(SingletonComponent::class)object NetworkModule {@Provides@Singletonfun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()@Provides@Singletonfun provideRetrofit(client: OkHttpClient): Retrofit =Retrofit.Builder().baseUrl("https://api.example.com/").client(client).addConverterFactory(MoshiConverterFactory.create()).build()@Providesfun provideUserApi(retrofit: Retrofit): UserApi = retrofit.create(UserApi::class.java)}@Module@InstallIn(SingletonComponent::class)abstract class RepositoryModule {@Bindsabstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository}@HiltViewModelclass UserViewModel @Inject constructor(private val repository: UserRepository,) : ViewModel()@AndroidEntryPointclass MainActivity : ComponentActivity() {// Hilt injects fields/ViewModels here automatically — no factory to write.}
The payoff is real: @HiltViewModel removes the need to hand-write a ViewModelProvider.Factory, @AndroidEntryPoint wires injection into Activities/Fragments/Services/WorkManager Workers without you touching their constructors, and Hilt ships dedicated testing artifacts for swapping bindings in instrumented tests. The cost is annotation processing — historically kapt, which is one of the slower parts of a large Gradle build — plus a real learning curve around components, scopes, and qualifiers before the error messages stop feeling opaque.
#Koin: a pure-Kotlin DSL, no codegen
Koin takes the opposite bet: no annotation processing, no generated code — dependencies are declared in a Kotlin DSL and resolved through a runtime service-locator-style container.
val networkModule = module {single { OkHttpClient.Builder().build() }single {Retrofit.Builder().baseUrl("https://api.example.com/").client(get()).addConverterFactory(MoshiConverterFactory.create()).build()}single { get<Retrofit>().create(UserApi::class.java) }}val repositoryModule = module {single<UserRepository> { UserRepositoryImpl(get()) }}val viewModelModule = module {viewModel { UserViewModel(get()) }}class MyApp : Application() {override fun onCreate() {super.onCreate()startKoin {androidLogger()androidContext(this@MyApp)modules(networkModule, repositoryModule, viewModelModule)}}}class UserActivity : ComponentActivity() {private val viewModel: UserViewModel by viewModel()}
Builds stay fast because there's nothing to generate, everything you're reading is the actual Kotlin that runs (no digging through build/generated), and Koin has first-class Kotlin Multiplatform support, which Hilt — being built on Dagger and deeply Android-specific — does not. The trade-off is that a missing or mistyped binding is a runtime failure: Koin can only tell you the graph is broken when the code path actually executes, unless the team disciplines itself to call checkModules()/use Koin's test module verification in CI.
#Choosing for a real app
| Criterion | Manual DI | Koin | Hilt |
|---|---|---|---|
| Build-time cost | None | Low | Higher (annotation processing) |
| Error detection | Compile-time (you wrote every wire) | Runtime, unless you add checkModules() | Compile-time — the graph is validated |
| Learning curve | Low | Low–medium | Medium–high |
| Android lifecycle integration | You build it by hand | Manual, via extensions | First-class (@AndroidEntryPoint, @HiltViewModel, WorkManager) |
| Kotlin Multiplatform | Yes, it's just your code | Yes | No — Android/JVM only |
| Best fit | Tiny apps, prototypes, teaching | Small–medium teams, KMP projects, fast iteration | Large codebases, big teams, long-lived production apps |