wandres.dev
NIVEL DIOS: SÍNTESIS · MVI en la práctica

MVI en el mapa mayor: Elm, Redux y TCA

MVI no se inventó en Android ni es una idea de Kotlin: es la enésima reaparición de un patrón que Elm formuló con una claridad que nadie ha mejorado, que Redux popularizó a costa de diluirlo y que TCA llevó al extremo de la composición y la inyección de dependencias como ciudadanos de primera clase. Esta lección recorre los tres linajes, traduce sus nombres a los del track para que el parentesco sea evidente, señala qué problema resolvió cada uno que los otros no habían visto, y extrae las cuatro lecciones transferibles que cambian cómo escribes Kotlin aunque no vayas a usar ninguno de ellos.

⏱ 21 min

Hay un momento en la formación de cualquier programador en el que descubre que la técnica que acaba de aprender con esfuerzo tiene primos en otros lenguajes, que algunos son mayores que ella y que uno de ellos la enunció mejor. Ese descubrimiento suele producir primero una punzada de decepción y después algo mucho más valioso: la capacidad de distinguir lo esencial de lo accidental. MVI en Kotlin comparte esqueleto con la arquitectura de Elm, con Redux y con TCA, y las diferencias entre ellos no son de gusto ni de sintaxis, sino de qué problema tenía delante cada comunidad cuando lo escribió. Recorrer ese mapa es la forma más rápida de entender qué partes de tu MVI son la idea y qué partes son el idioma en que la estás escribiendo.

🎯 Al terminar esta lección sabrás
  • Traducir el vocabulario de Elm, Redux y TCA al del track y reconocer el esqueleto común.
  • Explicar qué problema real resolvió cada linaje y qué precio pagó por resolverlo.
  • Identificar qué garantías desaparecen cuando el patrón se implementa en un lenguaje sin sus condiciones previas.
  • Extraer las cuatro lecciones transferibles que mejoran tu Kotlin aunque no adoptes ninguno.

Elm: el origen y la formulación limpia

La arquitectura de Elm no se diseñó: se descubrió. En un lenguaje puramente funcional, sin mutación ni efectos secundarios sueltos, las aplicaciones interactivas convergieron por necesidad a la misma forma, y esa forma resultó ser tres piezas y una firma.

El modelo es el estado completo de la aplicación. El mensaje es un tipo suma que enumera todo lo que puede ocurrir. Y la actualización es una función cuya firma, escrita informalmente, es Msg -> Model -> (Model, Cmd Msg): recibe un mensaje y un modelo, devuelve el modelo siguiente y una descripción de los efectos a ejecutar. La vista es Model -> Html Msg: una función del modelo a una descripción de interfaz que produce mensajes.

Si sustituyes los nombres, es exactamente el track. Modelo es estado, mensaje es intent, actualización es reduce, comando es efecto, vista es componible. La correspondencia es tan literal que resulta incómoda.

flowchart LR
M[Model o Estado] --> V[View o Composable]
V -->|Msg o Intent| U[Update o Reduce]
U --> M
U --> C[Cmd o Efecto]
C -->|Msg de vuelta| U
style U fill:#cba6f7,color:#11111b
style M fill:#a6e3a1,color:#11111b

Pero hay una diferencia que no es cosmética y que conviene entender bien, porque es la aportación central de Elm: el efecto no se ejecuta, se devuelve como valor. La función de actualización no llama a la red; devuelve una descripción de que hay que llamar a la red, y el runtime la ejecuta fuera. Eso significa que la actualización sigue siendo pura incluso cuando provoca trabajo, y que se puede comprobar qué efectos habría lanzado sin lanzar ninguno.

// La idea de Elm expresada en Kotlin: reducir devuelve estado Y comandos
data class Salida<S, C>(val estado: S, val comandos: List<C> = emptyList())

fun reducir(estado: PerfilState, intent: PerfilIntent): Salida<PerfilState, PerfilCmd> =
    when (intent) {
        is PerfilIntent.Reintentar -> Salida(
            estado = estado.copy(cargando = true, error = null),
            comandos = listOf(PerfilCmd.PedirPerfil(estado.id)),
        )
        is PerfilIntent.PerfilRecibido ->
            Salida(estado.copy(cargando = false, perfil = intent.perfil))
    }

Compáralo con lo que hace Orbit: allí el intent ejecuta el trabajo y el reduce solo transforma el estado. Ambos separan decisión de ejecución, pero Elm lo hace con un valor y Orbit con una separación de bloques. La versión de Elm es más comprobable —el efecto es un dato que se puede afirmar en un test— y más ceremoniosa. La de Orbit es más directa y deja el efecto fuera del alcance de la aserción pura.

ℹ️
La condición previa que Elm tenía y Kotlin no

Elm puede permitirse esta arquitectura sin disciplina porque el lenguaje hace imposible lo contrario: no hay mutación, no hay excepciones, no hay efectos secundarios fuera del runtime y el compilador exige exhaustividad en cada case. En Kotlin, en cambio, todas esas propiedades son elecciones tuyas que nadie impone: puedes mutar una lista dentro del estado, lanzar una excepción en el reductor o poner un else que oculte una rama olvidada. Por eso la misma arquitectura exige en Kotlin una vigilancia que en Elm es innecesaria: no estás copiando un patrón, estás reconstruyendo a mano las garantías que otro lenguaje daba gratis.

Redux: la popularización y la dilución

Redux llevó la idea al mundo de JavaScript en 2015 y le dio el vocabulario que hoy usa medio ecosistema: store, action, reducer, dispatch. Su aportación real no fue técnica sino de diseño de herramientas: las extensiones de depuración con viaje en el tiempo, la inspección de cada acción y la reproducción de sesiones demostraron ante una audiencia enorme algo que Elm sabía pero no había vendido, que un estado inmutable y transiciones puras convierten la depuración en una actividad distinta.

Su diferencia estructural con Elm es que Redux tiene una sola tienda global para toda la aplicación, no un modelo por componente. Eso resuelve de un plumazo la pregunta de dónde vive un dato compartido y crea a cambio dos problemas que ocuparon a la comunidad durante años: cómo se seleccionan trozos del árbol sin recalcular medio mundo, y dónde se ponen los efectos asíncronos, ya que el reductor de Redux —a diferencia del de Elm— no devuelve comandos.

Ese hueco explica la proliferación de middlewares para efectos, cada uno con una filosofía distinta, y explica también la queja recurrente sobre la ceremonia: tres archivos y un tipo de acción para incrementar un contador. La respuesta madura del ecosistema fue reducir la ceremonia con herramientas que generan el andamiaje, no abandonar el patrón.

Hay además una lección de Redux que se transfiere a Kotlin de forma directa y que casi nadie aprovecha: el middleware como punto de observación transversal. Si toda intención pasa por un embudo, ese embudo es el sitio natural para el registro, la telemetría, la medición de latencia y la captura de trazas para reproducir fallos, todo ello sin tocar ni una pantalla.

// El embudo unico permite instrumentar toda la app desde un solo punto
protected fun intentInstrumentado(
    nombre: String,
    bloque: suspend SimpleSyntax<S, E>.() -> Unit,
) = intent {
    val inicio = System.nanoTime()
    trazas.registrar(nombre, state)
    runCatching { bloque() }
        .onFailure { telemetria.fallo(nombre, it) }
    telemetria.latencia(nombre, System.nanoTime() - inicio)
}

Nótese lo que este envoltorio hace posible y que sería inviable con callbacks dispersos: una traza completa y ordenada de qué intenciones ocurrieron y con qué estado de partida, que es literalmente el material con el que se reproduce un fallo de producción. Ese dividendo no viene de Redux ni de Orbit, viene de haber aceptado que todas las entradas pasen por un solo sitio.

🔱

Una tienda o varias

Redux centraliza todo el estado; MVI en Android suele dar un container por pantalla. La centralización simplifica compartir y complica aislar; la distribución hace lo contrario.

🔱

El efecto sin sitio

Al no devolver comandos, Redux tuvo que inventar un lugar para lo asíncrono. Orbit lo resolvió dando al intent la capacidad de suspender; Elm, devolviendo el efecto como dato.

🔱

Selectores y memoización

Con un árbol único hace falta seleccionar y memoizar para no recalcular. En Compose ese trabajo lo hace la igualdad estructural del estado y la estabilidad de tipos.

🔱

Sin tipos suma reales

JavaScript no tiene tipos sellados, así que la exhaustividad de las acciones no la comprueba nadie. Buena parte de la ceremonia de Redux es un sustituto manual de lo que un sealed interface da gratis.

TCA: composición y dependencias

The Composable Architecture, del ecosistema Swift, es la versión que se tomó en serio las dos preguntas que Elm y Redux dejaron a medias: cómo se componen dos funcionalidades sin escribir pegamento a mano, y cómo se inyectan las dependencias sin renunciar a la comprobabilidad.

Su respuesta a la primera es que un reductor es un valor componible, no una función suelta: un reductor padre se construye a partir de reductores hijo mediante operadores que proyectan el estado y elevan las acciones, de modo que combinar dos funcionalidades es una expresión y no un when escrito a mano. En el track hiciste esto último a mano al partir un reductor grande en subreductores; TCA convierte ese patrón en una biblioteca.

Su respuesta a la segunda es un contenedor de dependencias explícito que forma parte del entorno de ejecución del reductor, con implementaciones distintas para producción, previsualización y pruebas. La consecuencia práctica es una comprobabilidad exhaustiva: un test de TCA falla si el estado cambia de una forma que la prueba no declaró o si queda un efecto sin terminar. No es que puedas comprobar la traza; es que estás obligado a describirla entera.

// El patron de dependencias explicitas, trasladado a Kotlin
data class PerfilEntorno(
    val repo: PerfilRepository,
    val reloj: () -> Instant,
    val io: CoroutineDispatcher,
)

class PerfilViewModel(
    private val env: PerfilEntorno,
) : ContainerHost<PerfilState, PerfilEfecto>, ViewModel() {
    override val container = container<PerfilState, PerfilEfecto>(PerfilState())
    fun cargar(id: String) = intent {
        val perfil = withContext(env.io) { env.repo.perfil(id) }
        reduce { state.copy(perfil = perfil, visto = env.reloj()) }
    }
}

Ese reloj inyectado no es un capricho: es la respuesta exacta al problema del reloj oculto que vimos al hablar de pureza. Una dependencia declarada es una dependencia sustituible, y una dependencia sustituible es una prueba determinista.

💡
La exhaustividad en las pruebas es una idea que puedes robar hoy

No necesitas TCA para adoptar su idea más útil: que un test afirme la secuencia completa de estados en vez de comprobar solo el último. Un test que solo mira el estado final no detecta que pasaste por un estado imposible por el camino, ni que emitiste dos veces el mismo estado, ni que olvidaste apagar el indicador de carga en una rama. La herramienta de pruebas de Orbit permite exactamente esa afirmación por traza, y adoptarla cuesta una tarde y cambia la calidad de la suite entera.

Las cuatro lecciones transferibles

Recorrido el mapa, lo que queda no son cuatro arquitecturas sino cuatro ideas que sobreviven a cualquiera de ellas y que puedes aplicar mañana en Kotlin sin adoptar nada.

La primera es que el efecto puede ser un valor. Aunque uses Orbit, nada te impide que la decisión de qué efecto lanzar la tome una función pura que devuelve un tipo sellado, y que el intent se limite a ejecutarlo. Ganas la posibilidad de comprobar en un test que cierta entrada habría producido cierta navegación, sin navegar.

La segunda es que la composición debe ser explícita. Un reductor de pantalla grande se parte en subreductores con su propio estado y su propio tipo de intención, y el padre solo enruta. Es lo que TCA automatiza y lo que tú puedes hacer a mano con disciplina.

La tercera es que las dependencias van declaradas. Reloj, aleatoriedad, dispatchers, identificadores generados: todo lo que introduce indeterminismo se inyecta o se transporta como dato dentro de la intención.

La cuarta es que la exhaustividad es una propiedad que hay que exigir. En los tipos, quitando el else del when. En las pruebas, afirmando trazas completas. En los efectos, enumerándolos en un tipo sellado que documenta todo lo que la pantalla puede provocarle al mundo.

Conviene cerrar el mapa señalando también lo que no conviene importar, porque una comparación honesta incluye los rechazos. La tienda global única de Redux resuelve un problema que en Android rara vez existe —el estado compartido por media aplicación— y trae a cambio un acoplamiento entre pantallas que no tenían por qué conocerse; el container por pantalla, con lo verdaderamente compartido en la capa de datos, es mejor arbitraje. La exhaustividad total de las pruebas de TCA es admirable en un ecosistema donde la herramienta la sostiene, y reconstruirla a mano en Kotlin produce suites que fallan a cada refactor menor por motivos que no son defectos. Y el estilo de un solo tipo de mensaje para toda la aplicación, común en implementaciones ingenuas de la idea de Elm, produce en Kotlin jerarquías selladas enormes que nadie puede leer de un vistazo. En los tres casos el rechazo se justifica igual: la idea era buena en su contexto y su contexto no es el tuyo.

Los patrones no se copian: se reconstruyen con las garantías del lenguaje de destino

Lo que este recorrido enseña no es historia de la programación, es una advertencia sobre cómo viajan las ideas. Cada vez que un patrón se traslada de un lenguaje a otro llega intacto en su forma y mutilado en sus garantías, y el desastre ocurre cuando alguien copia la forma creyendo que ha traído también las garantías. La arquitectura de Elm es segura en Elm porque el lenguaje prohíbe la mutación, obliga a la exhaustividad, no tiene excepciones y no permite un efecto fuera del runtime; ninguna de esas cuatro cosas es cierta en Kotlin. Cuando escribes MVI en Kotlin no estás usando la arquitectura de Elm, estás reconstruyéndola a mano con materiales que no la sostienen solos: la inmutabilidad la eliges tú con data class y colecciones persistentes, la exhaustividad la eliges tú borrando el else, la pureza la eliges tú sacando el reductor a un módulo sin Android, y la separación de efectos la eliges tú resistiendo la tentación de una llamadita dentro del reduce. Cada una de esas elecciones es reversible por cualquiera del equipo en cualquier momento, y ninguna la vigila el compilador salvo que tú construyas la barrera. De ahí sale el criterio que hace útil todo este mapa comparativo: ante un patrón que te resulta atractivo en otro ecosistema, la pregunta correcta nunca es cómo se escribe aquí, sino qué propiedad del lenguaje de origen lo hacía seguro y qué la sustituye en el mío. A veces la respuesta es una construcción del lenguaje, y entonces la traducción es limpia. A veces es una herramienta de análisis o una regla de compilación, y entonces la traducción cuesta trabajo pero es sólida. Y a veces la respuesta es que nada la sustituye salvo la disciplina humana, y entonces conviene saberlo antes de adoptar el patrón, porque estarás firmando un contrato cuya única garantía es que todo el mundo se acuerde de cumplirlo, incluida la persona que entre en el equipo dentro de dos años y no haya leído esta conversación.

⚔️ Traduce entre linajes
  1. Reescribe el reductor de una pantalla tuya con la firma de Elm: que devuelva estado y lista de comandos, y que el intent se limite a ejecutar esa lista.
  2. Escribe un test que afirme qué comandos habría devuelto esa función para tres entradas distintas, sin ejecutar ninguno.
  3. Enumera todas las dependencias implícitas de una de tus pantallas —reloj, azar, dispatcher, identificadores— y conviértelas en un entorno inyectado.
  4. Convierte un test tuyo que solo comprueba el estado final en uno que afirme la traza completa, y anota qué defecto salió a la luz.
  5. Elige una garantía que Elm da gratis y que tu código no tiene, y construye la barrera estructural que la sustituya en Kotlin.