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

Inyectar en el ViewModel: el constructor como contrato

Un ViewModel puede recibir sus colaboradores por constructor o ir a buscarlos por su cuenta a un registro global, y aunque ambas rutas compilan y funcionan, solo una deja el contrato escrito en la firma. Esta lección defiende la inyección por constructor frente al localizador de servicios con argumentos que van más allá del gusto: honestidad de la firma, fallo temprano, ausencia de estado global oculto y sustituibilidad sin framework. También trata los dos casos que la complican de verdad, los argumentos de navegación y las dependencias que dependen de datos, y cómo resolverlos sin abrir la puerta al localizador.

⏱ 19 min

Hay dos maneras de que un ViewModel obtenga lo que necesita, y la diferencia entre ambas parece cosmética hasta que se examina lo que cada una comunica. En la primera, el objeto declara en su constructor todo aquello sin lo cual no puede existir, y quien lo construye está obligado a proporcionarlo; el resultado es que la firma dice la verdad y que un objeto mal ensamblado es imposible de crear. En la segunda, el objeto se construye vacío y va a buscar sus colaboradores a un registro accesible desde cualquier punto del programa; el resultado es que la firma calla, que el objeto arrastra una dependencia invisible con el registro entero y que un ensamblaje incorrecto no se detecta al construirlo sino más tarde, cuando alguien toca la parte que faltaba. Esta lección explica por qué la primera es preferible casi siempre y qué hacer en los pocos casos donde parece que no lo es.

🎯 Al terminar esta lección sabrás
  • Justificar la inyección por constructor con cuatro argumentos independientes del framework elegido.
  • Reconocer el localizador de servicios, entender por qué resulta cómodo y qué garantía sacrifica.
  • Resolver los argumentos de navegación con SavedStateHandle en vez de con inicializadores tardíos.
  • Tratar las dependencias que dependen de datos mediante fábricas o parámetros de operación, sin recurrir al registro global.

La firma que no puede mentir

Un constructor es la única parte de una clase que el compilador obliga a satisfacer. Todo lo que aparece ahí es, por construcción, un requisito verificado; todo lo que no aparece es opcional, aunque el cuerpo lo use.

@HiltViewModel
class PedidoViewModel @Inject constructor(
    private val repositorio: RepositorioPedidos,
    private val calcularEnvio: CalcularEnvio,
    private val reloj: Reloj,
) : ViewModel(), ContainerHost<PedidoState, PedidoEfecto> {

    override val container = container<PedidoState, PedidoEfecto>(PedidoState())

    fun confirmar() = intent {
        reduce { state.copy(enviando = true) }
        val envio = calcularEnvio(state.lineas)
        val pedido = repositorio.crear(state.lineas, envio, reloj.ahora())
        reduce { state.copy(enviando = false, confirmado = pedido.id) }
        postSideEffect(PedidoEfecto.IrAResumen(pedido.id))
    }
}

Tres líneas de firma comunican más que cualquier documentación: esta pantalla habla con una fuente de pedidos, calcula portes y consulta la hora. Ese último punto merece énfasis porque suele olvidarse. Reloj no es una abstracción gratuita ni una manía purista: mientras el código llame directamente a la hora del sistema, el comportamiento del contenedor depende de cuándo se ejecuta el test, y hay reglas de negocio —descuentos que caducan, franjas horarias de reparto— cuyo test solo es reproducible si el tiempo es una dependencia declarada.

📜

Honestidad

La firma enumera exactamente de qué depende la clase. Leerla basta para saber qué hay que preparar para entenderla o probarla, sin leer el cuerpo entero.

⏱️

Fallo temprano

Si falta una pieza, el objeto no llega a existir. Con el localizador, el objeto existe roto y falla más tarde, en un punto que no señala la causa.

🧩

Sustituibilidad sin framework

En un test se construye a mano con dobles. No hace falta arrancar Hilt ni Koin, ni instalar reglas, ni limpiar registros globales entre casos.

🔒

Inmutabilidad

Las dependencias son val asignados en la construcción. No hay ventana entre crear y estar listo, ni campos que puedan ser nulos a mitad de vida.

El localizador y por qué seduce

El localizador de servicios es el patrón en el que el objeto pide sus colaboradores a un registro accesible globalmente. Su atractivo es real: no hay que tocar constructores, no hay que propagar nada por las capas intermedias y añadir una dependencia nueva cuesta una línea dentro del cuerpo.

// Antipatron: la clase busca en vez de recibir
class PedidoViewModel : ViewModel() {
    private val repositorio = ServiceLocator.get<RepositorioPedidos>()
    private val calcularEnvio = ServiceLocator.get<CalcularEnvio>()
}

// Correcto: la clase declara y recibe
class PedidoViewModel(
    private val repositorio: RepositorioPedidos,
    private val calcularEnvio: CalcularEnvio,
) : ViewModel()
flowchart LR
subgraph Localizador
  L1[ViewModel] --> L2[Registro global]
  L2 --> L3[Repositorio]
  L2 --> L4[Calculo de envio]
end
subgraph Constructor
  C1[Raiz de composicion] --> C2[ViewModel]
  C1 --> C3[Repositorio]
  C1 --> C4[Calculo de envio]
end
style L2 fill:#f38ba8,color:#11111b
style C1 fill:#a6e3a1,color:#11111b

El diagrama expone la diferencia estructural: con el localizador, la flecha sale del ViewModel hacia el registro, de modo que la clase depende del mecanismo de resolución y, a través de él, de todo lo registrado. Con el constructor, las flechas salen de la raíz hacia las hojas y el ViewModel no conoce a nadie por encima de sí mismo. El primer grafo tiene un nodo por el que pasa todo; el segundo no tiene ningún punto de acoplamiento común.

⚠️
El coste que aparece en el test, no en producción

Un localizador obliga a que cada test configure y limpie un registro compartido. Si un caso olvida limpiarlo, contamina a los siguientes, y aparece la patología más venenosa de una suite: tests que pasan solos y fallan en grupo, o que dependen del orden de ejecución. La causa nunca está en el test que falla, lo que multiplica el tiempo de diagnóstico. La inyección por constructor no puede producir esa clase de fallo, porque no existe nada compartido entre casos.

Argumentos de navegación y dependencias con datos

Hay dos situaciones donde el constructor parece insuficiente, y ambas tienen solución sin abrir la mano.

La primera es el identificador que llega por navegación. La tentación es un método inicializar que se llama desde la interfaz, pero eso crea una ventana en la que el ViewModel existe con estado inválido y depende de que alguien recuerde llamarlo. La solución correcta es SavedStateHandle, que el framework provee al grafo y que llega con los argumentos ya dentro.

@HiltViewModel
class DetalleViewModel @Inject constructor(
    private val repositorio: RepositorioPedidos,
    estadoGuardado: SavedStateHandle,
) : ViewModel(), ContainerHost<DetalleState, DetalleEfecto> {

    private val pedidoId: String = checkNotNull(estadoGuardado["pedidoId"])

    override val container = container<DetalleState, DetalleEfecto>(DetalleState(pedidoId)) {
        cargar()
    }

    private fun cargar() = intent {
        val pedido = repositorio.obtener(pedidoId)
        reduce { state.copy(pedido = pedido) }
    }
}

La segunda situación es una dependencia que solo se puede construir con un dato conocido en ejecución. Aquí caben dos respuestas legítimas. Si el dato acompaña a una operación concreta, pasa a ser un parámetro de esa operación y desaparece del problema. Si de verdad hace falta construir un colaborador con ese dato, se inyecta una fábrica —una interfaz con un método que recibe el dato y devuelve el colaborador—, con lo que la dependencia sigue declarada y sigue siendo sustituible.

💡
Inyecta contratos, no infraestructura

Un ViewModel que recibe seis dependencias no tiene un problema de inyección: tiene un problema de responsabilidades, y el constructor solo lo está haciendo visible. Esa visibilidad es una ventaja, no una molestia. Y hay una frontera que conviene no cruzar: no se inyectan tipos de Android en la lógica. Un contexto, un Resources o un SharedPreferences dentro del contenedor devuelven el acoplamiento a la plataforma que la abstracción pretendía eliminar; lo que se inyecta es el contrato, y quien conoce Android es su implementación.

El constructor no es una puerta de entrada: es la definición de qué significa existir bien

Detrás de la elección entre constructor y localizador hay una pregunta que precede al software y que la programación heredó del diseño de tipos: cuándo se considera que un objeto está completo. Con el constructor, completo y existente son la misma cosa, porque no hay ningún instante del programa en que la referencia exista y le falte algo; el tipo, entendido como conjunto de valores válidos, no contiene ningún elemento roto. Con el localizador, existir y estar completo se separan, y esa separación abre un intervalo temporal —a veces microsegundos, a veces la vida entera del proceso— durante el cual la referencia es válida para el compilador y falsa para el dominio. Toda la clase de fallos que llamamos de inicialización vive en ese intervalo, y no se elimina con más cuidado sino cerrando el intervalo por diseño. Por eso el argumento decisivo no es que el constructor sea más limpio ni más testeable, aunque lo sea: es que hace inexpresable el estado inválido, que es la misma estrategia que nos lleva a preferir tipos sellados a cadenas de texto y campos no nulos a comprobaciones defensivas. Cuando esa idea se interioriza, la inyección de dependencias deja de percibirse como una capa de infraestructura y se revela como lo que siempre fue: una aplicación local de la disciplina de hacer que los estados imposibles no se puedan escribir. Y quien la ve así ya no necesita que le convenzan de usar el constructor, porque preguntarle por qué inyecta ahí equivale a preguntarle por qué prefiere que sus tipos no mientan.

⚔️ Cierra la ventana del estado inválido
  1. Busca un ViewModel de tu proyecto con un método inicializar o similar y enumera todos los estados inválidos que existen antes de esa llamada.
  2. Reescríbelo para que reciba el argumento de navegación por SavedStateHandle y no exponga ningún método de arranque.
  3. Localiza una dependencia obtenida dentro del cuerpo con un localizador o un objeto acompañante y muévela al constructor.
  4. Extrae una interfaz Reloj y sustituye todas las llamadas directas a la hora del sistema; escribe un test con una hora fija que antes era imposible.
  5. Argumenta por escrito qué le pasaría a tu suite si dos tests configuraran el mismo registro global con implementaciones distintas.