wandres.dev
ORBIT: EL CONTAINER · la API a fondo

ContainerHost y container: la anatomía mínima

Orbit no impone una clase base ni obliga a heredar de nada: pide implementar una interfaz de una sola propiedad y construir con una fábrica el objeto que esa propiedad devuelve. Esta lección diseca esa anatomía mínima con precisión quirúrgica: qué contrato expresa ContainerHost y por qué sus dos parámetros de tipo bastan para describir una pantalla entera, qué construye realmente la fábrica container y sobre qué scope la ata, por qué el estado inicial es obligatorio y no admite ausencia, cómo la inicialización perezosa retrasa el nacimiento del container hasta que alguien lo observa, y qué papel juega onCreate como único lugar idiomático para el arranque. Al final, un archivo de siete líneas deja de ser ceremonia y se convierte en una declaración densa de garantías.

⏱ 18 min

Hay librerías que se apoderan de tu jerarquía de clases y hay librerías que se conforman con una propiedad. Orbit pertenece al segundo grupo, y esa modestia es deliberada: toda su maquinaria —la cola de intents, el aislamiento del reductor, los dos flujos de salida, la persistencia opcional del estado— se cuelga de un único miembro que tu clase debe exponer. La consecuencia práctica es que la anatomía mínima de una pantalla en Orbit cabe en siete líneas; la consecuencia profunda es que esas siete líneas no son ruido de configuración sino una declaración formal de qué existe, de qué tipo es, cuánto vive y quién lo mira. Leerlas como ceremonia es perder la mitad del diseño. Esta lección las lee como lo que son: un contrato.

🎯 Al terminar esta lección sabrás
  • Distinguir la interfaz ContainerHost del objeto Container que la fábrica construye, y saber qué aporta cada pieza.
  • Declarar el estado inicial con las garantías de tipo que Orbit exige y explicar por qué no admite ausencia.
  • Describir la inicialización perezosa del container y el papel de onCreate como único punto de arranque idiomático.
  • Reconocer la anatomía mínima de una clase de lógica y las deformaciones habituales que la corrompen.

Una interfaz, una fábrica, un objeto

Tres nombres circulan alrededor de la misma idea y conviene separarlos antes de tocar una sola línea de código. ContainerHost es una interfaz: el contrato que tu clase firma para declararse anfitriona de una unidad de lógica. Container es el tipo del objeto que custodia el estado y expone los flujos. Y container en minúscula es la fábrica: una función que construye una instancia del segundo para satisfacer el primero.

interface ContainerHost<STATE : Any, SIDE_EFFECT : Any> {
    val container: Container<STATE, SIDE_EFFECT>
}

Eso es literalmente todo el contrato. Una propiedad. No hay métodos abstractos que implementar, no hay una clase base de la que heredar, no hay anotaciones de procesamiento. Los dos parámetros de tipo hacen el trabajo pesado: STATE describe todo lo que la pantalla retiene y SIDE_EFFECT todo lo que la pantalla dispara. Ambos están acotados a Any, lo que significa que ninguno admite nulo. La restricción parece menor y no lo es: prohíbe modelar el estado como una referencia ausente, que es precisamente la forma más común de eludir la obligación de pensar qué muestra la pantalla antes de que llegue el primer dato.

Que el contrato sea una interfaz y no una clase base tiene una consecuencia arquitectónica directa: tu clase de lógica sigue siendo libre de heredar de lo que quiera. En Android hereda de ViewModel porque quieres su viewModelScope y su supervivencia a los cambios de configuración, pero en un módulo compartido multiplataforma puede ser una clase corriente, y en un test puede ser una clase corriente también. Orbit se acopla a una propiedad, no a un árbol de herencia, y por eso la misma lógica puede vivir en contextos que no comparten nada.

class PerfilViewModel(
    private val repo: PerfilRepo,
) : ContainerHost<PerfilState, PerfilEffect>, ViewModel() {

    override val container = container<PerfilState, PerfilEffect>(
        initialState = PerfilState(),
    )
}

Siete líneas, y ya existe una máquina completa. La fábrica container que aparece aquí es la de orbit-viewmodel, una función de extensión sobre ViewModel que toma el viewModelScope de la clase anfitriona y lo usa como ámbito raíz. No estás pasando un scope porque la fábrica ya sabe dónde encontrarlo, y de ahí sale una garantía que rara vez se enuncia: no existe ninguna corrutina de esta lógica que pueda sobrevivir a la destrucción del ViewModel, porque todas descienden de ese ámbito.

El estado inicial no es opcional

initialState es el único parámetro obligatorio de la fábrica, y esa obligatoriedad es una decisión de diseño con consecuencias que se sienten meses después. Significa que el container nunca está vacío: desde el instante en que existe, stateFlow tiene un valor legible. No hay ventana temporal en la que la interfaz pueda preguntar por el estado y recibir un hueco.

Compáralo con la alternativa que otras arquitecturas permiten, donde el estado se declara nulo y se rellena cuando llega la primera respuesta. Esa forma traslada a cada punto de lectura la obligación de contemplar la ausencia, y con ella aparecen las comprobaciones defensivas que ensucian la vista y los ceros que se muestran medio segundo antes del dato real. Orbit cierra esa puerta con el sistema de tipos: si STATE está acotado a Any y la fábrica exige un valor, el estado de carga tiene que modelarse dentro del estado, con un campo explícito, y no como su negación.

data class PerfilState(
    val cargando: Boolean = true,
    val nombre: String = "",
    val insignias: List<Insignia> = emptyList(),
    val error: MensajeError? = null,
)

Obsérvese que la nulabilidad no ha desaparecido del todo: error sigue siendo un tipo anulable, y hace bien, porque ahí la ausencia significa algo preciso y comprobable —no hay error—. La diferencia con el antipatrón es de nivel: lo que Orbit prohíbe es que el estado entero sea la referencia ausente, no que un campo concreto modele una ausencia legítima. Anular el todo obliga a todos los lectores a defenderse; anular una parte documenta una alternativa del dominio.

💡
El estado inicial es la respuesta a una pregunta concreta

No lo escribas como un data class vacío por inercia. Pregúntate qué debe ver el usuario en el primer fotograma, antes de que ninguna corrutina haya devuelto nada, y codifica exactamente eso. Casi siempre la respuesta es un esqueleto de carga, y casi siempre eso implica un campo booleano o un tipo sellado con una rama inicial. Un estado inicial bien pensado elimina de golpe la mitad de los parpadeos visuales de una pantalla.

Los valores por defecto de Kotlin hacen que la declaración se lea bien: PerfilState() construye la instancia con cargando = true y las listas vacías si así se declararon en la data class. Esa aparente trivialidad concentra una decisión de producto, y por eso conviene que el estado inicial no sea un accidente de constructores por defecto sino una elección revisada.

Cuándo nace el container: la pereza deliberada

La fábrica no construye nada en el momento de la asignación. El container se materializa de forma perezosa la primera vez que alguien lo toca —cuando se colecciona uno de sus flujos o cuando se lanza el primer intent—, y ese detalle explica el segundo parámetro que más importa.

override val container = container<PerfilState, PerfilEffect>(
    initialState = PerfilState(),
    onCreate = { cargarPerfil() },
)

El bloque onCreate se ejecuta exactamente una vez, en el primer contacto con el container, y dentro de su propio intent. Es el sitio idiomático para la carga inicial de datos, y su existencia resuelve un problema real: si escribieras esa carga en el bloque init de la clase, arrancaría al construirse el ViewModel aunque nadie fuera a observarlo todavía, y peor aún, cualquier efecto secundario emitido durante esa carga podría producirse antes de que existiera el container que debe transportarlo.

flowchart TD
A[Se construye el ViewModel] --> B[La propiedad container queda declarada pero no materializada]
B --> C[La vista colecciona stateFlow o se lanza el primer intent]
C --> D[Se construye el Container sobre el scope raiz]
D --> E[Se ejecuta onCreate una sola vez dentro de un intent]
E --> F[El estado inicial se sustituye por el primer estado real]

La pereza tiene además un efecto colateral valioso en pruebas: puedes instanciar la clase de lógica sin que se dispare ninguna carga, inspeccionar su estado inicial y decidir tú cuándo empieza el trabajo. Una clase que arranca solicitudes en su constructor es una clase que no se puede probar sin dobles para todo; una clase que arranca en onCreate se puede examinar en reposo.

Hay un matiz sobre la frecuencia que conviene fijar porque genera dudas legítimas. El bloque se ejecuta una vez por vida del container, no una vez por suscripción. Si la pantalla gira y la vista se destruye y se recrea, el ViewModel sobrevive, el container sobrevive y onCreate no se vuelve a ejecutar: la nueva vista se suscribe y recibe el estado que ya existía. Si en cambio el usuario abandona la pantalla y vuelve a entrar, hay un ViewModel nuevo, un container nuevo y por tanto un arranque nuevo. Esa correspondencia entre la vida del contenedor y la vida de la carga inicial es justo la que uno querría, y no hay que escribir ninguna bandera para conseguirla.

📝
onCreate no es un constructor con otro nombre

Su cuerpo se ejecuta dentro de un intent, con todo lo que eso implica: es suspendible, puede mutar el estado, puede emitir efectos y desciende del ámbito raíz. Esa diferencia lo hace apto para trabajo real, mientras que un constructor solo puede hacer trabajo síncrono y no tiene forma legítima de esperar nada. Cuando alguien escribe una carga en el bloque init y la envuelve en un lanzamiento de corrutina manual, está reimplementando peor lo que onCreate ya ofrece.

La anatomía mínima y sus deformaciones

La forma canónica tiene cuatro rasgos: la clase implementa ContainerHost con dos tipos concretos, expone container como propiedad de solo lectura, recibe sus dependencias por constructor y no declara ninguna otra propiedad mutable. El cuarto rasgo es el que más se incumple y el que más daño hace.

🛰️

Estado paralelo

Una propiedad mutable en la clase —un contador, una bandera, una lista acumuladora— crea un segundo estado que la interfaz no observa y que ninguna herramienta de depuración registra. Todo lo que la pantalla necesita saber pertenece a STATE.

🧩

Container público de más

Exponer el container como parte de la API que consume la vista invita a leerlo por caminos que saltan la cola de intents. La vista debe hablar con métodos con nombre, nunca con el container directamente.

🪝

Carga en el bloque init

Arrancar el trabajo en init en vez de en onCreate rompe la pereza, complica las pruebas y expone a carreras con la suscripción de la vista. Si algo debe pasar al principio, su sitio es onCreate.

Hay una deformación más sutil y más frecuente en equipos que vienen de otras arquitecturas: declarar varios container en la misma clase para separar responsabilidades. La intención es buena y el resultado es malo, porque cada container tiene su propia cola y su propio estado, y coordinar dos colas independientes reintroduce exactamente la clase de carrera que la cola única elimina. Si una pantalla tiene demasiadas responsabilidades, la respuesta es dividir la pantalla o componer estados, no multiplicar contenedores.

class BusquedaViewModel(
    private val repo: BusquedaRepo,
    private val historial: HistorialRepo,
) : ContainerHost<BusquedaState, BusquedaEffect>, ViewModel() {

    override val container = container<BusquedaState, BusquedaEffect>(
        initialState = BusquedaState(),
        onCreate = { precargarHistorial() },
    )

    fun buscar(termino: String) = intent { /* ... */ }
    fun limpiar() = intent { /* ... */ }
    private fun precargarHistorial() = intent { /* ... */ }
}

Esta forma canónica tiene una cualidad que se aprecia sobre todo en revisión de código ajeno: es legible en diagonal. Las dependencias están arriba, en el constructor, y se ven de un vistazo; el estado inicial y el arranque están en la propiedad, juntos; y la superficie pública es una lista de métodos con nombre de acción, todos con la misma forma. Un revisor puede decidir en diez segundos si la clase respeta la arquitectura, y esa velocidad de auditoría es exactamente lo que se pierde cuando cada archivo inventa su propia estructura.

La última deformación merece mención porque se disfraza de buena práctica: exponer propiedades derivadas del estado como flujos independientes, con la idea de que la vista observe solo lo que necesita. En un sistema con un flujo conflado y una vista que sabe saltarse recomposiciones innecesarias, esa optimización rara vez paga lo que cuesta, y a cambio multiplica los caminos por los que la información sale del container. Una salida es auditable; cinco salidas son un grafo.

Una propiedad como frontera de todo un modelo de ejecución

Lo notable de esta anatomía no es su brevedad sino lo que la brevedad esconde. Al declarar una única propiedad de tipo Container estás firmando, de una sola vez, un conjunto de compromisos que en otras arquitecturas se distribuyen por decenas de decisiones sueltas y no escritas: que existe exactamente un estado y no varios, que ese estado tiene un valor desde el primer instante y por tanto ningún lector debe contemplar su ausencia, que todo el trabajo asíncrono de esta unidad desciende de un mismo ámbito y muere con él, que las mutaciones se aplicarán en un orden total y no concurrente, y que hay dos canales de salida con semánticas distintas y tipadas. Ninguno de esos compromisos está expresado como una regla que alguien deba recordar; todos están cableados en la estructura del objeto que la fábrica devuelve. Esa es la diferencia entre una convención y una arquitectura: la convención vive en la cabeza del equipo y se degrada con cada incorporación, mientras que la estructura vive en el tipo y se degrada solo si alguien la desmonta a propósito. Por eso la crítica habitual de que Orbit es poca cosa —una interfaz y una fábrica, apenas trescientas líneas de librería— confunde superficie con alcance. La librería es pequeña porque su trabajo no es darte funciones sino cerrar puertas, y cerrar puertas cuesta muy poco código cuando el sistema de tipos hace de portero. Cuando entiendas que override val container no es una línea de configuración sino la firma de ese contrato, habrás dejado de usar Orbit y habrás empezado a leerlo.

⚔️ Reconstruye la anatomía desde cero
  1. Escribe una clase de lógica para una pantalla de búsqueda que implemente ContainerHost con sus dos tipos concretos, sin copiar ningún ejemplo previo.
  2. Justifica cada campo de tu estado inicial respondiendo a qué debe ver el usuario en el primer fotograma, y elimina los campos que no superen esa pregunta.
  3. Mueve una carga inicial que hoy esté en un bloque init hacia onCreate y describe qué carrera concreta desaparece al hacerlo.
  4. Localiza en tu código alguna propiedad mutable declarada fuera del estado y argumenta qué información deja de ser observable por culpa de ella.
  5. Explica por qué la cota Any en los parámetros de tipo prohíbe modelar la carga como ausencia de estado, y qué campo la sustituye.