Por qué la inyección de dependencias importa
Si llegaste a este artículo, probablemente ya sabés qué es DI. Pero vale la pena recordar por qué existe: el objetivo no es "inyectar dependencias por las buenas prácticas" — el objetivo es que el código sea testeable y que los cambios de implementación sean baratos.
Sin DI, una clase crea sus propias dependencias. Eso hace imposible reemplazarlas en un test. Con DI, las dependencias se reciben desde afuera — podés reemplazarlas por fakes en testing sin cambiar el código de producción.
Hilt y Koin son dos formas de automatizar esa inyección. La pregunta no es cuál es "mejor" en abstracto — es cuál es la correcta para tu situación.
Cómo funciona Hilt
Hilt está construido sobre Dagger 2, que usa generación de código en tiempo de compilación. Cuando compilás tu app, el compilador de Hilt (via KSP) analiza tus anotaciones (@Inject, @Module, @Provides, etc.) y genera clases Java que conectan las dependencias.
// Lo que vos escribís:
@HiltViewModel
class ProductosViewModel @Inject constructor(
private val repo: ProductoRepository
) : ViewModel()
// Lo que Hilt genera en tiempo de compilación (simplificado):
// ProductosViewModel_HiltModules_BindsModule
// ProductosViewModel_Factory
// etc.
// Clases que el sistema de DI usa para construir el ViewModel
El resultado: en runtime no hay reflection, no hay lookup dinámico. Todo el grafo de dependencias está resuelto en tiempo de compilación. Si cometés un error — una dependencia faltante, un ciclo, un scope incorrecto — el build falla con un mensaje de error claro.
Cómo funciona Koin
Koin usa un enfoque completamente diferente: es un service locator construido con Kotlin DSL. No genera código — las dependencias se registran en un mapa en runtime y se resuelven cuando se necesitan.
// Definir módulos con DSL
val appModule = module {
single<ProductoRepository> { ProductoRepositoryImpl(get(), get()) }
factory { ProductoDao(get()) }
viewModel { ProductosViewModel(get()) }
}
// Inicializar en Application
class MiApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MiApp)
modules(appModule)
}
}
}
// Inyectar con by inject() o by viewModel()
class ProductosFragment : Fragment() {
private val viewModel: ProductosViewModel by viewModel()
private val repo: ProductoRepository by inject()
}
El resultado: setup más rápido, sintaxis más limpia, cero boilerplate de generación de código. Pero las dependencias se resuelven en runtime — los errores aparecen cuando la app corre, no cuando compila.
La diferencia fundamental
Esta es la distinción que importa más que cualquier otra:
// HILT — el error aparece en compilación:
@HiltViewModel
class MiViewModel @Inject constructor(
private val repo: ProductoRepository // si no hay binding para esto → BUILD FAIL
) : ViewModel()
// Error: [Dagger/MissingBinding] ProductoRepository cannot be provided
// → Sabés el problema antes de correr la app, en CI, en code review
// KOIN — el error aparece en runtime:
val appModule = module {
viewModel { MiViewModel(get()) }
// Olvidaste declarar ProductoRepository
}
// Compila perfectamente.
// Cuando el usuario navega a la pantalla que crea el ViewModel:
// org.koin.core.error.NoBeanDefFoundException: No definition found for class ProductoRepository
// → Crash en producción, reportado por Crashlytics, no en desarrollo
Esta diferencia no es menorLos errores de runtime de Koin son los más difíciles de detectar: solo aparecen cuando se navega a la pantalla específica que usa esa dependencia. Si la pantalla tiene poco tráfico, el error puede pasar desapercibido en QA y llegar a producción.
Setup comparado
Koin gana claramente en velocidad de setup. Hilt requiere más configuración inicial pero cero plumbing manual después.
// ── KOIN ────────────────────────────────────────────────────
// build.gradle — una sola dependencia
implementation("io.insert-koin:koin-android:3.5.6")
implementation("io.insert-koin:koin-androidx-compose:3.5.6") // si usás Compose
// Application — 5 líneas
startKoin {
androidContext(this@App)
modules(appModule, networkModule, databaseModule)
}
// Módulo — DSL limpia
val appModule = module {
single<ProductoRepository> { ProductoRepositoryImpl(get(), get()) }
viewModel { ProductosViewModel(get()) }
}
// ── HILT ─────────────────────────────────────────────────────
// build.gradle — plugin + dependencias
plugins { id("com.google.dagger.hilt.android") }
implementation("com.google.dagger:hilt-android:2.51")
ksp("com.google.dagger:hilt-compiler:2.51")
// Application — una anotación
@HiltAndroidApp
class MiApp : Application()
// Módulo — más verboso pero más explícito
@Module
@InstallIn(SingletonComponent::class)
abstract class RepositoryModule {
@Binds @Singleton
abstract fun bindProductoRepository(impl: ProductoRepositoryImpl): ProductoRepository
}
// Cada Activity/Fragment — una anotación
@AndroidEntryPoint
class ProductosFragment : Fragment()
ViewModel injection — las dos formas
// ── KOIN ────────────────────────────────────────────────────
// Declarar:
val appModule = module {
viewModel { ProductosViewModel(get(), get()) }
// Con parámetros dinámicos:
viewModel { (productoId: Int) -> DetalleViewModel(get(), productoId) }
}
// Inyectar en Fragment:
private val viewModel: ProductosViewModel by viewModel()
// Con parámetros:
private val viewModel: DetalleViewModel by viewModel(
parameters = { parametersOf(args.productoId) }
)
// ── HILT ─────────────────────────────────────────────────────
// Declarar:
@HiltViewModel
class ProductosViewModel @Inject constructor(
private val repo: ProductoRepository,
private val savedStateHandle: SavedStateHandle // inyectado automáticamente
) : ViewModel()
// Inyectar en Fragment (una línea):
private val viewModel: ProductosViewModel by viewModels()
// Con SavedStateHandle los argumentos de Navigation llegan automáticamente:
@HiltViewModel
class DetalleViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val repo: ProductoRepository
) : ViewModel() {
val productoId = savedStateHandle.get<Int>("productoId")
}
Testing — la diferencia más práctica
En testing es donde la arquitectura de DI se pone a prueba. Los dos enfoques son completamente distintos:
// ── KOIN — reemplazar módulos en tests ───────────────────────
@RunWith(AndroidJUnit4::class)
class ProductosViewModelTest : KoinTest {
private val fakeRepo = FakeProductoRepository()
@Before
fun setup() {
// Reemplazar el módulo de producción con uno de test
startKoin {
modules(
module {
single<ProductoRepository> { fakeRepo } // fake
viewModel { ProductosViewModel(get()) }
}
)
}
}
@After
fun tearDown() { stopKoin() }
@Test
fun `cargar productos emite Success`() = runTest {
val vm = get<ProductosViewModel>()
// test...
}
}
// ── HILT — reemplazar con @TestInstallIn ─────────────────────
// Módulo de test que reemplaza al de producción:
@TestInstallIn(
components = [SingletonComponent::class],
replaces = [RepositoryModule::class]
)
@Module
abstract class FakeRepositoryModule {
@Binds @Singleton
abstract fun bindFakeRepo(fake: FakeProductoRepository): ProductoRepository
}
@HiltAndroidTest
class ProductosViewModelTest {
@get:Rule val hiltRule = HiltAndroidRule(this)
@Inject lateinit var viewModel: ProductosViewModel
@Before
fun setup() { hiltRule.inject() }
@Test
fun `cargar productos emite Success`() = runTest {
// test...
}
}
Koin es más fácil de configurar para tests. Hilt requiere más setup pero los tests están más aislados entre sí — cada test parte de cero, no hay estado compartido entre tests si alguien se olvidó de limpiar el módulo.
Performance — el debate real
Koin tiene fama de ser "más lento" por usar reflection. La realidad es más matizada:
Desde Koin 3.x, la resolución de dependencias usa lazy evaluation y caching. El primer acceso a una dependencia tiene overhead, los siguientes son casi gratuitos. En la práctica, la diferencia en startup time entre Hilt y Koin en una app mediana es de decenas de milisegundos — perceptible en benchmarks, imperceptible para el usuario.
Donde Hilt gana más claramente en performance es en build time: al usar KSP en lugar de KAPT, los builds incrementales son más rápidos. En proyectos grandes con muchos módulos, esto sí se nota en el día a día de desarrollo.
El verdadero costo de Koin en runtimeNo es tanto la velocidad de resolución sino la ausencia de verificación. El grafo de dependencias de Koin se construye en runtime — si hay errores, se descubren tarde. Hilt construye el grafo en compilación y garantiza que si el build pasa, el grafo es correcto.
Proyectos multimodulo
Uno de los casos donde la diferencia es más marcada:
// ── KOIN en multimodulo ───────────────────────────────────────
// Cada módulo de Gradle define sus módulos Koin:
// :feature:productos → productosModule
// :feature:checkout → checkoutModule
// :core:network → networkModule
// El módulo :app los junta todos:
startKoin {
modules(networkModule, productosModule, checkoutModule)
}
// Simple pero sin verificación cross-module en compilación
// ── HILT en multimodulo ───────────────────────────────────────
// Cada módulo de Gradle puede contribuir al grafo de Hilt
// con sus propios @Module instalados en los componentes correctos:
// En :core:network:
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule { /* ... */ }
// En :feature:productos:
@Module
@InstallIn(ViewModelComponent::class)
abstract class ProductosModule { /* ... */ }
// Hilt verifica la consistencia del grafo completo en compilación
// incluyendo dependencias cross-module
En proyectos con muchos módulos, Hilt escala mejor porque la verificación en compilación detecta inconsistencias entre módulos antes de que lleguen a producción. Con Koin, un módulo Gradle puede declarar que provee algo que otro espera, y si hay un error solo aparece en runtime.
Calidad de los mensajes de error
Un factor práctico que raramente se menciona: la calidad de los mensajes de error cuando algo falla.
# Error de Hilt — compilación, claro y accionable:
# error: [Dagger/MissingBinding] ProductoRepository cannot be provided
# without an @Inject constructor or an @Provides-annotated method.
#
# A binding for ProductoRepository was injected at:
# ProductosViewModel(repo) ← dónde se necesita
# The following other entry points also depend on it: [...]
#
# → Sabés exactamente qué falta y en qué clase
# Error de Koin — runtime, stack trace extenso:
# org.koin.core.error.NoBeanDefFoundException:
# No definition found for class:'ar.pensa.app.ProductoRepository'.
# Check your definitions!
#
# Caused by: ...
# at org.koin.core.registry.InstanceRegistry.resolveInstance(...)
# at ...20 more frames...
# at ProductosFragment.onCreate(ProductosFragment.kt:32)
#
# → Tenés que rastrear de dónde viene el NoBeanDefFoundException
La recomendación — sin vueltas
Después de trabajar con los dos en proyectos de distintos tamaños, mi posición es clara:
Usá Hilt si:
- El proyecto va a vivir más de 6 meses y tiene más de 2 desarrolladores.
- Necesitás proyectos multimodulo o ya estás en ese camino.
- La seguridad de detectar errores en compilación vale el setup inicial.
- Ya usás otras librerías de Google/Jetpack y querés consistencia.
- Venís del mundo Dagger y Hilt es una mejora directa.
Usá Koin si:
- Es un proyecto personal, prototipo o app de una persona.
- El equipo es pequeño y prefiere la simplicidad por encima de todo.
- Necesitás algo funcionando rápido — la curva de aprendizaje de Koin es notablemente menor.
- El proyecto también tiene módulos Kotlin puro o Kotlin Multiplatform — Koin funciona en KMP, Hilt no.
Kotlin Multiplatform es el caso de uso estrella de Koin hoySi tu proyecto apunta a KMP (Android + iOS con código compartido), Koin es prácticamente la única opción con soporte maduro para el módulo compartido. En ese contexto, la decisión está tomada.
Hay una última consideración que muchos ignoran: el costo de migrar es alto. Si empezás con Koin y el proyecto crece, migrar a Hilt es refactoring significativo — cambiar todas las anotaciones, los módulos, los tests. La decisión inicial importa más de lo que parece.
Para la mayoría de los proyectos Android profesionales en 2025, Hilt es la elección correcta. No porque Koin sea malo — es una librería excelente — sino porque la verificación en compilación y la integración con Jetpack hacen que los proyectos grandes sean más mantenibles y los errores se detecten antes.