El veredicto pragmático y la migración gradual
Cerrado el análisis comparativo, queda lo que un equipo necesita de verdad: un criterio operativo para decidir y un camino para moverse sin detener la entrega. Esta lección propone tres preguntas que sitúan cualquier pantalla en el eslabón que le corresponde, defiende sin complejos los casos en los que MVVM es la respuesta correcta y no una concesión, y describe una migración en cuatro pasos donde cada uno es útil por sí solo y reversible. Termina catalogando los errores de migración que se repiten en todos los equipos: la reescritura total, la adopción por decreto, el estado que crece sin apretarse y la ceremonia sin disciplina.
Después de cuatro lecciones de genealogía, análisis y tablas, lo que un equipo se lleva a la reunión del lunes no puede ser una postura: tiene que ser un procedimiento. Y el procedimiento útil aquí es más modesto de lo que las discusiones de arquitectura sugieren. No hay que decidir el patrón de la app, porque las apps no tienen un patrón: tienen pantallas, y las pantallas de una misma app difieren en complejidad y en criticidad más de lo que difieren dos apps entre sí. La pantalla de ajustes con cuatro interruptores y el flujo de pago en tres pasos con reintentos y confirmación no merecen la misma disciplina, y forzarles la misma es cometer uno de dos errores simétricos: ceremonia inútil en la primera o libertad peligrosa en la segunda. Esta lección entrega tres preguntas que sitúan cada pantalla donde le toca, defiende sin disculpas los casos en que MVVM es la respuesta correcta, y describe cómo moverse hacia MVI en pasos que valen la pena por separado, porque una migración que solo rinde al final es una migración que se abandonará a la mitad.
- Aplicar tres preguntas operativas que sitúan una pantalla en el eslabón de disciplina que le corresponde.
- Defender con criterio los casos en que
MVVMes suficiente yMVIsería ceremonia sin retorno. - Ejecutar una migración en cuatro pasos donde cada paso aporta valor por sí solo y es reversible.
- Reconocer y evitar los cuatro errores de migración que se repiten en todos los equipos.
Tres preguntas que deciden
El criterio no necesita ser sofisticado, necesita ser aplicable en cinco minutos por cualquier miembro del equipo y dar la misma respuesta en manos distintas. Estas tres preguntas cumplen.
La primera es de complejidad: cuántas piezas independientes tiene el estado de la pantalla y cuántas fuentes asíncronas pueden escribirlas a la vez. Con tres piezas y una corrutina, el riesgo de incoherencia es teórico. Con siete piezas y tres fuentes concurrentes, es estadístico.
La segunda es de criticidad: qué pasa si el usuario ve durante un segundo un estado que nadie diseñó. En un buscador es un parpadeo que nadie reporta. En una pantalla de transferencia es un incidente, y posiblemente un cargo duplicado.
La tercera es de longevidad: cuántas veces se va a tocar esta pantalla en los próximos dos años y por cuántas personas distintas. La ceremonia se paga una vez; las decisiones repetidas que la ceremonia elimina se pagan cada vez, multiplicadas por el número de personas que las toman con criterios distintos.
Las tres preguntas tienen la propiedad que hace útil a cualquier criterio de equipo: se contestan con datos que ya existen. El número de piezas del estado se cuenta leyendo el ViewModel. La criticidad la contesta quien lleva el producto en una frase. Y la longevidad se estima mirando el historial del control de versiones, que dice cuántas veces se tocó esa pantalla el año pasado y cuántos autores distintos aparecen. Ninguna de las tres exige una reunión de arquitectura ni una opinión sobre patrones, que es exactamente lo que las hace aplicables.
Si al menos dos de las tres respuestas están en el extremo alto —muchas piezas concurrentes, fallo caro, mantenimiento largo con varias manos— MVI completo se paga solo. Si solo una lo está, unifica el estado y deja la entrada como está. Si ninguna lo está, MVVM tal cual es la decisión correcta y añadir ceremonia es empeorar la pantalla con buenas intenciones.
Cuándo MVVM es suficiente, sin disculpas
Conviene decir esto sin el tono de concesión que suele acompañarlo, porque el tono importa: hay pantallas donde MVVM no es lo aceptable mientras llega algo mejor, sino lo correcto.
Pantallas de exhibición
Un detalle que carga un objeto y lo pinta, sin más modos que cargando, error y contenido, con una sola fuente. La disciplina extra no elimina ningún riesgo real.
Prototipos y experimentos
Código con fecha de caducidad conocida. La ceremonia es una inversión a devolver en el futuro, y aquí no hay futuro que la devuelva.
Equipos pequeños y estables
Cuando tres personas conocen toda la app y no hay rotación, el conocimiento tácito sostiene barato lo que en un equipo grande hay que codificar en tipos.
Hay un cuarto caso menos obvio y bastante frecuente: las pantallas cuyo estado no es realmente suyo. Una lista que refleja directamente una consulta a base de datos observable, sin filtros ni modos locales, tiene su fuente de verdad fuera del ViewModel, y envolverla en un reductor añade una capa que no decide nada. El reductor rinde cuando hay decisiones que tomar sobre el estado; si el estado solo se transporta, MVI es tubería con ceremonia.
La migración en cuatro pasos
La migración que funciona no reescribe: aprieta. Y su propiedad esencial es que cada paso deja la base de código mejor aunque los siguientes no lleguen nunca, porque la mayoría de las migraciones que se anuncian no se terminan.
// Paso 1: reunir los observables en un solo tipo. La entrada no se toca.
data class BusquedaState(
val consulta: String = "",
val contenido: Contenido = Contenido.Vacio,
)
class BusquedaViewModel : ViewModel() {
private val _state = MutableStateFlow(BusquedaState())
val state: StateFlow<BusquedaState> = _state.asStateFlow()
// Los metodos publicos siguen siendo los de siempre
fun onConsultaCambiada(t: String) = _state.update { it.copy(consulta = t) }
fun onBuscar() { /* escribe el estado entero, de una pieza */ }
}
El paso uno unifica el estado y no toca la entrada. Es el que más rinde por lo que cuesta: elimina la ventana de incoherencia, hace la pantalla comprobable por valor y permite empezar a apretar tipos. Y es completamente reversible.
El paso dos aprieta ese tipo. Los campos que se excluyen mutuamente pasan a ser un tipo suma, los que no pueden faltar dejan de ser anulables, y las combinaciones imposibles dejan de compilar. No cambia ninguna interfaz pública; cambia qué se puede representar.
// Paso 2: el compilador empieza a rechazar lo que el dominio no admite.
sealed interface Contenido {
data object Cargando : Contenido
data object Vacio : Contenido
data class Exito(val items: List<Item>) : Contenido
data class Error(val mensaje: String) : Contenido
}
Este paso es el que más equipos se saltan y el que más rinde por línea escrita, porque sin él el paso uno solo ha cambiado la forma del contenedor sin reducir lo que cabe dentro. La señal de que se ha hecho bien es concreta y se puede comprobar en una revisión: después del paso dos no debe quedar en el estado ningún par de campos cuya combinación no corresponda a una pantalla que alguien dibujó.
// Paso 3: extraer un reductor puro. La vista sigue llamando a metodos.
fun reduce(estado: BusquedaState, intent: BusquedaIntent): BusquedaState = when (intent) {
is BusquedaIntent.ConsultaCambiada -> estado.copy(consulta = intent.texto)
BusquedaIntent.Buscar -> estado.copy(contenido = Contenido.Cargando)
BusquedaIntent.Reintentar -> estado.copy(contenido = Contenido.Cargando)
}
El paso tres extrae las transiciones a una función pura, aunque los métodos públicos sigan existiendo y se limiten a delegar en ella. Aquí llega la comprobabilidad completa sin haber cambiado todavía nada del lado de la vista. El paso cuatro, y solo si las tres preguntas lo justifican, colapsa los métodos públicos en un único onIntent, con lo que aparecen la trazabilidad, la reproducibilidad y las políticas transversales.
// Paso 4: los metodos publicos se conservan un tiempo y delegan en la unica entrada.
fun onIntent(intent: BusquedaIntent) = _state.update { reduce(it, intent) }
@Deprecated("Usa onIntent", ReplaceWith("onIntent(BusquedaIntent.Buscar)"))
fun onBuscar() = onIntent(BusquedaIntent.Buscar)
Ese periodo de convivencia es lo que hace la migración soportable en una app viva. Las pantallas que ya llaman a los métodos antiguos siguen funcionando, las nuevas usan la entrada única, y el aviso de obsolescencia convierte la migración pendiente en una lista que el compilador mantiene sola. Cuando la lista llega a cero, los métodos se borran; si nunca llega, la base de código sigue siendo coherente y nadie ha quedado a medias.
flowchart LR P0[MVVM disperso] --> P1[Paso 1 estado unico] P1 --> P2[Paso 2 tipos apretados] P2 --> P3[Paso 3 reductor puro] P3 --> P4[Paso 4 entrada unica] style P1 fill:#f9e2af,color:#11111b style P3 fill:#89b4fa,color:#11111b style P4 fill:#a6e3a1,color:#11111b
Los cuatro errores que se repiten
Las migraciones fracasan casi siempre por los mismos motivos, y ninguno es técnico.
El primero es la reescritura total: congelar funcionalidades para migrar la app entera. Termina mal por aritmética elemental, no por falta de voluntad: el valor llega solo al final, el negocio pierde la paciencia antes, y lo que queda es media app en cada estilo con la peor propiedad de ambos.
El segundo es la adopción por decreto. Imponer MVI en todas las pantallas nuevas sin criterio produce ajustes con reductor de tres líneas y un tipo sellado de dos intents, que es exactamente el ejemplo que los escépticos usarán —con razón— para argumentar que el patrón es burocracia.
Hay una variante de este error que se disfraza de rigor: aplicar MVI en las pantallas nuevas y dejar las viejas como estaban, sin plan. El resultado no es una migración gradual sino una app con dos dialectos permanentes, donde cada persona tiene que saber en cuál está antes de leer nada. La diferencia entre migración gradual y bilingüismo crónico es que la primera tiene un orden justificado y un criterio para detenerse; la segunda solo tiene la fecha en que se escribió cada archivo.
El tercero es el estado unificado que nadie aprieta. Reunir ocho observables en una data class de ocho campos anulables y booleanos deja el mismo espacio combinatorio de antes, ahora con una fachada de orden. El paso uno sin el paso dos entrega la mitad del beneficio y toda la ilusión de haber terminado.
El fallo más silencioso es adoptar la forma de MVI y romper su regla. Un ViewModel que expone un StateFlow y un onIntent, pero que además tiene un método público que escribe el estado directamente desde una corrutina, ha pagado la ceremonia entera y ha perdido la garantía entera, porque la garantía no la da el reductor sino la exclusividad del reductor. Un solo camino alternativo de escritura destruye el invariante para toda la pantalla, y a partir de ahí el equipo razona con una confianza que el código ya no merece.
Hay una asimetría incómoda que atraviesa toda esta discusión y que ninguna tabla comparativa recoge, porque no es una propiedad del código: la calidad real de una arquitectura no es la que exhibe el día que se diseña, sino la que conserva después de cien cambios hechos con prisa por personas que no estuvieron en aquella reunión. Y bajo ese criterio, el patrón teóricamente superior pierde con frecuencia frente al patrón que el equipo entiende, porque un diseño que solo funciona mientras todo el mundo lo respeta es un diseño que depende de una condición que ninguna organización sostiene indefinidamente. De ahí que la pregunta operativa no sea cuál es el mejor patrón sino cuál es el patrón más restrictivo que este equipo, con esta rotación y esta presión de entrega, va a aplicar correctamente sin excepciones. Un MVVM unificado aplicado con rigor absoluto vale mucho más que un MVI aplicado con tres puertas traseras, porque el valor de una restricción es proporcional a su tasa de cumplimiento y cae a cero en cuanto admite excepciones. Nótese lo que esto implica sobre la migración gradual: no es una versión descafeinada de la migración de verdad, es la forma que respeta esa asimetría. Cada paso deja la base de código estrictamente mejor, no depende de que los siguientes lleguen, y permite al equipo detenerse en el punto donde su capacidad real de sostener disciplina se encuentra con la complejidad real de sus pantallas. Ese punto de equilibrio es distinto para cada equipo, cambia con el tiempo y no aparece en ningún artículo, porque depende de gente concreta. Quien lo entiende deja de buscar la arquitectura correcta como si fuera un teorema con solución única y empieza a hacer la única pregunta que ha importado siempre: cuánta disciplina puede sostener este equipo sin excepciones, y qué complejidad puede permitirse en consecuencia. Esa pregunta no se contesta una vez: se recontesta cada vez que cambia el equipo, el producto o el riesgo, y la señal de madurez de una organización técnica es que sepa recontestarla sin convertirlo en una guerra de bandos.
- Aplica las tres preguntas —complejidad, criticidad, longevidad— a cinco pantallas de tu app y sitúa cada una en el paso que le corresponde.
- Elige la pantalla con puntuación más alta y ejecuta el paso uno: reúne sus observables en un solo tipo sin tocar la entrada; mide qué fallos deja de poder tener.
- Ejecuta el paso dos apretando ese tipo con tipos suma y comprueba cuántas combinaciones dejan de compilar.
- Extrae el reductor puro y escribe tres tests que antes habrían necesitado instrumentación; compara el tiempo de ejecución.
- Decide de forma justificada si esa pantalla debe dar el paso cuatro, y busca en tu base de código un caso del cuarto error: ceremonia de
MVIcon una escritura del estado por fuera del reductor.