wandres.dev
MVI · Model-View-Intent

Intent: modelar cada acción del usuario como un dato

El Intent es la entrada del ciclo de MVI: cada cosa que el usuario puede hacer se modela como un valor explicito de un tipo sellado, no como una llamada a un metodo con logica dentro. Esta leccion muestra como se expresa en Kotlin con sealed interface, data class y data object, por que el when exhaustivo convierte al compilador en tu aliado, y como un unico punto de entrada onIntent concentra toda la interaccion. Aclara el choque de nombres con android.content.Intent, del que este patron no tiene nada, y defiende que cosificar la intencion como un tipo suma finito es lo que hace enumerable, registrable y comprobable el conjunto de lo que el usuario puede provocar.

⏱ 17 min

Si el flujo unidireccional es la columna vertebral de MVI, el Intent es la puerta por la que todo entra. La palabra desconcierta al principio porque en Android ya existe android.content.Intent, el mensajero que arranca actividades y servicios; el Intent de este patrón no tiene nada que ver con aquel. Aquí un intent es la intención del usuario capturada como un dato: no la acción de guardar, sino el hecho “el usuario pulsó guardar”; no el efecto de escribir, sino el hecho “el usuario cambió el texto a esto”. Modelar cada interacción posible como un valor de un tipo cerrado es el primer acto de disciplina que exige MVI, y de él se siguen casi todas sus virtudes: el registro, la reproducción, la comprobación y, sobre todo, la certeza de que no hay ninguna acción del usuario que el sistema no haya previsto por escrito.

🎯 Al terminar esta lección sabrás
  • Modelar el conjunto de acciones del usuario como un sealed interface de intents.
  • Distinguir data object para intents sin datos y data class para intents con carga.
  • Usar el when exhaustivo para que el compilador exija cubrir cada intención posible.
  • Concentrar toda la interacción en un único punto de entrada onIntent y saber por qué.

La intención como tipo suma

En Kotlin, el conjunto de todo lo que el usuario puede hacer en una pantalla se expresa con un sealed interface: un tipo cuyas únicas implementaciones son las que declaras en el mismo módulo. Cada acción es una de esas implementaciones. Cuando la acción no lleva datos —un botón de reintentar— basta un data object, un valor único sin estado. Cuando lleva información —el texto que se escribió, el identificador de la fila que se tocó— usas una data class que la transporta.

// OJO: no es android.content.Intent. Aqui Intent es dato de dominio.
sealed interface TareasIntent {
    data class TextoCambiado(val texto: String) : TareasIntent
    data object AnadirPulsado : TareasIntent
    data class TareaAlternada(val id: String) : TareasIntent
    data class TareaBorrada(val id: String) : TareasIntent
    data object ReintentarPulsado : TareasIntent
}

Esta declaración es, leída con atención, una especificación completa de la superficie de interacción de la pantalla: cinco cosas, ni una más, puede hacer el usuario. No hay acción escondida en un callback perdido ni intención implícita en un flag. La lista es la verdad, y está en un solo sitio.

⚠️
El choque de nombres con android.content.Intent

El nombre es una trampa histórica. android.content.Intent es la clase del sistema para lanzar componentes; el Intent de MVI es un tipo tuyo que representa una intención de la interfaz. No comparten nada. Por eso muchos equipos, para ahorrarse la ambigüedad en cada revisión de código, renombran el concepto de MVI a UiEvent, UiAction o simplemente Action —así lo llaman Circuit y buena parte de la guía oficial de 2026—. El patrón es idéntico; solo cambia la etiqueta. Elige un nombre y sé consistente: la claridad del equipo vale más que la fidelidad al término original.

El when exhaustivo: el compilador como aliado

Aquí se cobra el primer dividendo de modelar la intención como tipo suma. Como el sealed interface conoce todas sus implementaciones, un when sobre un intent puede ser exhaustivo: si te dejas un caso sin cubrir y no pones rama else, el código no compila. El conjunto de acciones del usuario deja de ser una lista mental que se te puede olvidar y pasa a ser una obligación que la máquina verifica en cada build.

fun onIntent(intent: TareasIntent) {
    when (intent) {
        is TareasIntent.TextoCambiado  -> _state.update { it.copy(borrador = intent.texto) }
        TareasIntent.AnadirPulsado     -> anadirTarea()
        is TareasIntent.TareaAlternada -> alternar(intent.id)
        is TareasIntent.TareaBorrada   -> borrar(intent.id)
        TareasIntent.ReintentarPulsado -> cargar()
        // sin rama else: si anades un intent y olvidas su caso, no compila
    }
}
flowchart LR
A[TextoCambiado] --> OI[onIntent]
B[AnadirPulsado] --> OI
C[TareaAlternada] --> OI
D[TareaBorrada] --> OI
E[ReintentarPulsado] --> OI
OI --> VM[el ViewModel decide que hacer con cada uno]
style OI fill:#f9e2af,color:#11111b
style VM fill:#a6e3a1,color:#11111b

El día que añadas un sexto intent —OrdenCambiado, digamos—, el compilador recorrerá cada when que consume TareasIntent y te obligará a decidir qué hacer con él. Esa es la diferencia entre una interfaz que crece con seguridad y una que acumula agujeros: en MVI, ampliar el conjunto de acciones es una operación que el tipo hace visible y verificable, no un olvido latente.

Un único punto de entrada

Todos los intents entran por la misma puerta: fun onIntent(intent: TareasIntent). Esta unicidad no es estética; es la que hace posible todo lo que el patrón promete. Como cada acción del usuario pasa por el mismo canal, basta un Log en onIntent para obtener la crónica exacta y ordenada de todo lo que se hizo en la sesión —la caja negra del nivel 17, ahora en Kotlin—. Como los intents son datos inertes y no llamadas, puedes grabarlos, serializarlos, reenviarlos en un test o reproducirlos para recrear un bug que un usuario reportó.

Cosificar la intención convierte lo posible en enumerable, y lo enumerable en verificable

La jugada profunda del intent no es sintáctica sino epistemológica: cambia lo que se puede saber sobre tu programa. Mientras las acciones del usuario viven como métodos dispersos —onGuardarClick, onTextChanged, onRetry—, el conjunto de lo que el usuario puede hacer es abierto e incognoscible: nadie puede afirmar que los ha visto todos, porque no hay ningún sitio donde estén todos. Al modelarlos como un tipo suma sellado, ese conjunto se vuelve finito, cerrado y escrito en un único lugar; deja de ser una intuición para convertirse en un valor de tipo Intent que el compilador conoce por completo. Y de esa clausura brotan, en cadena, todas las propiedades que asociamos a los sistemas razonables. La verificación exhaustiva, porque el compilador puede comprobar que atiendes cada caso. La trazabilidad, porque una intención que es dato se puede registrar, y un registro de intenciones es la historia de la sesión. La reproducibilidad, porque un dato se puede volver a inyectar, y reinyectar la misma secuencia de intents sobre el mismo estado inicial produce el mismo resultado. La comprobabilidad, porque testear una intención es construir un valor y pasarlo, sin tocar la pantalla. Todo esto —que en el Android imperativo era imposible— no lo trae ninguna librería: lo trae la simple decisión de tratar la intención del usuario como un sustantivo y no como un verbo. Ese es el poder de los tipos suma aplicado a la entrada de un sistema interactivo, y es también la razón por la que Elm, Redux y The Composable Architecture reinventaron, cada uno en su lenguaje, exactamente el mismo Msg, Action o Intent.

⚔️ Modela la superficie de interacción
  1. Elige una pantalla y escribe su sealed interface de intents: un data object por cada acción sin datos y una data class por cada acción con carga.
  2. Escribe el when exhaustivo sin rama else; añade luego un intent nuevo y comprueba que el compilador te obliga a cubrirlo.
  3. Renombra el tipo a UiEvent o UiAction y argumenta por qué evita el choque con android.content.Intent en cada revisión de código.
  4. Coloca un Log en onIntent y usa la app un minuto; lee después la traza y reconstruye qué hiciste solo con esa lista de intents.
  5. Escribe un intent con datos, por ejemplo TareaAlternada(val id: String), y explica por qué llevar el dato en el intent es mejor que leerlo desde una variable de la vista.