wandres.dev
EL LEGADO DE ELM · lo que sembró

The Composable Architecture: el eco de Elm en Swift

Si Redux fue la traducción apresurada y genial de la Arquitectura Elm a un lenguaje sin tipos, The Composable Architecture es la traducción tardía y meticulosa a un lenguaje que sí los tiene. Brandon Williams y Stephen Celis llevaban años derivando ideas de programación funcional en público cuando publicaron en 2020 una biblioteca que reponía justamente la pieza que Redux había dejado atrás: el efecto vuelve al tipo de retorno del reducer, deja de ser una llamada escondida y pasa a ser un valor que alguien recoge y ejecuta. Esta lección examina esa restitución y lo que Swift permitió construir encima de ella. Estudia por qué la mutación sobre un parámetro de entrada y salida es observacionalmente pura cuando el estado tiene semántica de valor, cómo el Store desempeña el mismo papel que el runtime de Elm y por qué cerrar el bucle reinyectando las acciones producidas por los efectos es lo que convierte una arquitectura en un sistema comprobable. El punto de llegada es el banco de pruebas exhaustivo, una idea que Elm no tenía y que solo resulta concebible cuando el mundo exterior, el tiempo incluido, se ha reducido a datos ordinarios.

⏱ 18 min

Hay una manera de leer la historia de The Composable Architecture que la vuelve inmediatamente inteligible: es la respuesta, quince años después y en otro lenguaje, a la pregunta que Redux dejó abierta. Si el reducer solo devuelve estado, dónde vive el efecto. La respuesta de Point-Free fue devolver el efecto a la firma, exactamente donde Elm lo tenía, y a partir de ahí construir todo lo que se puede construir cuando el mundo exterior ha quedado reducido a un valor. Lo interesante no es la copia, que los autores declaran sin rodeos, sino lo que hicieron con la herencia. Swift no es Elm: tiene mutación, tiene clases, tiene concurrencia estructurada, tiene macros y tiene un sistema de tipos con genéricos y protocolos que permite expresar composición como combinadores en vez de como cableado manual. La biblioteca aprovecha cada una de esas diferencias, y en dos puntos concretos supera a su modelo. El primero es la composición: donde Elm delega submódulos a mano y remapea mensajes uno por uno, TCA ofrece operadores que hacen esa delegación con una línea y verifican el encaje en tiempo de compilación. El segundo es el testing: donde Elm se conforma con que update sea una función pura que se puede llamar en un test, TCA construye un banco de pruebas que exige que el programador describa cada cambio de estado y cada acción recibida, y que falla si un efecto sigue vivo cuando el test termina. Esa exhaustividad no es una ocurrencia de biblioteca; es la consecuencia lógica de haber tomado en serio, hasta el final, que un efecto es un dato.

🎯 Al terminar esta lección sabrás
  • Explicar por qué la mutación sobre un estado con semántica de valor conserva la pureza observacional que exige la arquitectura.
  • Describir el ciclo completo del efecto como valor: producción, ejecución por el Store y reinyección de la acción resultante.
  • Justificar por qué un sistema de dependencias explícito es necesario en Swift y no lo es en Elm.
  • Leer un banco de pruebas exhaustivo como una transcripción total del comportamiento de una funcionalidad.

El efecto vuelve a la firma

La firma central de la biblioteca dice casi lo mismo que la de Elm, con dos diferencias que conviene no confundir con concesiones. La primera es que el estado llega como parámetro de entrada y salida y se modifica en el sitio, en lugar de reconstruirse y devolverse. La segunda es que lo que se devuelve es solo el efecto. Ambas son la misma decisión vista por dos lados, y descansan sobre una propiedad del lenguaje: en Swift, una estructura tiene semántica de valor, de modo que quien pasa el estado a la función conserva su copia intacta y la modificación no se propaga a nadie más. Desde fuera, llamar al reducer con un estado y una acción produce siempre el mismo estado resultante y el mismo efecto, que es exactamente la definición operativa de pureza que la arquitectura necesita. La mutación es local, acotada y sin testigos, y a cambio evita la copia profunda de árboles grandes en cada pulsación de tecla.

@Reducer
struct Contador {
  @ObservableState
  struct State: Equatable {
    var cuenta = 0
    var cargando = false
    var hecho: String?
  }

  enum Action {
    case incrementar
    case pedirDato
    case datoRecibido(Result<String, any Error>)
  }

  @Dependency(\.apiCliente) var api
  private enum CancelID { case peticion }

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .incrementar:
        state.cuenta += 1
        return .none

      case .pedirDato:
        state.cargando = true
        return .run { [n = state.cuenta] send in
          await send(.datoRecibido(Result { try await api.hecho(n) }))
        }
        .cancellable(id: CancelID.peticion)

      case let .datoRecibido(resultado):
        state.cargando = false
        state.hecho = try? resultado.get()
        return .none
      }
    }
  }
}

Obsérvese que .run no ejecuta nada al construirse: describe un trabajo asíncrono que recibirá un canal para devolver acciones. El reducer termina en microsegundos y entrega esa descripción; quien la ejecuta es otro. Y obsérvese también el .cancellable, que no tiene equivalente directo en Elm y que expresa una necesidad muy real de las interfaces móviles: si el usuario vuelve a pulsar, la petición anterior debe morir. Poder decir eso sobre un efecto solo es posible porque el efecto es un valor al que se le pueden colgar atributos, igual que a cualquier otro dato.

El runtime tiene nombre y se llama Store

En Elm, el runtime es invisible y anónimo: el programador declara main con uno de los constructores de Browser y a partir de ahí un sistema que no ve recoge los comandos, los ejecuta, escucha las suscripciones y devuelve los mensajes resultantes al update. TCA hace lo mismo con una diferencia importante: el runtime es un objeto que el programador crea, sostiene y puede inspeccionar, y se llama Store. Recibe el estado inicial y el reducer, expone un método para enviar acciones y se encarga del resto del bucle. Cuando el reducer devuelve un efecto, el Store lo lanza, y cuando ese efecto emite una acción, el Store la vuelve a meter por la misma puerta por la que entran las de la vista. Ese detalle es la clave de todo el diseño: no hay una vía privilegiada para el resultado de una operación asíncrona, no hay callbacks que escriban en el estado por su cuenta, no hay dos caminos. Hay un embudo y todo pasa por él.

flowchart LR
V[Vista SwiftUI] -->|Action| S[Store]
S --> R[Reducer sobre estado inout]
R -->|Effect como valor| S
S -->|ejecuta y reinyecta la accion| R
R --> M[Estado observable]
M --> V
D[Dependencias inyectadas] -.-> R
style S fill:#cba6f7,color:#11111b
style R fill:#a6e3a1,color:#11111b

Sobre esa base aparece lo que da nombre a la biblioteca. Elm compone módulos con estado escribiendo a mano la delegación: se llama al update del hijo, se envuelve su modelo en el del padre y se remapea su comando con Cmd.map. Es explícito, es legible y es tedioso. TCA convierte ese ritual en combinadores que reciben las rutas de acceso al estado y a la acción del hijo y generan la delegación completa, incluido el remapeo del efecto. El resultado se compone en un constructor de resultados, de modo que un reducer grande se lee como una lista de reducers pequeños con sus conexiones declaradas.

@Reducer
struct App {
  @ObservableState
  struct State: Equatable {
    var contador = Contador.State()
    var lista: IdentifiedArrayOf<Fila.State> = []
    @Presents var detalle: Detalle.State?
  }

  enum Action {
    case contador(Contador.Action)
    case lista(IdentifiedActionOf<Fila>)
    case detalle(PresentationAction<Detalle.Action>)
  }

  var body: some ReducerOf<Self> {
    Scope(state: \.contador, action: \.contador) { Contador() }

    Reduce { state, action in .none }  // aqui reacciona el padre a sus hijos
    .forEach(\.lista, action: \.lista) { Fila() }
    .ifLet(\.$detalle, action: \.detalle) { Detalle() }
  }
}
ℹ️
Los operadores hacen visible una jerarquía que en Elm hay que reconstruir leyendo

Scope conecta un hijo permanente, forEach conecta una colección identificada y ifLet conecta un hijo opcional cuya vida depende de que el estado exista. Los tres cancelan automáticamente los efectos del hijo cuando su estado desaparece, que es precisamente el tipo de fuga que en un sistema hecho a mano se olvida siempre. La jerarquía de funcionalidades deja de estar dispersa en llamadas y pasa a leerse de un vistazo en el cuerpo del reducer padre.

Dependencias y un test que lo cuenta todo

Aquí aparece una divergencia que no es de estilo sino de necesidad. En Elm, un Cmd es opaco y la única manera de producirlo es a través de las funciones que ofrece la plataforma; no hace falta un mecanismo de inyección porque el programador no puede llamar al reloj ni a la red desde dentro del update aunque quiera. En Swift sí puede: nada impide escribir una llamada directa en el cuerpo de .run. La biblioteca responde con un sistema de dependencias explícito, un contenedor de valores accesible por ruta de acceso, con implementación real, implementación de prueba e implementación previsualizada. La consecuencia práctica es que el tiempo, la aleatoriedad, la red, el almacenamiento y la geolocalización pasan a ser datos configurables, y un reloj de prueba permite avanzar horas en un test sin esperarlas.

@Test
func pideElDatoYLoGuarda() async {
  let store = TestStore(initialState: Contador.State()) {
    Contador()
  } withDependencies: {
    $0.apiCliente.hecho = { n in "El numero \(n) tiene historia" }
  }

  await store.send(.pedirDato) {
    $0.cargando = true
  }
  await store.receive(\.datoRecibido.success) {
    $0.cargando = false
    $0.hecho = "El numero 0 tiene historia"
  }
}

El contrato del banco de pruebas es más severo de lo que aparenta. Cada envío debe ir acompañado de una descripción completa del cambio de estado que provoca, y si el estado real difiere en un solo campo el test falla mostrando la diferencia. Cada acción que llegue desde un efecto debe ser esperada explícitamente, y una acción no esperada es un fallo. Si al terminar el test queda un efecto en vuelo, también es un fallo. Un test así deja de ser una muestra de comportamiento y pasa a ser una transcripción cerrada: describe todo lo que ocurre y afirma que no ocurre nada más.

🌱

Heredado de Elm

Estado único por funcionalidad, acción como unión exhaustiva, transición pura y efecto devuelto como descripción inerte.

⚙️

Aportado por Swift

Mutación local con semántica de valor, cancelación de efectos por identificador y composición mediante combinadores tipados.

🧪

Superado al original

El banco de pruebas exhaustivo y el control del tiempo mediante relojes sustituibles, que Elm no ofrece de serie.

⚖️

El precio

Tiempos de compilación altos, una curva de aprendizaje real y un acoplamiento fuerte a la biblioteca en toda la base de código.

Cuando el mundo exterior es un dato, el test deja de ser una muestra y se convierte en una descripción

Conviene detenerse en la magnitud de lo que ocurre cuando un efecto pasa a ser un valor, porque la formulación suena modesta y sus consecuencias no lo son. En la práctica habitual de la industria, un test de una pantalla comprueba unas cuantas afirmaciones sobre unas cuantas propiedades después de simular unas cuantas interacciones, y el resto del comportamiento queda fuera del alcance de la prueba, no por descuido sino porque no hay manera de hablar de él: las llamadas a la red suceden dentro de objetos que el test no ve, el tiempo transcurre de verdad y las tareas pendientes mueren cuando el proceso termina. Al reducir el efecto a un dato producido por una función pura y ejecutado por un runtime que el test puede sustituir, todo ese territorio inaccesible se vuelve enumerable. Se puede afirmar que tras una acción el estado quedó exactamente así y no de otra manera, que llegó exactamente esta respuesta y ninguna otra, que a los treinta segundos simulados ocurrió esto, y que al final no quedaba absolutamente nada corriendo. La diferencia entre comprobar algunas cosas y describirlas todas es cualitativa: en el primer régimen, un test que pasa significa que no se encontró un fallo entre los que se buscaron; en el segundo, significa que el comportamiento observable coincide con el descrito. Ese salto tiene un coste que sería deshonesto ocultar, porque la exhaustividad obliga a escribir tests largos y a mantenerlos cuando el estado cambia, y hay equipos para los que la cuenta no sale. Pero el principio subyacente sobrevive a cualquier biblioteca concreta y es la herencia más valiosa de Elm en este linaje: el grado en que un sistema es comprobable no depende de cuántos tests se escriban, sino de cuánto de su comportamiento se ha convertido previamente en datos que un test pueda nombrar.

⚔️ Reconstruir el eco a mano
  1. Escribe la firma de Reduce y la de update de Elm una debajo de otra y explica por qué la mutación sobre estado con semántica de valor no rompe la pureza observacional.
  2. Implementa un contador con petición asíncrona sin usar la biblioteca: una función pura que devuelva estado y descripción de efecto, y un ejecutor mínimo que reinyecte las acciones.
  3. Añade cancelación por identificador a tu ejecutor y comprueba qué estructura de datos necesitas para sostenerla.
  4. Traduce una funcionalidad tuya con dos hijos a Scope y ifLet, y anota cuántas líneas de cableado desaparecen respecto a la delegación manual.
  5. Escribe un test exhaustivo de esa funcionalidad y provoca a propósito los tres modos de fallo: estado distinto, acción no esperada y efecto en vuelo al terminar.
  6. Argumenta en contra de esta lección: sostén que la exhaustividad del banco de pruebas produce tests frágiles que se rompen con cada cambio de diseño y evalúa honestamente qué se perdería al renunciar a ella.