wandres.dev
MVI A MANO · sin librería

Qué pierdes y qué ganas cuando no hay librería

El balance honesto de la implementación artesanal: se gana control total, cero dependencias, un binario más pequeño y la desaparición de una capa de indirección; se pierde un modelo de concurrencia probado, las herramientas de test que afirman sobre secuencias completas, el aislamiento de dependencias en pruebas y el vocabulario compartido que hace legible el código para quien llega. Esta lección cuantifica ambos platillos con casos concretos en vez de eslóganes, y muestra qué parte de lo perdido se puede recuperar y a qué precio.

⏱ 20 min

Las dos lecciones anteriores demostraron que MVI cabe en cincuenta líneas propias, y esa demostración tiene un efecto secundario predecible: la tentación de concluir que las librerías del ecosistema son ceremonia evitable. Conviene resistirla, no por respeto al ecosistema sino por precisión. Lo que se ha construido a mano reproduce fielmente el camino feliz del patrón, que es la parte fácil; lo que no se ha construido —y ni siquiera se ha mencionado— es el conjunto de decisiones sobre concurrencia, orden, reentrada y observabilidad que una librería madura ya ha tomado y verificado. El balance real no se decide comparando lo que cada opción hace cuando todo va bien, sino inventariando con frialdad qué ocurre cuando dos intents compiten, cuando un test necesita afirmar sobre una secuencia y cuando alguien nuevo abre el fichero dentro de dos años.

🎯 Al terminar esta lección sabrás
  • Enumerar con precisión qué se gana al eliminar la dependencia, más allá del argumento del tamaño.
  • Identificar las cuatro pérdidas reales: concurrencia, testing especializado, utilidades y vocabulario compartido.
  • Estimar el coste de recuperar cada pérdida escribiéndola uno mismo.
  • Distinguir los costes que se pagan una vez de los que se pagan en cada pantalla y en cada incorporación.

El platillo de las ganancias

Lo primero que se gana es control total sobre la semántica. Cuando el contenedor es tuyo, ningún comportamiento te sorprende porque ninguno está oculto: si quieres que los intents se procesen en paralelo, se procesan; si quieres que la reducción ocurra en un despachador concreto, ocurre. Esa transparencia tiene un valor real en dominios con requisitos raros, donde la semántica por defecto de una librería resulta ser la equivocada y forzarla cuesta más que no haberla tenido.

Lo segundo es la desaparición de una capa de indirección al depurar. Una traza de pila que atraviesa el intermediario de una librería obliga a entender el modelo de ejecución de esa librería antes de entender tu fallo; una traza que va de tu función a tu bloque de reducción se lee entera de un vistazo. Quien haya depurado una emisión perdida dentro de un contenedor ajeno reconoce inmediatamente la diferencia.

🎛️

Semántica propia

Ningún comportamiento por defecto que descubrir a base de sorpresas. Lo que hace tu contenedor es lo que has escrito.

🔍

Trazas planas

La pila de llamadas no atraviesa capas ajenas. Depurar es leer tu código y solo tu código.

🪶

Sin dependencia

Nada que actualizar, nada que pueda abandonarse, ningún conflicto de versiones con el resto del grafo.

La tercera ganancia, la ausencia de dependencia, merece una matización porque suele exagerarse en ambos sentidos. El argumento del tamaño del binario es casi siempre irrelevante: una librería MVI aporta unas pocas decenas de kilobytes y el reductor de código elimina lo no usado. El argumento serio es otro y es de gobierno: cada dependencia es una apuesta sobre que alguien seguirá manteniéndola, sobre que su próxima versión mayor no romperá tu código y sobre que su modelo de concurrencia seguirá encajando con el tuyo cuando ambos evolucionen. En un proyecto que debe vivir diez años con mantenimiento intermitente, esa apuesta tiene un coste esperado que no es cero.

ℹ️
El argumento del tamano casi nunca es el argumento verdadero

Cuando alguien defiende quitar una librería apelando al peso del binario, conviene pedirle el número. Suele ser dos órdenes de magnitud menor que cualquier imagen de la aplicación, y en cuanto se dice en voz alta el debate se traslada a donde debía estar desde el principio: control, mantenimiento y riesgo de abandono. Esos sí son argumentos que se sostienen, y tienen la virtud de que se pueden discutir con datos del propio proyecto en vez de con eslóganes.

El platillo de las pérdidas

La primera pérdida, y la que más caro se paga, es el modelo de concurrencia. Tu contenedor artesanal no dice nada sobre qué ocurre si dos intents llegan a la vez, si un intent se emite mientras otro está a mitad de camino, o si una reducción ocurre después de que su corrutina fuera cancelada. Una librería madura sí lo dice: define si los intents se serializan en una cola, si la reducción es atómica respecto de la lectura del estado y qué garantías de orden existen entre reducciones y efectos de un mismo intent.

// Con contenedor artesanal, esto puede intercalarse de formas no evidentes
fun aplicarCupon(codigo: String) = viewModelScope.launch {
    reducir { it.copy(validando = true) }
    val descuento = repo.validar(codigo)      // otro intent puede reducir aqui
    reducir { it.copy(validando = false, descuento = descuento) }
}

La segunda pérdida es el testing especializado. Sin librería, un test que quiera afirmar sobre la secuencia de estados debe montar a mano la recolección, un despachador de prueba y algún mecanismo para saber cuándo ha terminado el trabajo pendiente. Con herramientas específicas, esa misma afirmación es declarativa: se enuncia la secuencia esperada de estados y efectos y el marco se encarga de la sincronización.

@Test
fun aplicarCuponReduceYEmite() = runTest {
    val vm = CarritoViewModel(repoFalso)
    val estados = mutableListOf<CarritoState>()
    val trabajo = launch(UnconfinedTestDispatcher()) { vm.estado.toList(estados) }

    vm.procesar(CarritoIntent.AplicarCupon("VERANO"))
    advanceUntilIdle()

    assertEquals(3, estados.size)
    trabajo.cancel()
}

Ese fragmento funciona y es exactamente lo que hay que escribir en cada test de cada pantalla. No es difícil; es repetitivo, y lo repetitivo se degrada: aparece la variante que olvida cancelar, la que usa el despachador equivocado, la que afirma solo sobre el estado final porque montar la lista costaba demasiado. Y afirmar solo sobre el estado final es precisamente renunciar a lo que hace verificable a MVI, que es la secuencia de transiciones.

flowchart LR
A[Sin libreria] --> B[Control total y trazas planas]
A --> C[Concurrencia sin definir]
A --> D[Test montado a mano en cada pantalla]
A --> E[Vocabulario solo tuyo]
C --> F[Coste que crece con el equipo]
D --> F
E --> F
style B fill:#a6e3a1,color:#11111b
style F fill:#f38ba8,color:#11111b

La tercera pérdida son las utilidades acumuladas: aislamiento de dependencias en pruebas, control del tiempo virtual integrado, inspección del contenedor, integración con el guardado de estado ante muerte del proceso, comprobaciones en tiempo de ejecución que avisan de usos incorrectos. Cada una es prescindible por separado y ninguna es difícil de escribir; juntas suman muchas tardes que no se dedicaron al producto.

La cuarta es la menos técnica y suele ser la decisiva: el vocabulario compartido. Cuando alguien se incorpora y lee un contenedor conocido, reconoce el patrón y busca la documentación pública. Cuando lee tu clase base, tiene que reconstruir tus decisiones leyendo el código y preguntando, y esa reconstrucción se repite con cada persona nueva. Es un coste que no aparece en ninguna estimación y que se paga siempre.

Conviene notar la forma temporal de cada platillo, porque es donde se esconde el error de estimación más frecuente. Las ganancias son en su mayoría de pago único y se cobran pronto: no aprender una API, no integrar una dependencia, no leer notas de versión. Las pérdidas, en cambio, son recurrentes y se cobran tarde: en cada test, en cada pantalla nueva, en cada incorporación al equipo, en cada fallo de concurrencia que hay que reproducir. Una comparación hecha el primer mes favorece sistemáticamente a la implementación artesanal, y una hecha al segundo año suele invertirse, no porque la artesanal haya empeorado sino porque solo entonces se ha pagado su factura completa.

⚠️
La concurrencia no definida no falla en desarrollo

El modo de fallo característico de un contenedor artesanal es un intercalado improbable entre dos intents: el usuario pulsa dos veces rápido, o una respuesta lenta llega después de que otra reducción haya cambiado el estado, y el resultado es un estado que no corresponde a ninguna secuencia legítima. Ese fallo casi nunca aparece en desarrollo, aparece en producción, es difícil de reproducir y su corrección suele consistir en introducir una serialización que es exactamente lo que la librería te habría dado el primer día.

Qué se puede recuperar y a qué precio

Ninguna de las cuatro pérdidas es irreversible, y conviene ponerles precio en vez de tratarlas como argumentos absolutos. La serialización de intents se recupera con una cola: un canal de entrada que un único bucle consume en orden, con lo que dos intents nunca se intercalan aunque lleguen a la vez.

private val entrada = Channel<I>(capacity = Channel.UNLIMITED)

init {
    viewModelScope.launch {
        for (intent in entrada) manejar(intent)   // un intent cada vez, en orden
    }
}

fun procesar(intent: I) { entrada.trySend(intent) }

Son ocho líneas y arreglan el fallo más caro de la lista, pero introducen su propia decisión: ahora un intent lento bloquea a los siguientes, y habrá que decidir cuáles pueden saltarse la cola. Es exactamente el tipo de pregunta que una librería ya respondió y documentó, y que aquí tendrás que responder tú y recordar dentro de un año.

El testing se recupera con una función auxiliar propia que monte la recolección, el despachador de prueba y la afirmación sobre la secuencia. Es media tarde de trabajo y otra media de pulirla, y a partir de ahí es tu equipo quien la mantiene cuando cambien las corrutinas.

💡
Escribe la utilidad de test antes que la segunda pantalla

Si eliges la vía artesanal, el orden importa: monta la utilidad de test antes de escribir la segunda pantalla, no después de la quinta. Escrita pronto, condiciona el diseño de todas las pantallas hacia lo comprobable; escrita tarde, tiene que acomodarse a cinco formas distintas que ya existen, y el resultado es una función con parámetros opcionales que nadie entiende. La utilidad de pruebas no es un accesorio del contenedor: es parte del contenedor.

El vocabulario compartido es lo único que no se recupera escribiendo código, porque no vive en el código sino fuera de él. Se mitiga, eso sí, con documentación breve en el propio fichero de la clase base y con un ejemplo canónico que sirva de plantilla. Es la mitigación más barata de la lista y la que casi nadie hace.

Puestas en fila, las recuperaciones dibujan una conclusión que no es la que se espera. Ninguna es difícil y todas son conocidas, de modo que un equipo competente puede reconstruir en unas semanas casi todo lo que la librería aportaba. El problema es que esas semanas no aparecen en ninguna planificación, se pagan en fragmentos dispersos a lo largo de meses y compiten siempre con trabajo de producto que parece más urgente. La consecuencia observable, repetida en muchos proyectos, es que la serialización se escribe después del primer fallo en producción y la utilidad de test no se escribe nunca.

Lo que compras a una libreria no son lineas, son decisiones ya verificadas

El error de razonamiento más extendido en esta discusión es contar líneas de código, porque las líneas son lo único que se ve. Reimplementar el contenedor cuesta cincuenta líneas y de ahí se concluye que la librería ahorra cincuenta líneas, lo cual convierte la decisión en trivial y equivocada. Lo que en realidad se compra es un conjunto de decisiones tomadas por gente que se encontró antes que tú con los casos raros —dos intents simultáneos, un efecto emitido antes de que exista consumidor, una reducción tras la cancelación, el orden entre estado y efecto— y que dejó cada una fijada, documentada y sostenida por una suite de tests que se ejecuta en cada versión. Ese conocimiento no es visible en el número de líneas porque no está en las líneas: está en las que no hizo falta escribir porque el problema ya se había pensado. Y hay un segundo elemento invisible, más caro todavía: una librería conocida convierte tu arquitectura en un asunto público, con nombres que alguien puede buscar y comportamientos que alguien puede leer sin ti. Tu clase base, por elegante que sea, convierte esa misma arquitectura en conocimiento tácito de tu equipo, y el conocimiento tácito tiene una propiedad muy incómoda: se evapora exactamente a la velocidad a la que la gente cambia de trabajo. Nada de esto significa que la librería siempre gane. Significa que el balance no se decide comparando lo que ambas opciones hacen cuando todo va bien, sino inventariando qué ocurre cuando algo va mal y cuánto de lo perdido tendrías que reconstruir tú, sabiendo que lo reconstruirás peor y más tarde.

⚔️ Haz el inventario de tu propio proyecto
  1. Escribe un test que afirme sobre la secuencia completa de estados de una pantalla con tu contenedor artesanal y cuenta las líneas de infraestructura frente a las de afirmación.
  2. Provoca un intercalado entre dos intents simultáneos y describe el estado incorrecto que obtienes. Diseña la serialización mínima que lo elimina.
  3. Enumera las utilidades de la librería que tu proyecto usa de verdad y estima, en horas, escribir cada una.
  4. Pide a alguien ajeno al proyecto que explique tu clase base leyéndola, y cronometra cuánto tarda en entender el orden entre reducción y efecto.
  5. Escribe el balance en dos columnas con números de tu proyecto, sin usar ni una sola vez la palabra elegante.