Decidir con criterio: librería o a mano, MVI o MVVM, KMP o nativo
Las tres decisiones que separan a quien aplica una arquitectura de quien la elige, tratadas sin lealtades y con el coste de cada opción escrito en voz alta. Esta lección desmonta el falso dilema entre Orbit y una implementación propia mostrando qué se paga exactamente en cada caso, ordena la comparación entre MVI y MVVM por el único eje que importa —cuántos estados imposibles admite el tipo—, examina Kotlin Multiplatform preguntando qué capa se comparte de verdad y cuál nunca conviene compartir, y termina con un árbol de decisión revisable y un protocolo para dar marcha atrás sin drama.
Llega un punto en el que ya no te preguntan cómo se hace algo, sino qué deberíamos hacer, y esa pregunta no tiene respuesta técnica: tiene respuesta contextual. Las tres decisiones de esta lección —adoptar una librería o construir el mecanismo, elegir MVI o MVVM, compartir código entre plataformas o no— se debaten habitualmente como si tuvieran una respuesta universal, y esa es exactamente la razón por la que los debates no terminan nunca. Cada una es un intercambio con costes reales en ambos lados, y la marca del criterio maduro no es defender una opción con energía sino saber enunciar en voz alta qué estás pagando por la que elegiste y qué evidencia te haría cambiar de idea.
- Comparar Orbit con una implementación propia por lo que cuesta mantener cada una, no por líneas de código.
- Ordenar la elección entre MVI y MVVM por el eje de los estados imposibles y el coste de la ceremonia.
- Evaluar Kotlin Multiplatform por capas, distinguiendo lo que se comparte bien de lo que nunca conviene compartir.
- Construir un árbol de decisión revisable con criterios de reversión escritos por adelantado.
Librería o a mano
El MVI mínimo cabe en unas cincuenta líneas: un MutableStateFlow para el estado, un Channel para los efectos, un mutex o un actor para serializar las transiciones y un par de funciones de conveniencia. Que quepa en cincuenta líneas es justamente lo que hace engañosa la decisión, porque el coste de una infraestructura propia nunca está en escribirla.
// El nucleo escrito a mano: corto de escribir, largo de mantener
abstract class MviViewModel<S, E>(inicial: S) : ViewModel() {
private val _estado = MutableStateFlow(inicial)
val estado: StateFlow<S> = _estado.asStateFlow()
private val _efectos = Channel<E>(Channel.BUFFERED)
val efectos: Flow<E> = _efectos.receiveAsFlow()
private val turno = Mutex()
protected fun intent(bloque: suspend IntentScope<S, E>.() -> Unit) {
viewModelScope.launch { IntentScope(_estado, _efectos, turno).bloque() }
}
}
Lo que hay que sopesar es lo que ese código no tiene todavía y que alguien pedirá en los próximos dos años: soporte para guardar y restaurar el estado, un modo de prueba que permita afirmar trazas, agrupación de intenciones por identificador para cancelar la anterior, integración con las herramientas de depuración, tratamiento uniforme de excepciones y documentación para el que entre nuevo. Cada una de esas piezas es razonable por separado y todas juntas son una librería, mantenida por una sola persona, sin usuarios externos que reporten fallos y sin nadie a quien preguntar cuando esa persona se va.
A cambio, la implementación propia da algo que no es despreciable: ausencia de dependencia, control total sobre la semántica y ninguna sorpresa en una actualización. En un equipo con criterio fuerte y necesidades peculiares —una semántica de cancelación específica, una integración con un sistema propio de telemetría— eso puede valer más que el ahorro.
Elige la librería si...
El equipo es mediano o grande, rota gente, quieres pruebas por traza desde el primer día y prefieres gastar tu presupuesto de complejidad en el dominio y no en el andamiaje.
Elige a mano si...
Necesitas una semántica que la librería no ofrece, tienes restricciones fuertes de dependencias, o estás aprendiendo y el objetivo explícito es entender el mecanismo por dentro.
El coste que se olvida
Escribirlo es una tarde. Mantenerlo, documentarlo, enseñarlo y evolucionarlo durante tres años es el coste real, y nunca aparece en la estimación inicial.
La vía intermedia
Escribe primero la versión mínima para entender el mecanismo, y adopta después la librería sabiendo exactamente qué te resuelve. El conocimiento no se pierde y la deuda no se contrae.
No preguntes qué es mejor. Pregunta esto: si la persona que escribió nuestro MVI casero se fuera mañana, ¿cuánto tardaría alguien nuevo en poder modificarlo con seguridad? Si la respuesta es un día porque son cincuenta líneas legibles y probadas, la implementación propia es defendible. Si la respuesta es varias semanas porque hay semántica sutil sin documentar, acabas de descubrir que tienes una librería sin comunidad, y esa es la peor de las tres opciones.
MVI o MVVM
Este debate se enturbia porque se plantea como una elección entre arquitecturas cuando en realidad es una elección sobre qué puede expresar tu tipo de estado. MVVM con varios flujos independientes permite escribir estados que no deberían existir; MVI con un único estado sellado los hace inexpresables. Todo lo demás —los nombres, las librerías, las convenciones— es secundario frente a eso.
// MVVM con flujos sueltos: nada impide cargando y error a la vez
val cargando: StateFlow<Boolean>
val datos: StateFlow<List<Pedido>>
val error: StateFlow<String?>
// MVI con estado sellado: la combinacion imposible no se puede escribir
sealed interface PedidosState {
data object Cargando : PedidosState
data class Contenido(val pedidos: List<Pedido>) : PedidosState
data class Error(val mensaje: String) : PedidosState
}
Con tres flujos booleanos y nulos hay ocho combinaciones representables y quizá tres legítimas; las otras cinco son bugs esperando a que alguien las produzca en un orden concreto de emisiones. Con el tipo sellado hay exactamente tres, y las cinco restantes dejaron de existir como categoría. Ese es el argumento de MVI, y es sólido.
El argumento contrario también lo es y conviene no caricaturizarlo. MVI cobra ceremonia: un tipo por gesto, un tipo por estado, un tipo por efecto, un reductor y un canal, todo eso para una pantalla que muestra un texto y un botón. En pantallas triviales esa ceremonia no compra nada porque no había estados imposibles que evitar, y pagarla igualmente es puro ruido que además desincentiva a los equipos nuevos.
Por eso el criterio útil no es cuál adoptar, sino dónde poner la frontera dentro de la misma aplicación. Una pantalla de ajustes con dos interruptores no necesita un reductor; una pantalla de pago con validación cruzada, reintentos, tiempo de espera y estados parciales lo necesita desesperadamente.
flowchart TD A[Cuantos estados puede tener la pantalla] -->|Dos o tres, sin combinaciones| B[MVVM simple basta] A -->|Muchos, con combinaciones invalidas| C[MVI con estado sellado] C --> D[Hay efectos de una sola vez] D -->|Si| E[Canal de efectos] D -->|No| F[Solo estado] style C fill:#a6e3a1,color:#11111b style B fill:#89b4fa,color:#11111b
Conviene además desactivar un falso dilema muy repetido: MVI y MVVM no son excluyentes en el sentido en que la gente cree. Un ViewModel que expone un único StateFlow de un data class inmutable y recibe eventos tipados es MVI, se llame como se llame. La discusión sobre nombres ha consumido más horas de equipo que cualquier decisión real de diseño.
Hay una gradación intermedia que resuelve la mayoría de los casos reales y que rara vez se enseña, porque no tiene nombre de marca: un solo estado, pero sin tipo sellado ni reductor separado. Es MVVM en su forma disciplinada y cubre bien la franja media de pantallas.
// Grado intermedio: un estado unico, metodos en vez de intents
data class AjustesState(
val notificaciones: Boolean = false,
val guardando: Boolean = false,
)
class AjustesViewModel(private val repo: AjustesRepository) : ViewModel() {
private val _estado = MutableStateFlow(AjustesState())
val estado: StateFlow<AjustesState> = _estado.asStateFlow()
fun alternarNotificaciones(valor: Boolean) = viewModelScope.launch {
_estado.update { it.copy(guardando = true) }
repo.guardar(valor)
_estado.update { it.copy(notificaciones = valor, guardando = false) }
}
}
Ese código ya ha comprado lo esencial —estado único, inmutable, comparable por igualdad— sin pagar los tipos de intención ni el reductor. Lo que no compra es la traza reproducible, la comprobación del reductor sin corrutinas y la exhaustividad de las entradas. Saber exactamente qué falta en el grado intermedio es lo que permite subir de grado cuando algo empieza a doler, en vez de elegir de antemano por doctrina.
KMP o nativo
Kotlin Multiplatform plantea la pregunta con una nitidez que agradecerás: no es si compartir código, sino qué capa compartir, y la respuesta se deduce de cuánto varía cada capa entre plataformas.
El dominio se comparte casi siempre bien, porque las reglas de negocio son idénticas en Android y en iOS por definición. La capa de datos se comparte bien con las herramientas maduras del ecosistema para red y persistencia, con la salvedad de la configuración específica de cada plataforma. La presentación —el estado, las intenciones y el reductor— se comparte bien en teoría y regular en la práctica, porque las convenciones de navegación y de interacción difieren más de lo que un diagrama sugiere. Y la interfaz de usuario, salvo casos concretos, no conviene compartirla: cada plataforma tiene expectativas visuales y de comportamiento que un componente común satisface a medias en ambas.
Compartir código no divide el trabajo por dos: cambia su naturaleza. Aparecen tiempos de compilación mayores, herramientas de depuración menos maduras del lado no nativo, una capa de interoperabilidad que hay que entender, dependencias que solo existen para una plataforma y una fricción organizativa real, porque el equipo de iOS pasa a depender de cambios hechos por gente que no escribe Swift. Nada de eso invalida la decisión; todo eso hay que presupuestarlo antes de tomarla, y muy especialmente el coste de dar marcha atrás.
El criterio que funciona es proporcional al tamaño de lo compartido. Si el dominio de tu aplicación es rico —muchas reglas, muchos cálculos, muchas validaciones— compartirlo evita la duplicación más cara que existe, que es la de la lógica que puede divergir en silencio y producir dos comportamientos distintos ante el mismo dato. Si el dominio es fino y la aplicación es casi toda interfaz sobre un servidor, lo compartido será poco y el peaje de infraestructura no lo compensa.
MVI, por su parte, es especialmente amistoso con esta decisión, y no por casualidad: un estado que es un data class inmutable, un reductor que es una función pura y unas intenciones que son un tipo sellado no dependen de ninguna plataforma. Es exactamente la clase de código que se comparte sin fricción. Los efectos, en cambio, dependen de la plataforma por naturaleza —navegar, vibrar, abrir un documento— y se declaran en común pero se ejecutan en cada plataforma.
El árbol y su reversa
Una decisión sin criterio de reversión no es una decisión, es una apuesta a la que nadie puso fecha de revisión. Escribe cada elección con tres partes: qué eliges, qué estás pagando y qué observación concreta te haría reconsiderarlo.
// Ejemplo de decision escrita para que se pueda revisar
// Elegimos: Orbit en lugar de MVI propio.
// Pagamos: una dependencia externa y su ritmo de actualizaciones.
// Revisaremos si: necesitamos una semantica de cancelacion que no ofrece,
// o si el proyecto queda sin mantenimiento durante mas de un ano.
// Coste de revertir: alto, porque afecta a todas las pantallas.
Ese último punto —el coste de revertir— es el que ordena la prudencia. Adoptar una librería de MVI es reversible con trabajo mecánico porque el patrón sobrevive a la herramienta. Adoptar Compose es más caro de revertir. Adoptar KMP y reescribir la capa de datos compartida es el más caro de los tres, porque implica a dos equipos y a dos calendarios. La regla que se sigue de ahí es sencilla y poco intuitiva: cuanto más caro sea revertir una decisión, más evidencia debes exigir antes de tomarla, y más pequeño debe ser el primer paso. Un experimento de KMP con un único módulo de dominio, atravesando el ciclo completo hasta la tienda, enseña más que seis meses de deliberación.
La marca del ingeniero maduro no es tener opiniones fuertes sobre estas tres preguntas, sino ser capaz de argumentar la posición contraria a la suya mejor que quien la defiende. Eso suena a virtud retórica y es en realidad una herramienta de diagnóstico: si no puedes construir el caso contrario, es que no entiendes qué estás comprando, y quien no sabe qué compra no puede saber si el precio es justo. Detrás de las tres decisiones de esta lección late la misma estructura, y una vez que la ves aparece en todas partes. Cada opción intercambia coste presente por opcionalidad futura, y ninguna dirección del intercambio es gratis. La librería cambia control por velocidad; MVI cambia ceremonia por estados imposibles eliminados; KMP cambia fricción de herramientas por una única verdad de negocio. Nada de eso es una mejora en abstracto: todo es un desplazamiento de coste de un sitio a otro, y la única pregunta legítima es si el sitio de destino es uno que tú puedas permitirte. De ahí se deduce por qué los debates arquitectónicos son eternos: cada bando mide una dimensión distinta y ambos tienen razón sobre la que miden. Quien defiende MVI mide estados imposibles y tiene razón; quien lo critica mide líneas por funcionalidad y también tiene razón; el desacuerdo solo se resuelve nombrando qué dimensión importa más en este proyecto, con este equipo, este año, y esa respuesta cambia legítimamente entre proyectos y dentro del mismo proyecto con el tiempo. Y de ahí se deduce también la obligación menos practicada de la profesión: escribir la decisión con su coste y su criterio de revisión, para que dentro de dos años alguien pueda comprobar si la predicción acertó en lugar de heredar una elección convertida en dogma por el simple hecho de que nadie recuerda por qué se tomó. Una arquitectura de la que nadie sabe justificar el precio no es una arquitectura: es una costumbre.
- Documenta en cinco líneas por qué tu proyecto usa lo que usa para MVI, incluyendo qué estás pagando y qué te haría cambiar.
- Construye el argumento más fuerte que puedas contra la elección que defiendes hoy, y muéstraselo a alguien que piense distinto.
- Recorre tus pantallas y clasifica cuáles justifican el coste de MVI completo y cuáles se resolverían mejor con un estado simple.
- Estima el coste de revertir tus tres decisiones arquitectónicas más grandes y ordénalas de más a menos reversible.
- Si estás considerando compartir código entre plataformas, define el experimento más pequeño que atraviese el ciclo completo y ponle una fecha de evaluación.