Funciones puras y extensiones: escribir y organizar el reducer
El reducer es el corazon logico de MVI y su unica exigencia seria es la pureza: dado el mismo estado y la misma intencion debe producir siempre el mismo estado siguiente, sin leer el reloj, sin tocar la red y sin dejar rastro. Esta leccion define la pureza con precision -determinismo mas ausencia de efectos observables-, muestra por que esa propiedad hace que el reducer sea testeable sin mocks ni corrutinas, y aterriza la firma canonica que va del par estado e intencion al estado siguiente. Despues aborda el problema practico de que un reducer honesto crece: se organiza con extension functions sobre el estado, que dan sintaxis de metodo sin herencia ni acoplamiento, y se traza con cuidado la frontera entre el nucleo puro y el borde impuro donde viven la red, el reloj y los efectos.
Todas las piezas anteriores del nivel convergen aqui. Tienes un estado que es un valor inmutable, un vocabulario cerrado de intenciones y un sistema de tipos que impide representar lo invalido; falta la funcion que une las tres cosas y decide qué estado sigue a cual. Esa funcion —el reducer— es el unico lugar de la aplicacion donde vive la logica de la pantalla, y por eso merece la unica exigencia seria de toda la arquitectura: debe ser pura. La pureza no es aqui una preferencia estetica heredada de la programacion funcional, sino la condicion tecnica de la que dependen el determinismo del render, la trivialidad de las pruebas, la reproducibilidad de un bug a partir de su secuencia de intenciones y la posibilidad misma de razonar sobre la pantalla leyendo una sola funcion en lugar de reconstruir mentalmente una historia de mutaciones.
- Definir la pureza con precision y saber reconocer las impurezas que se cuelan sin avisar.
- Escribir el reducer con la firma canonica que va del estado y la intencion al estado siguiente.
- Organizar un reducer que crece usando extension functions sobre el estado.
- Trazar la frontera entre el nucleo puro y el borde impuro donde viven red, reloj y efectos.
Pureza: determinismo y ausencia de rastro
Una funcion es pura cuando cumple dos condiciones a la vez. La primera es el determinismo: con las mismas entradas devuelve siempre la misma salida, hoy y dentro de un año, en tu maquina y en la de integracion continua. La segunda es la ausencia de efectos observables: no muta nada fuera de si misma, no escribe en disco, no emite eventos, no registra en un log. Cumplidas ambas, la llamada a la funcion es intercambiable por su resultado, y esa propiedad —la transparencia referencial— es la que permite razonar sobre el codigo por sustitucion, como se razona sobre una ecuacion.
Las impurezas rara vez entran por la puerta grande; entran disfrazadas de detalle inofensivo.
// IMPURA: lee el reloj, genera aleatoriedad y escribe fuera
fun reducir(state: TareasState, intent: TareasIntent): TareasState {
val nueva = Tarea(id = UUID.randomUUID().toString(), creada = System.now())
contadorGlobal++
return state.copy(tareas = state.tareas + nueva)
}
Tres delitos en cuatro lineas, y los tres son cotidianos. Conviene añadir un cuarto que casi nadie considera impureza: leer una configuracion global, una preferencia o una constante que alguien puede cambiar en caliente. Es determinismo aparente —funciona en todas las pruebas— hasta el dia en que la configuracion cambia y la misma entrada produce salidas distintas sin que nada en la firma lo anticipe. El identificador aleatorio y la marca de tiempo hacen que dos ejecuciones identicas produzcan estados distintos, con lo cual ninguna prueba puede afirmar una igualdad sin trucos. El contador global es un efecto: la funcion deja rastro en el mundo. La cura no es prohibir el tiempo ni el azar, que la aplicacion evidentemente necesita, sino desplazarlos: se generan fuera, se meten dentro de la intencion como datos, y el reducer los recibe ya resueltos. El azar y el reloj se convierten en entradas, y el reducer vuelve a ser una funcion de sus argumentos.
La firma canonica del reducer
Con esa disciplina, el reducer adquiere una forma que es la misma en Elm, en Redux, en The Composable Architecture y en cualquier variante de MVI: toma el estado actual y la intencion, y devuelve el estado siguiente. Escrito como tipo, es (Estado, Intent) -> Estado, y su implementacion natural en Kotlin es un when exhaustivo sobre el tipo sellado de intenciones.
fun TareasState.reducir(intent: TareasIntent): TareasState = when (intent) {
is TareasIntent.TextoCambiado ->
copy(borrador = intent.texto)
is TareasIntent.Anadir ->
if (borrador.isBlank()) this
else copy(borrador = "", tareas = tareas + Tarea(intent.id, borrador))
is TareasIntent.TareaMarcada ->
copy(tareas = tareas.map { if (it.id == intent.id) it.alternar() else it })
}
Hay tres cosas dignas de atencion. La primera es que no falta el else por descuido: su ausencia es lo que hace que añadir una intencion nueva rompa la compilacion en este punto exacto, que es donde uno quiere enterarse. La segunda es la rama que devuelve this: cuando una intencion no debe cambiar nada, el reducer devuelve el mismo estado, y como es una funcion total, no existe la categoria de “intenciones que se ignoran en silencio sin que nadie lo haya escrito”. La tercera, y la mas importante, es que el identificador llega dentro de la intencion, no se genera aqui: esa unica decision es lo que mantiene la funcion determinista y por tanto testeable con una afirmacion de igualdad y nada mas.
Fijate tambien en que el reducer esta escrito como expresion y no como bloque con retornos. No es una preferencia caligrafica: una funcion de una sola expresion no tiene lugar donde esconder un efecto ni sitio donde colar una variable intermedia mutable, de modo que la forma misma empuja hacia la pureza. Y es una funcion total: acepta cualquier combinacion de estado e intencion sin lanzar excepciones, porque una intencion que no aplica en el estado actual no es un error del programa sino un caso previsto que devuelve el estado sin cambios.
// Un test de reducer no necesita mocks, corrutinas ni esperas
val siguiente = TareasState(borrador = "leer").reducir(TareasIntent.Anadir(id = "1"))
assertEquals(TareasState(tareas = listOf(Tarea("1", "leer"))), siguiente)
flowchart LR N[Nucleo puro reducer y transformaciones de estado] --- F[Frontera de mapeo] F --- B[Borde impuro red reloj azar y almacenamiento] I[Intent con datos ya resueltos] --> N N --> S[Estado siguiente] N --> E[Descripcion de efecto a ejecutar fuera] style N fill:#a6e3a1,color:#11111b style B fill:#f38ba8,color:#11111b style E fill:#f9e2af,color:#11111b
Extensiones: organizar el reducer sin herencia
Un reducer honesto crece. La pantalla de un carrito real tiene quince intenciones y varias de ellas requieren diez lineas de transformacion, de modo que el when monolitico se vuelve ilegible mucho antes de estar terminado. La respuesta idiomatica en Kotlin no es la herencia ni una clase auxiliar con dependencias, sino la extension function: una funcion declarada fuera del tipo que se invoca con sintaxis de metodo y que se resuelve estaticamente, sin tocar la clase original ni participar en polimorfismo.
// Cada transformacion, con nombre de dominio y firma de estado a estado
private fun TareasState.conBorrador(texto: String): TareasState =
copy(borrador = texto)
private fun TareasState.conTareaAnadida(id: String): TareasState =
if (borrador.isBlank()) this
else copy(borrador = "", tareas = tareas + Tarea(id, borrador))
fun TareasState.reducir(intent: TareasIntent): TareasState = when (intent) {
is TareasIntent.TextoCambiado -> conBorrador(intent.texto)
is TareasIntent.Anadir -> conTareaAnadida(intent.id)
is TareasIntent.TareaMarcada -> conTareaAlternada(intent.id)
}
El when recupera su papel de indice: se lee de un vistazo y dice qué intencion lleva a qué transformacion, mientras cada transformacion se estudia por separado y se prueba por separado. Como cada extension conserva la firma de estado a estado, todas son puras, todas son componibles y ninguna puede saltarse la inmutabilidad. Un detalle util del lenguaje: al declararlas private en el mismo fichero, la extension no contamina el autocompletado del resto del proyecto, con lo que obtienes modularidad de nombres sin crear ni una clase.
Conviene saber tambien qué no hace una extension. No accede a miembros privados del tipo, porque no esta dentro de el. No se sobrescribe en subtipos, porque se despacha por el tipo estatico de la expresion. Y si su nombre colisiona con un metodo real de la clase, gana el metodo. Nada de eso estorba en un reducer sobre data class finales, pero explica por qué la extension es una herramienta de organizacion y no un sustituto de la herencia.
La ventaja de diseño, y no solo de orden, es que la extension invierte la dependencia. El estado no sabe nada de las transformaciones que lo afectan: es un tipo de datos desnudo, sin metodos y sin ninguna razon para cambiar cuando cambia la logica. Las transformaciones, en cambio, viven junto al reducer que las usa y pueden multiplicarse por caracteristica sin tocar la definicion del estado. Eso permite que un mismo UiState se transforme desde varios ficheros —uno por area funcional— sin que ninguno se entere de los demas, algo que con metodos dentro de la clase obligaria a concentrarlo todo en un unico fichero creciente.
Para casos en que la transformacion necesita mas de un dato de contexto, Kotlin ofrece ademas la extension con receptor multiple y las funciones con receptor en un lambda, la construccion que sostiene los DSL del lenguaje. En un reducer conviene usarlas con moderacion: cada receptor implicito que añades ahorra unas letras y cuesta un poco de claridad sobre de dónde sale cada nombre, y el reducer es justamente el sitio donde quieres que todo lo que influye en la decision este a la vista.
La frontera: qué queda dentro y qué se va al borde
La pureza obliga a decidir dónde ocurre lo que no puede ser puro, y en MVI esa decision tiene una respuesta estable. El reducer no llama a la red ni al almacenamiento: como mucho, describe que hay que hacerlo, y quien ejecuta esa descripcion es el orquestador —el intent de Orbit, el efecto de otras arquitecturas— que despues devuelve el resultado al ciclo convertido en una intencion nueva.
// El borde: aqui viven la suspension, el reloj y el azar
fun cargar() = intent {
reduce { state.reducir(TareasIntent.CargaIniciada) }
val resultado = repo.obtener() // impuro y aislado
reduce { state.reducir(TareasIntent.CargaTerminada(resultado)) }
}
Lee la estructura: el orquestador no decide nada, solo secuencia. Todas las decisiones —qué estado corresponde a una carga iniciada, qué hacer si el resultado es un fallo, si conviene conservar los datos antiguos mientras se reintenta— estan dentro de reducir, que es puro y se prueba con valores. Cuando esa separacion se respeta, el numero de pruebas que necesitan infraestructura tiende a ser muy pequeño, y las que quedan verifican fontaneria en lugar de logica.
El sintoma de que la frontera se ha roto es facil de detectar: aparece un condicional dentro del bloque que hace la llamada asincrona. En cuanto el orquestador decide, esa decision deja de ser testeable sin montar la infraestructura entera y deja de ser legible junto a las demas. La correccion casi siempre consiste en mover la condicion al reducer y hacer que el resultado impuro entre como dato de una intencion.
Tres sintomas bastan para saber que una funcion ha cruzado la frontera sin permiso, y conviene tenerlos a mano al revisar codigo ajeno.
Necesita un mock para probarse
Si escribir el test exige montar un doble de prueba, la funcion depende de algo que no aparece en sus argumentos. Eso es una entrada oculta, y convertirla en parametro suele reducir el test a dos lineas.
El resultado depende de cuando se ejecuta
Relojes, temporizadores, identificadores aleatorios y contadores incrementales. Todos rompen el determinismo y todos se arreglan igual: se resuelven fuera y entran ya calculados dentro de la intencion.
Deja rastro fuera de su valor de retorno
Escribir en un log, emitir un evento, actualizar una cache o guardar en preferencias. Aunque el valor devuelto sea correcto, la funcion ya no es sustituible por su resultado y el razonamiento por sustitucion se rompe.
Compara TareasState.anadir(id) con TareasState.conTareaAnadida(id). La primera suena a orden y sugiere que algo se modifica; la segunda describe el valor que sale, que es literalmente lo que la funcion hace. Ese matiz mantiene alineado el lenguaje con la semantica y evita el desliz mental de pensar que el estado se edita. La misma logica se aplica al when: cuando las ramas se leen como una tabla que va de intenciones a estados resultantes, revisar el reducer consiste en verificar una correspondencia, no en simular una ejecucion paso a paso. Y una funcion que hay que simular para entenderla es una funcion que costara mantener.
Existe una objecion razonable a todo esto y conviene responderla en serio: una aplicacion es efectos y nada mas. Sin red no hay datos, sin almacenamiento no hay sesion, sin reloj no hay caducidad. ¿De qué sirve una arquitectura que exige pureza si el noventa por ciento del valor de un programa esta precisamente en lo que ensucia? La respuesta es que la pureza nunca prometio eliminar los efectos, sino relocalizarlos, y esa relocalizacion es la que produce todo el beneficio. La forma que toma es siempre la misma, se llame como se llame en cada ecosistema: un nucleo puro y grande donde vive la logica de decision, rodeado de un borde impuro y fino donde vive la ejecucion. Dentro del nucleo, cada funcion es una correspondencia entre valores; se lee sin contexto, se prueba con dos lineas, se razona por sustitucion y su comportamiento no depende de nada que no aparezca en su firma. Fuera, en el borde, ocurre lo inevitable: se llama a la red, se lee el reloj, se genera el identificador, se escribe en disco. La clave es que el borde no decide, solo ejecuta y devuelve lo ocurrido como un dato que vuelve a entrar al nucleo convertido en intencion. Y ahi esta la asimetria decisiva del diseño: la parte dificil de razonar es voluminosa pero pura, y la parte imposible de razonar es impura pero minima y aburrida. Cuando esa proporcion se respeta, la mayor parte de tus pruebas dejan de necesitar mocks, esperas y escenarios de temporizacion, porque prueban el nucleo con valores; y las pocas que si necesitan infraestructura son pocas precisamente porque el borde no contiene logica que merezca probarse. Cuando la proporcion se invierte —cuando las decisiones se toman dentro de las funciones que hacen la llamada de red— cada prueba se convierte en un montaje, cada bug en una investigacion sobre el orden de los eventos, y el reducer, que deberia ser el sitio donde se entiende la pantalla entera, se queda vacio de significado mientras la logica se dispersa por lugares que nadie puede leer completos.
- Recorre un reducer tuyo y señala cada lectura del reloj, cada generacion aleatoria y cada acceso a estado global; para cada uno decide qué dato deberia llegar dentro de la intencion.
- Reescribe la firma de ese reducer como funcion del estado y la intencion al estado siguiente, y comprueba que puedes probar tres casos con afirmaciones de igualdad y sin un solo mock.
- Extrae cada rama de mas de cinco lineas a una extension privada nombrada por el estado resultante, y verifica que el
whenqueda legible como un indice. - Elimina cualquier
elsedelwhen, añade una intencion nueva y observa que el compilador te lleva exactamente al punto que falta actualizar. - Dibuja la frontera de tu pantalla: escribe qué queda en el nucleo puro, qué queda en el borde impuro y qué dato cruza en cada direccion; justifica que el borde no contenga ninguna decision.