wandres.dev
ERRORES COMUNES · y cómo evitarlos

Sobreingeniería: cuando la ceremonia de MVI no se paga

Aplicar MVI completo a una pantalla de ajustes con tres interruptores produce ocho intents, un reductor, una jerarquía de efectos, cuatro ficheros y una batería de pruebas para un comportamiento que no tiene ninguna transición interesante. Esta lección construye el análisis coste-beneficio que casi nunca se hace: qué compra exactamente la ceremonia —reproducibilidad, un solo autor del estado, exhaustividad de casos, opcionalidad futura—, qué cuesta en indirección y en carga cognitiva, y qué criterio decide. El criterio no es el número de campos sino el número de transiciones válidas y la existencia de concurrencia real. Cierra con el error simétrico, que es más caro y menos discutido, y con la salida gradual que evita partir la aplicación en dos arquitecturas.

⏱ 18 min

Toda arquitectura tiene un coste de entrada y un umbral por debajo del cual ese coste no se recupera nunca; lo peculiar de MVI es que su umbral es alto y su atractivo estético es altísimo, combinación que produce una cantidad notable de ceremonia aplicada a pantallas que no la necesitaban. La escena típica es reconocible: alguien termina de estudiar el patrón, entiende por fin por qué el flujo unidireccional elimina clases enteras de fallos, y lo primero que toca es la pantalla de ajustes, que tiene tres interruptores y ninguna concurrencia. El resultado son cuatro ficheros nuevos, una jerarquía sellada de ocho intents, un reductor y unas pruebas que verifican que pulsar un interruptor lo pone en verdadero. Nada de eso está mal escrito. Simplemente no compra nada, y en ingeniería un coste que no compra nada tiene un nombre preciso que no es elegancia.

🎯 Al terminar esta lección sabrás
  • Enumerar qué garantías compra exactamente MVI y cuáles de ellas necesita una pantalla dada.
  • Cuantificar el coste real de la ceremonia en indirección, ficheros y carga cognitiva.
  • Aplicar el criterio de las transiciones válidas y la concurrencia en lugar del recuento de campos.
  • Reconocer el error simétrico y adoptar una escala gradual que no parta la aplicación en dos arquitecturas.

Qué compra la ceremonia

Antes de decidir si algo sobra hay que saber qué hace, y MVI compra cuatro garantías bien definidas que conviene separar porque no todas interesan a la vez.

La primera es la reproducibilidad. Como toda transición pasa por una función pura del estado anterior y una entrada nombrada, la secuencia de intents es una descripción completa de cómo se llegó al estado actual, lo que permite reproducir un fallo a partir de un registro y probar la lógica sin plataforma. La segunda es el autor único: no hay ninguna vía para modificar el estado que no sea el reductor, de modo que la pregunta quién cambió esto tiene siempre una respuesta finita y localizable. La tercera es la exhaustividad, que aporta el compilador: una jerarquía sellada de intents obliga a tratar todos los casos y convierte el olvido de uno en un error de compilación en lugar de en un botón que no hace nada. La cuarta, la menos mencionada y a menudo la más valiosa, es la opcionalidad: una pantalla con la forma de MVI absorbe requisitos nuevos —un tercer estado de carga, una condición de carrera, un reintento— sin cambiar de estructura.

🚨

Reproducibilidad

Vale mucho cuando los fallos llegan desde producción y hay que reconstruirlos sin poder preguntar al usuario. Vale poco cuando la pantalla no tiene secuencias que reconstruir.

🧩

Autor único y exhaustividad

Valen mucho cuando varias personas tocan la misma pantalla durante meses. Valen poco cuando el fichero completo cabe en una pantalla y lo mantiene quien lo escribió.

🚪

Opcionalidad futura

Vale en proporción a la probabilidad de que la pantalla crezca. Es la única de las cuatro que se paga por adelantado y se cobra más tarde, y por eso la que más se sobreestima.

Nótese que las cuatro garantías se cobran en circunstancias distintas y ninguna de forma continua. La reproducibilidad se cobra el día que llega un informe de producción sin pasos claros. La exhaustividad, el día que alguien añade un caso nuevo. El autor único, el día que hay que averiguar por qué un campo cambió sin permiso. Una pantalla en la que ninguno de esos días vaya a llegar nunca no está pagando una prima cara: está pagando una prima entera por un riesgo nulo, que es una descripción bastante exacta de lo que significa sobreingeniería.

El coste tiene tres partidas y también conviene nombrarlas sin dramatismo. La primera es la indirección: seguir el recorrido desde una pulsación hasta un cambio visible exige atravesar la declaración del intent, su tratamiento, el reductor y la observación, cuatro saltos donde una llamada directa tenía uno. La segunda es la superficie de código, que en la pantalla de ajustes multiplica por varias veces el número de líneas necesarias. La tercera, y la que de verdad importa, es la carga cognitiva sobre quien llega después, que debe reconstruir el modelo mental completo para modificar un interruptor.

// Ceremonia completa para algo que no tiene transiciones interesantes
sealed interface AjustesIntent {
    data class CambiarNotificaciones(val activo: Boolean) : AjustesIntent
    data class CambiarTemaOscuro(val activo: Boolean) : AjustesIntent
    data class CambiarSonido(val activo: Boolean) : AjustesIntent
}

// Lo que la pantalla necesitaba de verdad
fun cambiarTemaOscuro(activo: Boolean) = intent {
    reduce { state.copy(temaOscuro = activo) }
    prefs.guardarTema(activo)
}
⚠️
El argumento de la consistencia es real, pero no es gratis

La objeción más seria a esta lección no es técnica sino organizativa: tener dos arquitecturas conviviendo en una aplicación tiene un coste propio, porque cada persona que abre una pantalla debe primero averiguar en qué régimen está. Ese coste existe y no conviene negarlo. Lo que sí conviene es medirlo contra el otro: si la aplicación tiene cuarenta pantallas y treinta son listas de ajustes o páginas informativas, aplicar la ceremonia completa a las cuarenta para no tener dos regímenes significa pagar treinta veces un coste que solo se recupera diez. La salida no es elegir entre uniformidad y pragmatismo, sino definir una escala explícita con criterios escritos, de modo que el régimen de cada pantalla sea deducible en diez segundos a partir de lo que la pantalla hace y no una decisión personal de quien la escribió.

El criterio: transiciones, no campos

La heurística que circula —usa MVI si el estado tiene muchos campos— es incorrecta y produce exactamente los errores que esta lección quiere evitar. El número de campos mide cuánta información muestra la pantalla; lo que hace falta medir es cuántas transiciones válidas tiene, es decir, cuántas maneras distintas hay de pasar de una situación a otra y cuántas restricciones existen entre ellas. Una pantalla puede tener treinta campos y una sola transición: se cargan los datos y se muestran. Y puede tener tres campos y quince transiciones, si esos tres campos participan en un proceso con reintentos, cancelaciones y validación cruzada.

El segundo criterio es la concurrencia real. Si en la pantalla puede haber dos operaciones en vuelo cuyo orden de terminación no está garantizado, hay una máquina de estados aunque nadie la haya escrito, y la única elección disponible es si estará explícita en un reductor o implícita y repartida por devoluciones de llamada. Ese es el punto donde MVI deja de ser una preferencia y pasa a ser la opción barata, porque el coste de depurar una condición de carrera implícita supera con holgura el coste de toda la ceremonia.

flowchart TB
A[Pantalla nueva]
A --> B[Cuenta las transiciones validas]
B --> C[Una sola transicion y sin concurrencia]
B --> D[Varias transiciones o trabajo asincrono simple]
B --> E[Concurrencia real o proceso con reintentos]
C --> F[Estado local recordado en la propia pantalla]
D --> G[ViewModel con estado observable y metodos publicos]
E --> H[MVI completo con intents reducer y efectos]
style F fill:#a6e3a1,color:#11111b
style G fill:#f9e2af,color:#11111b
style H fill:#89b4fa,color:#11111b

Contar transiciones es más fácil de lo que parece y no requiere formalismo. Basta con enumerar las situaciones en las que la pantalla puede encontrarse y trazar las flechas que las conectan, ignorando los datos concretos. Una pantalla de ajustes tiene una sola situación: está mostrando los ajustes, y cada interruptor cambia un valor sin cambiar la situación. Una pantalla de pago tiene al menos cinco y varias flechas de vuelta, incluidas las que representan un fallo recuperable. La diferencia entre ambos dibujos se ve en treinta segundos y decide más que cualquier discusión sobre estilo.

// Una sola situacion y ninguna transicion: la ceremonia no compra nada
var notificaciones by rememberSaveable { mutableStateOf(prefs.notificaciones) }

// Cinco situaciones y flechas de vuelta: aqui la maquina explicita se paga sola
sealed interface PagoState { /* Inactivo, Validando, Enviando, Fallo, Confirmado */ }

Hay un tercer criterio, menos técnico y perfectamente legítimo: la esperanza de vida de la pantalla. Una pantalla que va a durar cinco años y ser tocada por veinte personas justifica una estructura que una pantalla de campaña, que se retirará en seis semanas, no justifica en absoluto. Es el mismo razonamiento que se aplica a cualquier inversión con horizonte, y omitirlo es la razón por la que tantos experimentos temporales acaban con más arquitectura que el producto principal.

El error simétrico

Conviene dedicar espacio al fallo contrario, porque es más caro y se discute mucho menos. Consiste en infraestructurar la pantalla que sí lo necesitaba: el proceso de pago, el asistente de varios pasos, la pantalla de sincronización con conflictos, el editor con guardado automático y trabajo pendiente. En estas, la ausencia de una máquina de estados explícita no elimina la máquina, solo la reparte entre banderas booleanas independientes que ninguna persona puede enumerar completa. Los síntomas son inconfundibles y llegan siempre en el mismo orden: primero aparecen combinaciones de banderas que nadie sabe si son posibles, después llegan los fallos que solo se dan con la red lenta, y al final alguien añade una bandera nueva para tapar un caso concreto y vuelve a empezar el ciclo.

El diagnóstico es mecánico y no requiere juicio: cuenta los booleanos independientes que describen la situación de la pantalla y eleva dos a esa cifra. Ese número es el de configuraciones que el tipo admite. Después enumera las que el producto considera legítimas. Si la primera cifra es varias veces la segunda, tienes una máquina de estados repartida, y la diferencia entre ambas cifras es la lista exacta de situaciones que nadie ha probado nunca porque nadie sabía que existían.

// La misma pantalla con la maquina explicita: cinco situaciones y ninguna imposible
sealed interface PagoState {
    data object Inactivo : PagoState
    data object Validando : PagoState
    data class Enviando(val intento: Int) : PagoState
    data class Fallo(val motivo: MotivoPago, val reintentable: Boolean) : PagoState
    data class Confirmado(val recibo: String) : PagoState
}

La asimetría de costes entre ambos errores es lo que debería inclinar la decisión en los casos dudosos. La sobreingeniería produce código aburrido, verboso y correcto, que se puede simplificar en una tarde cuando alguien se harta. La infraingeniería en una pantalla con concurrencia produce fallos intermitentes en producción, en un dominio donde los fallos cuestan dinero, y su corrección exige rehacer la pantalla entera con usuarios dentro. Ante la duda genuina, el error caro es el segundo.

// Infraingenieria: la maquina de estados existe, solo que repartida
var cargando = false
var enviando = false
var hayError = false
var pagoConfirmado = false
// cuatro booleanos son dieciseis combinaciones y solo cinco son legitimas

Decidir sin rehacer la discusión cada vez

El problema práctico de todo lo anterior es que produce una decisión por pantalla, y una decisión por pantalla tomada de cero es una discusión por pantalla. Los equipos que resuelven bien esto no lo hacen teniendo mejor criterio individual, sino escribiendo el criterio una vez y aplicándolo después sin volver a debatirlo.

La forma mínima que funciona cabe en media página del documento de arquitectura y tiene tres partes. La primera es la escala de grados con su descripción, para que el vocabulario sea común y nadie tenga que explicar qué quiere decir con MVI ligero. La segunda son los disparadores objetivos que obligan a subir de grado: más de una operación asíncrona simultánea, más de tres situaciones distintas, o cualquier operación que mueva dinero o datos irreversibles. La tercera es la regla de revisión: la decisión se anota en una línea de comentario al principio del fichero, de modo que quien llegue después sepa que fue una decisión y no un descuido.

Esa última parte es la que más rendimiento da y la que más se omite. La mayor parte del daño que causa la ceremonia insuficiente no viene de la decisión original, que solía ser correcta cuando se tomó, sino de que nadie sabe que se tomó. Una pantalla en grado uno sin explicación se lee como una pantalla mal hecha, y la reacción típica es reescribirla entera en grado cuatro o, peor, añadirle banderas hasta que deje de comportarse como grado uno sin llegar a ser otra cosa.

Queda una pregunta legítima: cuándo se sube de grado. La respuesta no es cuando alguien tenga tiempo, sino en el momento exacto en que un requisito nuevo cruza uno de los disparadores escritos. Subir de grado durante la implementación del requisito que lo justifica cuesta una fracción de lo que cuesta hacerlo después, porque el contexto está cargado y porque la prueba de que hacía falta está delante. Aplazarlo produce el patrón más común de deuda de arquitectura: una pantalla que lleva dos años pidiendo el grado siguiente y recibiendo parches del grado anterior.

💡
La escala gradual que evita partir la aplicación en dos

La forma de tener criterio sin tener dos arquitecturas es reconocer que hay una sola dirección de flujo y varios grados de ceremonia sobre ella. En todos los grados el estado baja y los eventos suben; lo que cambia es cuánta estructura se pone en el camino. Grado uno: estado recordado en la propia pantalla, con persistencia frente a la muerte del proceso cuando haga falta. Grado dos: un ViewModel con un estado observable y métodos públicos que lo actualizan, sin jerarquía de intents. Grado tres: intents nombrados y reductor, todavía sin canal de efectos. Grado cuatro: la ceremonia completa. Como los cuatro comparten dirección y forma del estado, subir de grado es una ampliación local y nunca una reescritura, que es justo la propiedad que hace innecesario acertar a la primera.

La arquitectura es un seguro, y un seguro se valora por la prima y por el riesgo

La discusión sobre si MVI sobra en una pantalla concreta suele plantearse como una cuestión de gusto o de pureza, y se vuelve mucho más manejable en cuanto se reconoce lo que realmente es: una decisión bajo incertidumbre con la misma estructura que cualquier decisión de seguro. Toda arquitectura cobra una prima cierta y presente —líneas de código, indirección, tiempo de comprensión de cada persona que llegue después— a cambio de cubrir un riesgo incierto y futuro: la condición de carrera que aún no ha ocurrido, el requisito que aún no ha llegado, la persona que aún no ha entrado en el equipo. De ahí se sigue todo lo demás sin necesidad de apelar a ninguna doctrina. Se sigue que la pregunta correcta nunca es si la arquitectura es buena, porque las primas no se juzgan en abstracto, sino si esta prima concreta es proporcionada a este riesgo concreto. Se sigue que la respuesta depende de propiedades del contexto que no están en el código —cuántas personas, cuántos años, cuánta concurrencia, cuánto cuesta un fallo— y que por tanto ninguna regla universal puede sustituir al juicio local, lo cual explica por qué las discusiones arquitectónicas entre equipos distintos rara vez convergen: están valorando riesgos genuinamente diferentes con la misma palabra. Se sigue también que sobreasegurar y desasegurar son ambos errores, y que su asimetría de costes es la que debe gobernar los casos dudosos: la prima excesiva se pierde de forma lenta, visible y recuperable, mientras que la cobertura insuficiente se paga de golpe, en producción y en el peor momento. Y se sigue, por último, la única recomendación estructural que sobrevive a todos los contextos: si consigues que subir de grado sea barato, la calidad de tu decisión inicial deja de importar tanto. Ahí está el verdadero valor de mantener una sola dirección de flujo en toda la aplicación con distintos grados de ceremonia encima. No es uniformidad estética; es reducir el coste de haberse equivocado, que en un oficio donde nadie conoce los requisitos de dentro de un año es la propiedad más valiosa que una arquitectura puede ofrecer.

⚔️ Valora la prima y el riesgo
  1. Elige tres pantallas de un proyecto real y cuenta, para cada una, sus transiciones válidas y si tiene dos operaciones que puedan estar en vuelo a la vez.
  2. Asigna a cada una un grado de la escala y escribe la justificación en dos frases que otra persona pueda comprobar.
  3. Toma una pantalla con ceremonia completa que no la necesite y bájala de grado sin cambiar la dirección del flujo. Anota cuántas líneas y cuántos ficheros desaparecen.
  4. Busca una pantalla con banderas booleanas independientes, enumera todas sus combinaciones y señala cuáles son imposibles.
  5. Estima para esa misma pantalla el coste de un fallo intermitente en producción y compáralo con el coste de la ceremonia que le falta.