Hilt vs Koin vs DI manual en Android — ¿cómo elegís para una app real?
La inyección de dependencias va desde simples constructores hasta generación de código en compilación — Hilt, Koin y la DI manual negocian velocidad de build, seguridad en runtime y cuánta magia aceptás.
APRENDE HACIENDOPon a prueba tu intuición3 pasos
Hilt vs Koin vs DI manual en Android — ¿cómo elegís para una app real?
Usa los ejemplos y la explicación de la lección para comprobar tu idea.
Reflexión personal. No se envía a ningún servicio de IA.#Qué problema resuelve realmente la inyección de dependencias
Una clase que construye sus propias dependencias — private val repository = UserRepositoryImpl(RetrofitClient.api) sentado adentro del constructor de un ViewModel — queda acoplada para siempre a una única implementación concreta. No podés reemplazarla por un fake en un test sin tocar código de producción, no podés cambiar el cliente de red real por uno offline en un build de demo, y si dos clases construyen cada una su propio Retrofit, terminaste duplicando en silencio un objeto que se suponía que era un singleton. La inyección de dependencias es simplemente lo contrario: una clase declara lo que necesita como parámetros de constructor, y otra cosa — un llamador, una factory, o un framework — es responsable de construir la instancia concreta y pasársela. El trabajo completo de un framework de DI es automatizar la parte de "construir todo el grafo de objetos y entregar la instancia correcta en el scope correcto" para que no lo cablees a mano en todos lados. En Android, la decisión real no es "¿hago DI o no?" — la inyección por constructor es casi gratis — sino "cuánta automatización quiero, y qué pago a cambio".
#DI manual: un contenedor que escribís y sos dueño de él
El enfoque más simple no usa ninguna librería: una clase guarda los singletons de larga vida detrás de by lazy, y construís los objetos de vida corta (como un ViewModel) a través de una pequeña factory que saca lo que necesita de ese contenedor.
// Un contenedor artesanal — este es todo el "framework de DI".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}
Esto no cuesta nada en tiempo de build, no tiene reflection ni código generado, y cada dependencia está a un Ctrl/Cmd-clic de su implementación real — no hay una capa de framework que aprender a "atravesar con la vista". Deja de escalar en cuanto el grafo crece: la clase contenedora se convierte en un archivo largo y sensible al orden, pasar dependencias a través de varios constructores ("constructor drilling") se vuelve tedioso, y nada impide que dos personas levanten sin querer un segundo singleton en otra parte de la app.
#Hilt: generación de código en tiempo de compilación, consciente de Android
Hilt es la librería oficial de DI de Google, construida sobre Dagger, y su rasgo definitorio es que todo el grafo de objetos se valida en tiempo de compilación. Los métodos @Provides/@Binds declaran cómo construir cada tipo, @InstallIn fija un módulo a un componente cuya vida coincide con un concepto de Android (SingletonComponent, ActivityComponent, ViewModelComponent, etc.), y si falta un binding, es ambiguo o está mal scopeado, el build falla — no un crash tres pantallas más adelante en producción.
@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 inyecta campos/ViewModels acá automáticamente — no hay que escribir una factory.}
El beneficio es real: @HiltViewModel elimina la necesidad de escribir a mano un ViewModelProvider.Factory, @AndroidEntryPoint cablea la inyección en Activities/Fragments/Services/Workers de WorkManager sin tocar sus constructores, y Hilt trae artefactos de testing dedicados para reemplazar bindings en tests instrumentados. El costo es el procesamiento de anotaciones — históricamente kapt, una de las partes más lentas de un build grande de Gradle — más una curva de aprendizaje real alrededor de componentes, scopes y qualifiers antes de que los mensajes de error dejen de sentirse opacos.
#Koin: un DSL puro de Kotlin, sin generación de código
Koin apuesta lo contrario: sin procesamiento de anotaciones, sin código generado — las dependencias se declaran en un DSL de Kotlin y se resuelven a través de un contenedor en tiempo de ejecución al estilo service locator.
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()}
Los builds se mantienen rápidos porque no hay nada que generar, todo lo que estás leyendo es el Kotlin real que corre (sin tener que revisar build/generated), y Koin tiene soporte de primera clase para Kotlin Multiplatform, algo que Hilt — construido sobre Dagger y profundamente específico de Android — no tiene. La contrapartida es que un binding faltante o mal tipeado es una falla en tiempo de ejecución: Koin recién puede avisarte que el grafo está roto cuando ese camino de código realmente se ejecuta, salvo que el equipo se discipline a llamar checkModules() o usar la verificación de módulos de test de Koin en CI.
#Cómo elegir para una app real
| Criterio | DI manual | Koin | Hilt |
|---|---|---|---|
| Costo en tiempo de build | Ninguno | Bajo | Más alto (procesamiento de anotaciones) |
| Detección de errores | En compilación (vos cableaste cada conexión) | En runtime, salvo que agregues checkModules() | En compilación — el grafo se valida |
| Curva de aprendizaje | Baja | Baja–media | Media–alta |
| Integración con el ciclo de vida de Android | La construís a mano | Manual, vía extensiones | De primera clase (@AndroidEntryPoint, @HiltViewModel, WorkManager) |
| Kotlin Multiplatform | Sí, es solo tu código | Sí | No — solo Android/JVM |
| Mejor encaje | Apps chicas, prototipos, enseñanza | Equipos chicos–medianos, proyectos KMP, iteración rápida | Bases de código grandes, equipos grandes, apps de producción de vida larga |