El problema: un reducer que toca el mundo
Antes de aprender la solución hay que sentir el problema en toda su gravedad. Un reducer que llama a Date, a UUID o a URLSession directamente deja de ser una función matemática y se convierte en un procedimiento cuyo resultado depende del instante, del azar y de la red: la misma acción sobre el mismo estado ya no produce el mismo estado siguiente. Esta lección diagnostica esa pérdida de determinismo, inventaría las impurezas que se cuelan en la lógica —reloj, azar, red, disco, entorno—, describe sus tres síntomas clínicos (tests frágiles, previews muertas, bugs irreproducibles) y explica por qué la inyección manual por inicializador, la respuesta refleja de la orientación a objetos, tampoco resuelve el problema en un árbol de features compuestas.
Todo lo que TCA promete —un test que es una especificación, una preview que dibuja sin backend, un bug que se reproduce copiando un estado y una acción— descansa sobre una única propiedad del reducer: que sea una función determinista. Dado un estado y una acción, el estado siguiente y el efecto devuelto tienen que ser siempre los mismos. Basta una línea para romperlo. Un Date() incrustado en la lógica, un UUID() recién generado, una llamada directa a URLSession.shared: cada una de esas expresiones introduce un argumento oculto que nadie declaró y que cambia entre ejecuciones. El reducer sigue compilando, sigue funcionando en el simulador y sigue pareciendo puro, pero ya no lo es, y la degradación no avisa: se manifiesta semanas después, cuando un test empieza a fallar los martes o cuando nadie consigue reproducir un informe de error. Esta lección se dedica entera al diagnóstico, porque quien no ha visto con precisión el problema tiende a leer la solución como ceremonia burocrática.
- Formular con exactitud qué significa que un reducer sea determinista y qué expresiones concretas lo destruyen.
- Inventariar las impurezas que se infiltran en la lógica: reloj, azar, red, disco, entorno y localización.
- Reconocer los tres síntomas clínicos del mundo cableado: tests frágiles, previews muertas y bugs irreproducibles.
- Explicar por qué la inyección manual por inicializador no escala en un árbol de features compuestas.
El determinismo es una ecuación que debe cerrar
Un reducer se declara con una firma que promete mucho más de lo que parece: reduce(into:action:) toma un estado por referencia mutable y una acción, y devuelve un Effect<Action>. Leída con rigor, esa firma es un contrato: todo lo que interviene en el resultado está delante de ti, en los parámetros. Nada más entra. Si eso se cumple, el reducer es una función matemática y disfrutas de la propiedad que Point-Free llama, con razón, el activo más valioso de la arquitectura: la ecuación cierra, y puedes razonar sobre ella con papel y lápiz.
// El contrato implícito de la firma
// estadoSiguiente, efecto = reduce(estadoAnterior, accion)
case .guardarPulsado:
state.items.append(Item(id: UUID(), creado: Date()))
return .none
Conviene subrayar qué clase de promesa es esa, porque el compilador de Swift no la verifica. Nada en el sistema de tipos impide que dentro de reduce llames a la red, escribas en disco o preguntes la hora: la firma sugiere pureza pero no la garantiza. Es un contrato social sostenido por la disciplina del equipo, no un contrato mecánico sostenido por el verificador, y esa asimetría entre lo que la firma promete y lo que el lenguaje comprueba es el hueco exacto por el que se cuela todo el problema de esta lección.
Ese fragmento incumple el contrato sin una sola advertencia del compilador. UUID() consulta un generador del sistema y Date() consulta el reloj del dispositivo: dos entradas que no aparecen en la firma. La función real que estás escribiendo no es la de arriba, sino una con dos parámetros invisibles —el instante y la semilla— que el lenguaje te permite ocultar. Y ocultar un parámetro no lo elimina: solo lo vuelve incontrolable.
La consecuencia es inmediata y aritméticamente inevitable. Ejecuta ese case dos veces sobre el mismo estado inicial y obtendrás dos estados distintos. No puede haber una aserción exacta sobre state.items, porque no existe un valor exacto que afirmar. El test más honesto que podrías escribir sería comprobar que el array creció en uno, lo que equivale a renunciar a testear qué se guardó.
// El mismo estado y la misma accion, dos resultados distintos
var a = State(); var b = State()
_ = reduce(into: &a, action: .guardarPulsado)
_ = reduce(into: &b, action: .guardarPulsado)
// a != b --> la funcion no es una funcion
Merece la pena insistir en que esto no es una limitación de las herramientas de test ni un problema de estilo: es una imposibilidad lógica. Ninguna herramienta, por sofisticada que sea, puede afirmar el valor exacto de una expresión que no tiene valor exacto. Cuando alguien dice que una feature no se puede testear, casi siempre está describiendo, sin saberlo, este hecho: su lógica no es una función, y las funciones son lo único que se sabe testear bien.
Conviene además notar que la impureza tiene grados de visibilidad. URLSession.shared grita lo que hace y todo el mundo lo reconoce como un acoplamiento con el exterior. Date() susurra, porque parece una construcción de valor inocente y no una llamada al sistema. Y Locale.current ni siquiera susurra: pasa por una constante. Las impurezas que más daño hacen no son las escandalosas —esas se aíslan pronto porque duelen pronto— sino las que se leen como código puro.
El inventario de impurezas
El reloj y el azar son los ejemplos canónicos, pero la lista de puertas por las que el mundo se cuela en la lógica es más larga de lo que se suele admitir. Conviene tenerla presente, porque el trabajo de descablear una base de código consiste literalmente en recorrerla, una entrada tras otra, hasta que no quede ninguna sin declarar.
| Impureza | Cómo aparece en el código | Qué propiedad destruye |
|---|---|---|
| Reloj | Date(), Task.sleep, Timer |
Repetibilidad: el resultado depende de cuándo corres |
| Azar | UUID(), Int.random, shuffled |
Exactitud: no hay valor que afirmar |
| Red | URLSession.shared, SDK de terceros |
Aislamiento: el test depende de un servidor |
| Disco y base de datos | FileManager, UserDefaults |
Idempotencia: el orden de los tests importa |
| Entorno del dispositivo | idioma | zona horaria | tamaño de fuente | Portabilidad: falla en otra máquina |
Repara en que la columna de la derecha no dice dificulta el testing en ninguna fila. Dice qué propiedad matemática se pierde, y esa es la formulación correcta: el testing es solo el primer sitio donde se nota la pérdida. La repetibilidad también es lo que permite reproducir un informe de error; la exactitud es lo que permite comparar dos estados; el aislamiento es lo que permite ejecutar tests en paralelo. Cablear el mundo no rompe una herramienta, rompe un conjunto de garantías del que la herramienta dependía.
La última fila merece atención porque casi nadie la considera una dependencia. Un reducer que formatea una fecha para mostrarla depende de la configuración regional del dispositivo; el test pasa en tu Mac en español y falla en el runner de integración continua configurado en inglés. Lo mismo ocurre con la zona horaria, con el calendario y con los ajustes de accesibilidad. Todo aquello que el sistema operativo decide por ti y tu lógica consulta sin declarar es una impureza, aunque no huela a red ni a reloj.
Hay además una categoría especialmente traicionera: las impurezas de escritura. Un analytics.track o un logger.log dentro del reducer no cambian el estado y por eso parecen inofensivos, pero convierten un test en un emisor real de eventos hacia un servidor de producción. La pureza no se rompe solo al leer el mundo; también al escribirlo.
Una lectura impura produce un test que falla, y un test que falla se arregla. Una escritura impura produce un test que pasa mientras envía eventos reales a la analítica de producción, escribe en la base de datos compartida del equipo o dispara una notificación a un usuario real. El fallo no aparece en la consola: aparece en el panel de métricas del lunes, con miles de eventos fantasma generados por la integración continua. Por eso conviene inventariar también lo que tu lógica escribe en el mundo, no solo lo que lee.
Los tres síntomas clínicos
El mundo cableado no produce un error de compilación sino un cuadro de síntomas, y reconocerlos es lo que permite diagnosticar una base de código ajena en minutos. Ninguno de los tres se presenta diciendo su nombre: llegan disfrazados de otra cosa —de aquí los tests no aportan, de las previews de SwiftUI van mal, de ese bug no se puede reproducir— y esa es justamente la razón por la que persisten tanto tiempo sin que nadie los trate.
Tests frágiles o inexistentes
Los tests que dependen del reloj fallan al cambiar el horario de verano; los que dependen de la red son lentos e intermitentes. El equipo acaba marcándolos como omitidos y la suite se degrada hasta ser decorativa.
Previews que no dibujan
El canvas de SwiftUI intenta ejecutar la lógica real, que pide datos a un backend inexistente en tiempo de diseño. La preview se queda en blanco o casca, y el equipo deja de usarla.
Bugs irreproducibles
El informe dice que ocurrió a las 23:59 del último día del mes con la sesión caducada. Sin control del entorno no puedes recrear esa conjunción, así que el bug se cierra como no reproducible y vuelve.
El tercero es el más caro de todos, aunque el que menos se contabiliza. Un bug irreproducible no se arregla: se mitiga a ciegas, con código defensivo que nadie sabe si hace falta, y vuelve. Cada vuelta consume una investigación completa, y la investigación no converge porque le falta el ingrediente básico del método experimental, que es poder repetir el experimento. Controlar el entorno es lo que devuelve esa capacidad: si el instante, el azar y la respuesta del servidor son argumentos, entonces la conjunción exacta que produjo el fallo se puede recrear escribiéndola.
Los tres síntomas comparten etiología y por eso aparecen juntos. Un equipo que sufre uno sufre los tres, aunque solo se queje del que más duele en ese trimestre. Y los tres tienen la misma dinámica perversa: el coste de cablear el mundo es cero hoy —escribes Date() y sigues— y creciente después, mientras que el coste de inyectarlo es unas líneas hoy y cero para siempre. Esa asimetría explica por qué casi todas las bases de código nacen cableadas: la decisión barata se toma sola.
Hay un cuarto síntoma, más difícil de atribuir porque se manifiesta como una opinión y no como un fallo: la lentitud percibida de la suite. Cuando cada test que toca la red espera respuestas reales y cada test que toca el tiempo duerme de verdad, la suite pasa de segundos a minutos. Una suite de minutos deja de ejecutarse en cada guardado, luego deja de ejecutarse antes de cada envío, y acaba ejecutándose solo en la integración continua, donde sus fallos llegan tarde y sin contexto. El mundo cableado no solo produce tests malos: produce tests que nadie quiere correr, y un test que no se corre protege exactamente lo mismo que un test que no existe.
Por qué el inicializador con parámetros no basta
Antes de seguir, conviene descartar la cura falsa: prohibir el reloj y la red. No es una opción, porque una app real necesita saber la hora, generar identificadores y hablar con un servidor. El objetivo nunca fue eliminar el mundo del programa, sino cambiar quién lo provee. Toda la solución cabe en esa inversión, y la pregunta que queda es cómo implementarla sin arruinar la composición.
La respuesta refleja de cualquier programador con formación en orientación a objetos es evidente: pasa las dependencias por el inicializador. Y funciona, en una feature aislada. El problema aparece cuando esa feature vive dentro de un árbol.
// Inyeccion manual: correcta en teoria, insostenible en la practica
struct AppFeature {
let api: ClienteAPI
let reloj: any Clock<Duration>
let uuid: () -> UUID
// y ahora hay que pasarlas a cada hijo, y el hijo a cada nieto...
}
// Cinco niveles mas abajo, la hoja que de verdad las usa
struct DetalleArticulo {
let api: ClienteAPI // le llego atravesando cuatro reducers
let reloj: any Clock<Duration>
}
Hay que reconocerle lo que sí resuelve: la sustituibilidad. Con esas tres propiedades declaradas, AppFeature ya puede recibir un reloj de test o un cliente falso, y en eso es estrictamente superior al mundo cableado. El problema no es la idea sino su coste de distribución, que crece con la profundidad del árbol.
Si una pantalla enterrada a cinco niveles de profundidad necesita el reloj, ese reloj tiene que atravesar los cinco inicializadores intermedios, y cada uno de esos cinco reducers pasa a declarar una dependencia que no usa y de la que solo es transportista. Es el problema que en el mundo de React se llama prop drilling, y sus efectos son destructivos para la composición: añadir una dependencia a una hoja obliga a modificar toda la rama, cualquier reordenación del árbol rompe las firmas, y los reducers intermedios dejan de ser reutilizables porque arrastran un equipaje ajeno.
Hay una segunda objeción, más sutil y más grave. La inyección manual es opcional: nada obliga a un compañero a usarla, y basta un Date() escrito con prisa dentro de un case para reintroducir la impureza sin que nadie lo note en la revisión. Un mecanismo que depende de la disciplina permanente de todo el equipo no es un mecanismo, es una esperanza. Lo que hace falta es un sistema donde acceder al mundo por el canal correcto sea más cómodo que cablearlo, porque solo entonces la opción correcta gana por ergonomía y no por vigilancia.
Y hay una tercera, económica. La inyección manual impone su coste a terceros: quien declara una dependencia nueva en una hoja obliga a cuatro compañeros a modificar cuatro archivos que no son suyos. En cualquier equipo bajo presión de calendario, ese coste externalizado se traduce en un incentivo perfectamente racional para no declarar nada y tirar del atajo cableado. No es indisciplina: es el diseño premiando el comportamiento equivocado. Un sistema de dependencias solo sobrevive a la realidad si declarar es barato para quien declara y no cuesta nada a nadie más.
Reúne los requisitos que acaban de aparecer, porque no son negociables y describen por adelantado lo que las cuatro lecciones siguientes van a construir: hace falta un mecanismo que permita sustituir cualquier trozo del entorno, que no obligue a los intermediarios a transportar nada, que tenga siempre un valor por defecto para que nada falle por falta de configuración, que acote sus sustituciones a un ámbito para que un test no contamine al siguiente, y que resulte más cómodo que la alternativa cableada. Cinco condiciones. El sistema @Dependency es, exactamente, la respuesta a esas cinco.
flowchart TD R[Reducer con Date y UUID incrustados] --> P1[Entradas ocultas no declaradas] P1 --> S1[Tests fragiles o eliminados] P1 --> S2[Previews que no dibujan] P1 --> S3[Bugs irreproducibles] R --> M[Intento de arreglo pasando todo por el init] M --> D[Dependencias atraviesan features que no las usan] style R fill:#f38ba8,color:#11111b style P1 fill:#f9e2af,color:#11111b
Merece la pena detenerse en la naturaleza exacta del daño, porque no es técnico sino epistemológico. Un reducer determinista es un objeto sobre el que puedes saber cosas: puedes afirmar con certeza qué hace ante cada entrada, y esa certeza es transferible —otro programador la obtiene leyendo el test, no reconstruyendo tu razonamiento—. Un reducer cableado al mundo solo admite creencias: crees que funciona porque lo probaste a mano el jueves. La diferencia entre saber y creer es toda la diferencia entre una base de código sobre la que se puede refactorizar sin miedo y una en la que cada cambio es una apuesta. Repara además en que la impureza es contagiosa hacia arriba pero no hacia abajo: un reducer padre que compone un hijo impuro es automáticamente impuro, mientras que ningún hijo puro puede purificar a su padre. Por eso el mundo cableado no se queda contenido en la función que lo introdujo; asciende por el árbol de composición hasta contaminar la raíz, y la app entera se vuelve intestable por culpa de una hoja. Y observa la trampa temporal: el Date() que escribiste hoy no produce ningún dolor hoy —esa es exactamente su peligrosidad—, sino dentro de seis meses, cuando alguien intente escribir el primer test de esa feature y descubra que no se puede, y entonces el coste de arreglarlo ya no es una línea sino una refactorización que nadie va a autorizar. La disciplina de no cablear el mundo no es purismo funcional: es la decisión de pagar hoy, en unas líneas, un seguro contra la pérdida futura de la capacidad de saber qué hace tu propio código.
- Busca en tu código todas las apariciones de
Date(),UUID(),Int.random,Task.sleepyURLSessionque estén dentro de un reducer o del cuerpo de un efecto. Anota cuántas hay y en qué features. - Elige la ocurrencia que te parezca más inocente y escribe un test que afirme el estado exacto resultante. Documenta con precisión por qué no puedes escribirlo.
- Localiza una consulta al entorno del dispositivo —idioma, zona horaria, calendario— dentro de tu lógica y razona qué pasaría en un runner de integración continua configurado en otro idioma.
- Intenta arreglar una de esas features pasando la dependencia por el inicializador. Cuenta cuántos archivos intermedios has tenido que tocar para llevarla desde la raíz hasta la hoja.
- Escribe en una frase la propiedad concreta que se pierde en cada caso —repetibilidad, exactitud, aislamiento, portabilidad— y ordena las ocurrencias por gravedad. Esa lista es tu plan de trabajo para las cuatro lecciones siguientes.