wandres.dev
INYECCIÓN DE DEPENDENCIAS · Hilt y Koin

Koin: el grafo declarado en Kotlin y resuelto en ejecución

Koin renuncia deliberadamente a la generación de código y describe el grafo con un DSL de Kotlin puro que se resuelve al pedir cada objeto. Eso elimina el procesador de anotaciones, acorta la compilación y hace que las definiciones sean código normal que se puede leer, componer y depurar; a cambio, un proveedor ausente deja de ser un error de compilación y pasa a ser una excepción en ejecución. Esta lección examina el DSL, el papel de `single` y `factory`, los ámbitos, la red de seguridad de `checkModules` y el criterio honesto para elegir entre garantía estática y agilidad.

⏱ 18 min

Koin parte de una intuición que merece respeto: si Kotlin ya es un lenguaje expresivo con funciones de orden superior, receptores y lambdas con contexto, quizá no haga falta un lenguaje de anotaciones encima ni un procesador que lo traduzca. El grafo de dependencias es, al fin y al cabo, un conjunto de recetas para construir objetos, y eso se puede escribir directamente como código. La consecuencia es un DSL que se lee casi como prosa, sin generación intermedia, sin tiempos de compilación penalizados y sin errores crípticos escupidos por un procesador. La contrapartida es exacta y hay que enunciarla sin adornos: lo que Hilt te demuestra antes de ejecutar, Koin te lo descubre al pedirlo. No es que uno sea correcto y el otro no; es que cada uno decide en qué momento del ciclo de vida del proyecto quiere pagar la factura de sus errores.

🎯 Al terminar esta lección sabrás
  • Escribir un módulo de Koin con el DSL de Kotlin y distinguir single de factory por su ciclo de vida.
  • Entender cuándo se resuelve realmente una dependencia y qué fallos quedan latentes hasta ese momento.
  • Declarar ámbitos y conectar el contenedor MVI mediante las integraciones de ViewModel y Compose.
  • Aplicar checkModules como red de seguridad y evaluar hasta dónde compensa la garantía que ofrece.

El grafo escrito como código

Un módulo de Koin no es un fichero de configuración ni una clase anotada: es una función que devuelve una lista de definiciones, y por tanto se puede componer, parametrizar y leer de arriba abajo sin descifrar ninguna convención.

val redModule = module {
    single { OkHttpClient.Builder().build() }
    single<PerfilApi> {
        Retrofit.Builder()
            .baseUrl("https://api.example.com")
            .client(get())
            .build()
            .create(PerfilApi::class.java)
    }
}

val datosModule = module {
    single<RepositorioPerfil> { RepositorioHttp(api = get()) }
    factory { MapeadorPerfil() }
}

class Aplicacion : Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@Aplicacion)
            modules(redModule, datosModule, presentacionModule)
        }
    }
}

La llamada a get es el corazón del mecanismo: dentro de una definición, get resuelve otra definición por su tipo. Como el DSL es Kotlin normal, la inferencia de tipos hace casi todo el trabajo, y el parámetro de tipo explícito —single<RepositorioPerfil>— sirve para registrar la implementación bajo el contrato y no bajo su clase concreta, que es justo la inversión que se busca.

La diferencia entre single y factory es de ciclo de vida y no de rendimiento: single construye una vez y devuelve siempre la misma instancia mientras viva el contenedor raíz; factory construye una nueva en cada petición. El criterio para elegir es el mismo de siempre: si el objeto guarda estado que debe compartirse, single; si no guarda estado o su estado es privado de quien lo pide, factory.

Dónde y cuándo estalla lo que falta

Aquí está la diferencia sustantiva con el enfoque de compilación, y conviene verla con precisión temporal en vez de en abstracto.

flowchart LR
A[Compilacion: el DSL solo es codigo valido] --> B[Arranque: startKoin registra definiciones]
B --> C[Primera peticion: se resuelve el tipo]
C --> D{Existe definicion}
D -->|si| E[Objeto construido]
D -->|no| F[NoDefinitionFoundException en ejecucion]
style A fill:#89b4fa,color:#11111b
style F fill:#f38ba8,color:#11111b
style E fill:#a6e3a1,color:#11111b

El detalle crítico es que startKoin no valida el grafo: registra definiciones. Una dependencia sin proveedor no falla al arrancar, falla cuando alguien la pide por primera vez, es decir, cuando el usuario abre esa pantalla concreta. Si esa pantalla está detrás de tres navegaciones y de un permiso, el fallo puede llegar a producción con toda naturalidad, porque nadie del equipo recorrió ese camino después del último cambio de módulos.

⚠️
`checkModules` cierra parte de la brecha, no toda

Koin ofrece una verificación explícita que recorre las definiciones y comprueba que todas las dependencias declaradas se pueden resolver. Ejecutada como test en la integración continua, convierte una clase entera de fallos en un test rojo antes de fusionar. Pero su alcance tiene límites reales: no cubre bien las definiciones que requieren parámetros en el momento de la petición, ni las resoluciones dentro de ámbitos que no se han abierto, ni los usos de get escritos dentro del cuerpo de una clase en lugar de en su constructor. Es una red de seguridad excelente y una demostración incompleta, y tratarla como lo segundo es exactamente el error que produce la falsa sensación de paridad con Hilt.

Ámbitos y el contenedor MVI

Koin también tiene ámbitos, aunque su naturaleza es distinta: no forman una jerarquía fijada por el framework, sino que se abren y se cierran explícitamente asociados a un objeto. Para el caso de MVI, lo habitual es apoyarse en la integración de ViewModel, que ya conoce el ciclo de vida de Android.

val presentacionModule = module {
    viewModel { PerfilViewModel(repositorio = get()) }
    // Version reflexiva equivalente, mas breve y menos explicita
    viewModelOf(::AjustesViewModel)

    scope<SesionCompra> {
        scoped { CarritoEnMemoria() }
        scoped<CalculadoraTotales> { CalculadoraImpl(carrito = get()) }
    }
}

class PerfilViewModel(
    private val repositorio: RepositorioPerfil,
) : ViewModel(), ContainerHost<PerfilState, PerfilEfecto> {

    override val container = container<PerfilState, PerfilEfecto>(PerfilState())

    fun cargar() = intent {
        reduce { state.copy(cargando = true) }
        val perfil = repositorio.obtener()
        reduce { state.copy(cargando = false, perfil = perfil) }
    }
}

En Compose, la obtención se hace con koinViewModel, simétrica a hiltViewModel, y el contenedor queda ligado al ciclo de vida del propietario del ViewModel igual que en el otro enfoque. Obsérvese que la clase del ViewModel no lleva ninguna anotación: es una clase de Kotlin corriente cuyo constructor pide lo que necesita. Esa neutralidad es una de las propiedades más subestimadas de Koin, porque significa que la lógica de presentación no importa nada del framework de inyección y puede construirse a mano en cualquier test sin ceremonia adicional.

💡
Prefiere el constructor al localizador

Koin permite obtener dependencias dentro de una clase con by inject o get, y esa capacidad es un imán para el antipatrón del localizador de servicios: la clase vuelve a buscar en vez de recibir, y con ello se pierde la delimitación que la firma del constructor hacía visible. La regla que mantiene sano un proyecto con Koin es sencilla y no admite excepciones cómodas: by inject solo en los puntos de entrada de Android que no controlas —Activity, Fragment, Service, Worker—; en todo lo demás, constructor.

El criterio de elección

La comparación honesta no enfrenta a un ganador con un perdedor, sino a dos posiciones sobre cuándo pagar. Koin cobra menos en la construcción y en la curva de entrada, y más en la superficie de fallos que llegan a ejecución. Hilt cobra más por adelantado —procesador, verbosidad, mensajes de error densos— y devuelve una demostración sobre el grafo entero.

Hay tres factores que inclinan la decisión mucho más que la preferencia estética. El primero es el tamaño del grafo: por debajo de unas pocas decenas de definiciones, la probabilidad de una arista rota que nadie ejecute es baja, y la garantía estática vale poco. El segundo es la rotación del equipo: donde entra gente nueva a menudo, la verificación al compilar sustituye al conocimiento tribal sobre qué módulo instala qué. El tercero es la multiplataforma: Koin funciona en Kotlin Multiplatform y Hilt está atado a Android y a la JVM, de modo que si el módulo de dominio debe compartirse con iOS la decisión ya está tomada por razones ajenas al debate.

Toda garantía que no da el compilador la acaba dando un humano, y los humanos se cansan

La tentación al comparar Koin y Hilt es reducirlo a rapidez de compilación contra seguridad de tipos, y ahí la discusión se atasca porque ambas magnitudes son reales y ninguna domina a la otra. El eje verdadero es otro y es más antiguo que ambos frameworks: quién sostiene la propiedad de que el sistema está bien ensamblado. Cuando el compilador la sostiene, la propiedad se mantiene sola, es invariante ante la fatiga del equipo, ante las prisas del cierre de sprint y ante la persona que hoy hace el cambio sin conocer el módulo que lo instala. Cuando la sostiene un proceso humano —una convención, una revisión, un test que alguien recuerda ejecutar—, la propiedad es tan fuerte como el eslabón más cansado de la cadena, y el cansancio no es una anomalía del proceso sino su régimen normal a partir del tercer mes. Eso no convierte a Koin en un error: convierte su elección en una apuesta explícita sobre el tamaño y la disciplina del equipo, que es exactamente la clase de apuesta que un ingeniero puede hacer con los ojos abiertos y un aficionado hace sin saber que la está haciendo. La pregunta correcta antes de elegir no es cuál es mejor, sino esta: si mañana alguien añade una dependencia y olvida declararla, ¿qué mecanismo del proyecto lo detiene antes de que un usuario lo descubra? Si la respuesta es el compilador, has comprado una garantía. Si es una revisión de código, has comprado una probabilidad. Y si la respuesta es que nadie lo detiene, entonces la elección de framework era el menor de los problemas.

⚔️ Mide la brecha entre declarar y resolver
  1. Monta un módulo de Koin con un cliente, una interfaz de repositorio y su implementación, y arranca la aplicación desde startKoin.
  2. Borra deliberadamente la definición del repositorio y anota en qué momento exacto aparece el fallo y qué había que hacer para llegar hasta él.
  3. Añade un test que ejecute checkModules y comprueba si detecta la definición borrada antes de ejecutar la aplicación.
  4. Introduce una dependencia que se resuelva con get dentro del cuerpo de una clase y verifica si la verificación la cubre; explica por qué.
  5. Escribe media página argumentando si tu proyecto concreto debería usar Koin o Hilt, citando tamaño del grafo, rotación del equipo y multiplataforma.