Modelar los efectos: tipo sellado y canal de entrega única
La solución al evento fosilizado tiene dos mitades y ambas son necesarias: un tipo sellado que enumere de forma exhaustiva los efectos posibles y un canal que garantice entrega única al emitirlos. Esta lección diseca las dos piezas con precisión: por qué la jerarquía sellada convierte el when exhaustivo en una red de seguridad del compilador, qué garantiza y qué no garantiza un Channel frente a un SharedFlow, cómo elegir capacidad y política de desbordamiento, y por qué el vocabulario de efectos debe hablar el idioma del dominio y no el de la plataforma.
Diagnosticado el bug, la cura tiene una forma muy concreta y dos mitades que se sostienen mutuamente. La primera es un tipo: una jerarquía sellada que enumera, de forma cerrada y verificable por el compilador, todos los efectos que una pantalla puede producir. La segunda es un transporte: un canal cuya semántica nativa es la entrega única, de modo que cada efecto llegue exactamente a un consumidor y desaparezca. Ninguna de las dos basta por separado. Un tipo sellado emitido por un flujo conflado sigue produciendo fantasmas; un canal que transporta cadenas sueltas pierde la exhaustividad y con ella la garantía de que no olvidarás manejar un caso. Juntas convierten una convención frágil, que dependía de la disciplina de quien escribe, en una propiedad estructural que el compilador y el runtime sostienen por ti.
- Diseñar una jerarquía sellada de efectos con vocabulario de dominio y no de plataforma.
- Explicar qué garantiza exactamente un
Channely en qué se diferencia de unSharedFlow. - Elegir capacidad y política de desbordamiento del canal según el tipo de efecto transportado.
- Justificar por qué el
whenexhaustivo sobre el tipo sellado es una red de seguridad y no una formalidad.
El tipo sellado como vocabulario cerrado
Un efecto sin tipo es una cadena de texto o un entero, y con ellos se pierde lo único que hace confiable a esta arquitectura: la posibilidad de que el compilador verifique que la interfaz maneja todos los casos. Una jerarquía sellada es un vocabulario cerrado: el conjunto de subtipos se conoce en tiempo de compilación, y de ahí sale la exhaustividad.
sealed interface CarritoEfecto {
data class IrAlPago(val carritoId: String) : CarritoEfecto
data class AvisarStockAgotado(val producto: String) : CarritoEfecto
data object ConfirmarBorrado : CarritoEfecto
data object PedirFocoEnCupon : CarritoEfecto
}
Fíjate en el nivel de abstracción de los nombres. No dice MostrarSnackbar, LlamarNavController ni AbrirImeSoftKeyboard: dice qué quiere el dominio que ocurra, no cómo lo hace la plataforma. Esa elección tiene consecuencias medibles. La lógica queda libre de dependencias de Android y por tanto es probable con un test de JVM puro. La misma lógica puede alimentar una app de escritorio o de iOS que traduzca AvisarStockAgotado a otra cosa. Y cuando el diseño cambie de un snackbar a un diálogo, no habrá que tocar ni una línea de la lógica, porque la lógica nunca supo qué era un snackbar.
Que el vocabulario sea cerrado tiene además una consecuencia sobre la evolución del código que no se aprecia hasta el segundo año de un proyecto: el conjunto de efectos de una pantalla se convierte en una superficie revisable. Cuando alguien quiere añadir una capacidad nueva —abrir una cámara, lanzar un pago, pedir un permiso— tiene que ampliar explícitamente ese tipo, y esa ampliación es visible en la revisión de código. Con cadenas de texto o llamadas directas, la misma capacidad entra sin dejar huella en ningún sitio central.
Los efectos también deben ser inmutables y autocontenidos: cada uno lleva consigo todo lo que la interfaz necesita para ejecutarlo. Si IrAlPago no llevara el identificador y la interfaz tuviera que ir a leerlo del estado en el momento de consumirlo, habrías reintroducido una carrera —el estado puede haber cambiado entre la emisión y el consumo— y con ella una fuente de fallos difíciles de reproducir.
La tentación de tener un único efecto genérico con un campo de texto libre es fuerte y hay que resistirla: colapsa todas las intenciones en una y devuelve la decisión de qué significa cada mensaje al consumidor, que es justo lo que el tipo sellado venía a evitar. La regla práctica es un caso por intención del dominio. Si dos efectos siempre se emiten juntos, probablemente eran uno solo; si uno se emite con cinco variantes de comportamiento según un campo interno, probablemente eran cinco.
El canal y su garantía
La segunda mitad es el transporte. Un Channel de corrutinas implementa una cola con semántica de encuentro entre productor y consumidor: cada elemento enviado se entrega a exactamente un receptor y, una vez recibido, deja de existir. No hay último valor, no hay reemisión, no hay historial. Suscribirse tarde no da acceso a lo ya consumido; suscribirse dos veces no duplica los elementos sino que los reparte.
flowchart LR L[Logica emite un efecto] --> C[Channel con bufer] C -->|entrega exactamente una vez| U[Interfaz consume y ejecuta] C -.->|si no hay consumidor| B[Espera en el bufer sin perderse] S[StateFlow conflado] -.->|reemite a cada suscriptor| U style C fill:#f9e2af,color:#11111b style S fill:#a6e3a1,color:#11111b
Conviene ser exacto sobre lo que el canal garantiza y lo que no. Garantiza que ningún efecto se entregará dos veces al mismo consumidor y que el orden de emisión se preserva. Garantiza que un efecto emitido antes de que exista consumidor esperará en el búfer en vez de perderse en el vacío. No garantiza, en cambio, entrega a múltiples consumidores: si dos pantallas colecciona el mismo canal, cada efecto irá a una sola de ellas, de forma no determinista. Y no garantiza supervivencia a la muerte del proceso: lo que está en el búfer vive en memoria.
private val _efectos = Channel<CarritoEfecto>(capacity = Channel.BUFFERED)
val efectos: Flow<CarritoEfecto> = _efectos.receiveAsFlow()
fun borrarLinea(id: String) = viewModelScope.launch {
repo.borrar(id)
_efectos.send(CarritoEfecto.ConfirmarBorrado)
}
La exposición como Flow mediante receiveAsFlow no es cosmética: impide que la interfaz llame a métodos de envío o cierre el canal, y deja el extremo de escritura confinado en la lógica. El flujo unidireccional se mantiene también aquí.
Esa limitación con varios consumidores no es un defecto que haya que ocultar sino una consecuencia directa de la garantía elegida: entregar a uno solo y entregar a todos son propiedades mutuamente excluyentes, y el canal optó por la primera porque es la que evita ejecutar dos veces una operación no idempotente. Un efecto que sí debe llegar a varios receptores probablemente no era un efecto de pantalla sino una notificación de aplicación, y su sitio natural es otro flujo, difusor y de ámbito superior.
La elección de capacidad merece pensarse, porque decide qué ocurre en el peor caso y el peor caso siempre acaba llegando.
Sin búfer
El emisor se suspende hasta que alguien reciba. Máxima garantía de no perder nada, a costa de poder bloquear una corrutina de lógica mientras la pantalla está oculta.
Con búfer
Absorbe la ventana entre emitir y empezar a coleccionar sin suspender a nadie. Es la elección por defecto sensata y la que usan las librerías del ecosistema.
Conflado
Solo sobrevive el último elemento. Defendible cuando los efectos se sustituyen entre sí, peligroso cuando cada uno importa por separado.
La regla práctica es elegir búfer por defecto y desviarse solo con un motivo escrito. Descartar el más antiguo es casi siempre la peor opción para efectos, porque pierde justo el evento que llevaba más tiempo esperando, que suele ser el más importante de la secuencia.
Un MutableSharedFlow con replay a cero parece equivalente y no lo es en el caso que más importa. Su emisión no espera a que exista un coleccionista: si la lógica emite un efecto durante la inicialización, antes de que la interfaz se suscriba, ese efecto se pierde sin rastro. Además difunde a todos los suscriptores, de modo que dos pantallas activas ejecutarían la misma navegación dos veces. El Channel fue diseñado justo para la propiedad contraria en ambos ejes: retiene hasta que haya alguien y entrega a uno solo. La difusión es la semántica correcta para notificaciones globales, no para órdenes de una pantalla.
La exhaustividad como red de seguridad
La tercera pieza aparece al consumir. Un when sobre un tipo sellado usado como expresión obliga al compilador a exigir que todas las ramas estén cubiertas, y ese detalle aparentemente burocrático es lo que hace que la arquitectura escale.
private fun manejar(efecto: CarritoEfecto): Unit = when (efecto) {
is CarritoEfecto.IrAlPago -> nav.navigate("pago/${efecto.carritoId}")
is CarritoEfecto.AvisarStockAgotado -> snackbar.mostrar(efecto.producto)
CarritoEfecto.ConfirmarBorrado -> haptica.golpeCorto()
CarritoEfecto.PedirFocoEnCupon -> foco.pedirFoco()
}
Añade mañana un quinto caso al tipo sellado y este when dejará de compilar en cada sitio donde se consuma. Eso no es una molestia: es la propiedad que garantiza que un efecto nuevo no puede quedar sin manejar en una pantalla olvidada. Compáralo con la alternativa de las cadenas de texto, donde añadir un caso nuevo compila perfectamente y falla en silencio en tiempo de ejecución, o peor, ejecuta la rama por defecto y no hace nada.
Por eso conviene evitar la rama comodín al consumir efectos, aunque el editor la sugiera. Una rama por defecto convierte una comprobación de compilación en un silencio en tiempo de ejecución, que es exactamente el intercambio contrario al que buscábamos. Si un efecto no interesa en una pantalla concreta, escríbelo con un cuerpo vacío y un comentario: el coste de una línea compra la certeza de que alguien revisó el caso.
Orbit y el efecto emitido antes de tiempo
Cuando se usa Orbit, las dos mitades vienen montadas: la fábrica del contenedor construye el canal, postSideEffect escribe en él y el contenedor lo publica como flujo hacia la interfaz. La firma del contenedor obliga además a declarar el tipo sellado desde el primer día.
class CarritoViewModel(
private val repo: CarritoRepo,
) : ContainerHost<CarritoState, CarritoEfecto>, ViewModel() {
override val container = container<CarritoState, CarritoEfecto>(
initialState = CarritoState(),
) {
cargarInicial()
}
private fun cargarInicial() = intent {
val lineas = repo.lineas()
reduce { state.copy(lineas = lineas) }
if (lineas.any { it.sinStock }) {
postSideEffect(CarritoEfecto.AvisarStockAgotado(lineas.first { it.sinStock }.nombre))
}
}
}
Este ejemplo contiene la carrera más citada contra los canales y también su respuesta. El bloque de creación se ejecuta la primera vez que alguien observa el contenedor, y puede emitir un efecto antes de que la interfaz haya empezado a coleccionar el flujo de efectos. Con un flujo difusor sin repetición, ese aviso se perdería para siempre. Con un canal con búfer, espera educadamente hasta que aparece el primer consumidor y se entrega entonces, una sola vez. La capacidad del búfer es configurable en los ajustes del contenedor, y su valor por defecto está pensado justo para absorber esta ventana.
Hay un límite honesto que conviene conocer: el búfer no es infinito ni persistente. Si la lógica emite más efectos de los que caben mientras nadie escucha, la política de desbordamiento decide qué se pierde o si el emisor se suspende. Y si el proceso muere con el búfer lleno, ese contenido desaparece. Ambas cosas son consecuencias aceptables para efectos efímeros y son exactamente el argumento que el bando contrario usará en la última lección de este nivel.
Una ventaja poco anunciada de modelar los efectos como valores es lo que ocurre en los tests: la herramienta de pruebas de Orbit permite afirmar sobre la secuencia exacta de efectos emitidos y compararla con la esperada, igual que se afirma sobre la secuencia de estados. Un efecto que fuera una llamada directa a la plataforma sería imposible de comprobar sin simular medio Android; un efecto que es un objeto inmutable en una lista se comprueba con una igualdad. Que el diseño elegido por corrección resulte además el más fácil de probar no es casualidad: la testabilidad suele ser el síntoma visible de una buena separación de responsabilidades.
Lo verdaderamente notable de esta combinación es lo que hace con la responsabilidad. Sin ella, la corrección del sistema descansa en la memoria de quien escribe el código: acuérdate de borrar la bandera, acuérdate de no dejar el evento en el estado, acuérdate de manejar el caso nuevo en las tres pantallas que lo usan. Toda arquitectura que descansa en el acuérdate está condenada a fallar en cuanto el equipo crece o pasan seis meses, porque la memoria no escala y la disciplina tampoco. Lo que hacen el tipo sellado y el canal, juntos, es mover esas obligaciones de la cabeza del programador al sistema de tipos y al runtime: la exhaustividad deja de ser un cuidado y pasa a ser un error de compilación; la entrega única deja de ser una promesa y pasa a ser el contrato de una estructura de datos que fue construida para eso. Ahí está la diferencia entre una convención —algo que el equipo acuerda respetar— y un protocolo —algo que el sistema no te deja violar—, y esa diferencia es prácticamente la definición de arquitectura seria. Hay un segundo efecto, más sutil y más duradero: al obligarte a nombrar cada efecto como una intención del dominio, el tipo sellado convierte la lista de efectos en documentación ejecutable de lo que esa pantalla puede provocarle al mundo. Un compañero que lea CarritoEfecto sabe en cinco segundos, sin leer una línea de lógica, todo el poder que esa pantalla tiene sobre la navegación, el hardware y la atención del usuario. Un tipo cerrado bien nombrado no es solo una salvaguarda contra el olvido: es el resumen más honesto que existe de lo que tu código es capaz de hacer.
- Escribe la
sealed interfacede efectos de una pantalla real usando exclusivamente vocabulario de dominio. Prohíbete mencionar cualquier clase de Android en los nombres. - Comprueba que cada efecto es autocontenido: si alguno obliga a la interfaz a leer el estado al consumirlo, corrígelo y explica qué carrera has eliminado.
- Justifica la capacidad y la política de desbordamiento que eliges para tu canal, y describe qué se pierde con cada alternativa.
- Argumenta con un caso concreto por qué un
SharedFlowsin replay no sirve aquí, usando el escenario del efecto emitido durante la inicialización. - Añade un caso nuevo a tu tipo sellado y observa dónde deja de compilar el proyecto. Explica por qué esa rotura es una funcionalidad y no un inconveniente.