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

Por qué la inyección de dependencias es inevitable en MVI

El contenedor de MVI promete determinismo: mismo estado, misma intención, mismo resultado. Esa promesa se rompe en el instante en que el contenedor fabrica por dentro las piezas de las que depende, porque entonces arrastra consigo la red, el reloj y la base de datos, y deja de ser una función para convertirse en un sistema. Esta lección reconstruye el principio de inversión de dependencias desde el problema concreto del contenedor, distingue construir de recibir, enumera los tres acoplamientos que introduce el `new` escondido y muestra cómo la sustitución por dobles deja de ser un truco de test para convertirse en una consecuencia estructural del diseño.

⏱ 18 min

Todo lo construido hasta aquí descansa sobre una promesa muy concreta: que el contenedor es determinista, que dado un estado y una intención el resultado es siempre el mismo, y que por tanto se puede razonar sobre él como se razona sobre una función. Esa promesa es cierta mientras el contenedor solo manipule datos. Deja de serlo en el momento exacto en que dentro de su cuerpo aparece la creación de un cliente de red, de un acceso a base de datos o de una lectura del reloj del sistema, porque a partir de ahí el resultado ya no depende solo de sus entradas visibles, sino de un mundo que el contenedor arrastra consigo sin declararlo. La inyección de dependencias no es una técnica de fontanería ni una moda de frameworks: es el mecanismo por el que un objeto declara qué necesita en lugar de ir a buscarlo, y esa declaración es lo que devuelve al contenedor su condición de función razonable.

🎯 Al terminar esta lección sabrás
  • Distinguir entre construir una dependencia y recibirla, y entender por qué esa diferencia decide la testabilidad.
  • Formular el principio de inversión de dependencias en los términos concretos del contenedor MVI.
  • Identificar los tres acoplamientos que introduce instanciar colaboradores dentro del contenedor.
  • Sustituir una dependencia real por un doble sin tocar una sola línea de la lógica de reducción.

El contenedor que fabrica sus propias piezas

El defecto es tan común que casi no se ve. Un contenedor necesita datos, alguien escribe la línea que crea el repositorio dentro de la clase, todo compila, la pantalla funciona, y el problema queda sembrado.

class PerfilContainer : ContainerHost<PerfilState, PerfilEfecto> {

    // El contenedor decide QUE implementacion usa y COMO se construye
    private val repositorio = RepositorioHttp(
        cliente = OkHttpClient(),
        base = "https://api.example.com",
    )

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

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

Esa línea introduce tres acoplamientos distintos, y conviene separarlos porque tienen remedios distintos. El primero es al tipo concreto: el contenedor no depende de la idea de un repositorio de perfiles, sino de esa clase exacta, con ese nombre y ese paquete. El segundo es al procedimiento de construcción: el contenedor sabe que hace falta un cliente y una dirección base, y por tanto cualquier cambio en la forma de construir el repositorio obliga a recompilar y modificar una clase que no tiene nada que ver con la red. El tercero, el más caro, es al ciclo de vida: ese cliente se crea una vez por pantalla, de modo que diez pantallas abren diez agrupaciones de conexiones y ninguna comparte caché con las demás.

⚠️
El test que no se puede escribir

La consecuencia práctica es inmediata: no existe manera de probar la lógica de reducción sin ejecutar la red. No es que el test sea lento, es que no es un test de la lógica; es un test del servidor, del DNS, del certificado y del contenedor a la vez. Cuando falla no dice qué está roto, y cuando pasa no dice qué está bien. Un test que depende de un sistema que no controlas no mide corrección, mide disponibilidad.

Recibir en lugar de buscar

La corrección es de una simpleza que engaña, porque su alcance conceptual es mucho mayor que su tamaño en líneas: el contenedor deja de crear y pasa a exigir.

interface RepositorioPerfil {
    suspend fun obtener(): Perfil
}

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

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

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

Lo relevante no es el ahorro de una línea, sino la inversión de la dirección de la dependencia. Antes, la clase de alto nivel —la que contiene las reglas del negocio de la pantalla— dependía de una clase de bajo nivel —la que sabe de sockets y de JSON—. Ahora ambas dependen de una abstracción que pertenece, conceptualmente, al lado de las reglas: es la lógica la que declara qué contrato necesita, y es la infraestructura la que se pliega a cumplirlo. Ese giro es lo que Robert Martin bautizó como principio de inversión de dependencias, y su formulación canónica dice exactamente esto: los módulos de alto nivel no deben depender de los de bajo nivel, ambos deben depender de abstracciones.

Hay una prueba de olfato muy fiable para saber si una clase respeta el principio: mira sus importaciones. Si el contenedor importa okhttp3, retrofit2, androidx.room o android.content.Context, la inversión no ha ocurrido, por muchas interfaces que haya declarado alrededor.

El grafo y la raíz de composición

Si nadie construye las dependencias dentro, alguien tiene que construirlas fuera. Ese alguien tiene nombre propio: la raíz de composición, el único punto del programa que conoce a la vez todas las abstracciones y todas las implementaciones, y cuyo trabajo consiste en ensamblarlas.

flowchart TD
R[Raiz de composicion] --> C[Cliente HTTP compartido]
R --> B[Base de datos local]
C --> I[RepositorioHttp]
B --> I
I -->|cumple el contrato| A[Interfaz RepositorioPerfil]
A --> V[PerfilContainer]
V --> P[Pantalla de perfil]
style A fill:#a6e3a1,color:#11111b
style R fill:#89b4fa,color:#11111b
style V fill:#f9e2af,color:#11111b

La estructura que dibuja ese ensamblaje no es una lista sino un grafo dirigido y acíclico: cada nodo es un tipo, cada arista es un “necesita”, y la ausencia de ciclos es lo que garantiza que existe un orden de construcción válido. Un framework de inyección de dependencias no es más que un mecanismo para describir ese grafo una vez y resolverlo automáticamente, en vez de escribir a mano una cascada de constructores que crece de forma cuadrática con el número de pantallas.

ℹ️
La inyección manual es inyección de verdad

Conviene decirlo pronto para evitar el malentendido más frecuente del nivel: pasar dependencias por constructor y ensamblarlas a mano en la clase de aplicación ya es inyección de dependencias, y en proyectos pequeños es a menudo la elección correcta. Hilt y Koin no aportan el principio, que es anterior a ambos y no necesita ninguna librería; aportan la automatización del grafo y el manejo de los ámbitos. Adoptar un framework antes de sentir el dolor que resuelve es pagar una complejidad cuya contrapartida todavía no existe.

Lo que gana el test

El resultado se ve mejor en el punto donde el diseño se cobra: el test unitario del contenedor pasa de imposible a trivial, y lo hace sin ninguna concesión en el código de producción.

class RepositorioPerfilFalso(
    private val respuesta: Perfil,
) : RepositorioPerfil {
    override suspend fun obtener(): Perfil = respuesta
}

@Test
fun `cargar deja el perfil en el estado y apaga el indicador`() = runTest {
    val contenedor = PerfilContainer(RepositorioPerfilFalso(Perfil("Ada")))

    contenedor.test(this) {
        expectInitialState()
        containerHost.cargar()
        expectState { copy(cargando = true) }
        expectState { copy(cargando = false, perfil = Perfil("Ada")) }
    }
}

Nótese lo que este test no necesita: ni servidor, ni permisos de red, ni esperas, ni limpieza posterior, ni tolerancia a fallos intermitentes. Y nótese sobre todo lo que sí puede hacer y antes no: un doble que lanza una excepción prueba la rama de error, uno que tarda prueba la cancelación, y uno que devuelve listas vacías prueba el estado vacío. Cada uno de esos escenarios era, con el repositorio construido dentro, o imposible o dependiente de convencer a un servidor real de que fallara a demanda.

La inyección no sirve para cambiar implementaciones: sirve para poder pensar en partes

La justificación que se repite en las entrevistas —“así puedo cambiar la implementación mañana”— es la más débil de todas y además casi siempre falsa: la inmensa mayoría de las interfaces extraídas por si acaso tendrán una sola implementación durante toda la vida del proyecto, más el doble de test. Si esa fuera la razón, el coste no compensaría. La razón real es otra y es epistemológica antes que técnica: un objeto que construye sus colaboradores no se puede entender solo, porque entenderlo exige entender también todo lo que arrastra, y esa clausura transitiva crece hasta abarcar el programa entero. Declarar las dependencias en el constructor convierte una propiedad implícita y difusa —“esto usa la red, en algún sitio, quizá”— en una firma legible que dice exactamente de qué depende esta clase y nada más. Ahí está el verdadero rendimiento: no en la sustitución, sino en la delimitación. El constructor pasa a ser una declaración de alcance, y con ella se vuelve posible razonar sobre una pieza sin cargar mentalmente el resto del sistema, que es la única forma conocida de que un programa siga siendo comprensible cuando supera el tamaño de una cabeza. La testabilidad, que es lo que todo el mundo cita, es apenas el síntoma observable de esa propiedad más profunda: un objeto es fácil de probar exactamente cuando es fácil de aislar, y es fácil de aislar exactamente cuando ha dejado de decidir por su cuenta quiénes son sus colaboradores. Por eso un diseño que solo se puede probar con la máquina entera encendida no tiene un problema de tests: tiene un problema de fronteras, y los tests son el mensajero.

⚔️ Invierte una dependencia de tu propio código
  1. Busca en tu proyecto un contenedor o ViewModel que instancie algo dentro de su cuerpo y anota las tres formas de acoplamiento que eso produce.
  2. Extrae la interfaz mínima con las operaciones que ese contenedor usa de verdad, sin añadir métodos por si acaso.
  3. Mueve la construcción de la implementación real a la clase de aplicación y pasa la dependencia por constructor.
  4. Escribe un doble que devuelva datos fijos y un test que verifique la secuencia de estados sin tocar la red.
  5. Argumenta en cinco líneas si la interfaz que has extraído está justificada por el aislamiento o solo por el test, y qué harías si la respuesta fuese la segunda.