wandres.dev
ARQUITECTURA DE APPS · patrones y capas

Arquitecturas formales: cuándo dar el salto a `TCA`

Una arquitectura unidireccional no es un patrón más elegante: es un cambio de régimen donde la mutación deja de ser una llamada y pasa a ser un dato inspeccionable. Esta lección explica qué compras exactamente con `TCA` o con cualquier arquitectura de acciones y reducers, qué señales concretas justifican el salto, cuánto cuesta la ceremonia y cómo se migra por partes sin reescribir la app entera.

⏱ 20 min

Llega un momento en la vida de una app en que el problema deja de ser dónde poner la lógica y pasa a ser cómo saber qué ocurrió. El equipo recibe un informe de fallo que nadie reproduce, una pantalla queda en un estado que sobre el papel era imposible, dos efectos asíncronos se pisan y el resultado depende del orden de llegada. En ese punto, ninguna cantidad de disciplina sobre modelos de vista ayuda, porque el límite no está en el reparto de responsabilidades sino en algo anterior: en una app donde cambiar el estado es llamar a un método, el conjunto de cambios posibles no existe como objeto y por tanto no se puede enumerar, ni grabar, ni reproducir, ni verificar. Las arquitecturas unidireccionales atacan exactamente ahí, y cobran por ello un precio que conviene mirar de frente antes de firmar.

🎯 Al terminar esta lección sabrás
  • Entender qué cambia cuando la mutación se representa como dato en lugar de como llamada.
  • Enumerar las señales objetivas que justifican adoptar una arquitectura formal en una app iOS.
  • Medir el coste real de la ceremonia: líneas, tiempos de compilación y curva de entrada del equipo.
  • Diseñar una migración por islas que conviva con MVVM y con el patrón MV sin reescribir la app.

Qué compras al cosificar la mutación

En TCA una pantalla se describe con tres piezas: un estado que es un valor, un conjunto cerrado de acciones y un reducer que, dada una acción y el estado actual, produce el estado siguiente y opcionalmente un efecto. El giro decisivo es que la acción es un valor enumerado. Todo lo que puede pasarle a la pantalla está escrito en un tipo, y el compilador exige que lo trates entero.

@Reducer
struct Editor {
  @ObservableState
  struct State: Equatable {
    var titulo = ""
    var guardando = false
    var error: String?
  }

  enum Action: Equatable {
    case tituloCambio(String)
    case guardarTocado
    case guardadoTermino(Result<Void, EquatableError>)
  }

  @Dependency(\.notas) var notas

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case let .tituloCambio(texto):
        state.titulo = texto
        return .none
      case .guardarTocado:
        state.guardando = true
        return .run { [titulo = state.titulo] send in
          await send(.guardadoTermino(Result { try await notas.guardar(titulo) }))
        }
      case .guardadoTermino:
        state.guardando = false
        return .none
      }
    }
  }
}

De esa forma se siguen cuatro consecuencias que ninguna cantidad de buenas costumbres consigue en el modelo de llamadas: la lista de mutaciones posibles es finita y visible, cada una es serializable y por tanto grabable, el efecto se declara como valor y el runtime puede cancelarlo, y el test puede afirmar la transición completa en lugar del resultado final.

La cuarta merece verse escrita, porque es la que cambia el trabajo diario de un equipo.

@Test func guardarActivaYDesactivaElIndicador() async {
  let store = await TestStore(initialState: Editor.State()) { Editor() }
  await store.send(.tituloCambio("Nota")) { $0.titulo = "Nota" }
  await store.send(.guardarTocado) { $0.guardando = true }
  await store.receive(\.guardadoTermino) { $0.guardando = false }
}

Lo decisivo no es lo que la prueba afirma sino lo que prohíbe: si el reducer modifica cualquier otro campo del estado, el test falla aunque nadie lo hubiera previsto. La aserción es exhaustiva por omisión, y ese detalle convierte cada prueba en una barrera contra el efecto secundario que nadie recordaba.

🏛️

La acción como dato

Todo lo que puede ocurrir vive en un enum. El switch exhaustivo convierte cada caso nuevo en un error de compilación.

🎞️

Historia reproducible

Una secuencia de acciones reconstruye cualquier estado. El informe de fallo deja de ser una narración y pasa a ser un dato.

⏹️

Efectos gobernados

El efecto se devuelve, se identifica y se cancela. No hay tareas huérfanas sobreviviendo a la pantalla.

🔬

Prueba exhaustiva

El test declara el estado esperado tras cada acción y falla si algo más cambió. La omisión también se detecta.

El recorrido completo de esta arquitectura, con composición de reducers, dependencias, navegación y colecciones identificadas, tiene su propio itinerario en el track de TCA, que es donde conviene profundizar una vez tomada la decisión.

Las señales que justifican el salto

La decisión no debe tomarse por elegancia. Estas son las señales que sí la sostienen, y todas son observables sin discutir de filosofía.

Señal en tu proyecto Por qué apunta a arquitectura formal
Fallos que nadie reproduce necesitas la secuencia de acciones, no la foto final del estado
Estados que sobre el papel eran imposibles necesitas que el tipo prohíba lo que la disciplina no logró
Efectos asíncronos que se pisan entre sí necesitas cancelación e identidad de efecto en el propio runtime
Flujos largos con navegación condicional necesitas que la ruta sea estado y no una pila de eventos de interfaz
Rotación alta en el equipo necesitas que el patrón sea explícito y no tradición oral
Pruebas que solo comprueban el final necesitas afirmar la transición, incluido lo que no debía cambiar

Con tres o más señales marcadas, el salto se paga solo. Con una sola, casi siempre sale más barato arreglar esa señal concreta que cambiar de régimen.

flowchart TD
A[Sintoma en produccion] --> Q1{el estado imposible aparece pese a la disciplina}
Q1 -->|no| MV[Sigue con modelo observable y capas simples]
Q1 -->|si| Q2{necesitas reproducir la secuencia exacta}
Q2 -->|no| MAQ[Extrae una maquina de estados en esa pantalla]
Q2 -->|si| TCA[Arquitectura unidireccional con acciones y reducers]
style MV fill:#a6e3a1,color:#11111b
style TCA fill:#89b4fa,color:#11111b

El precio de la ceremonia

Ser honesto con el coste es parte del argumento. Una pantalla trivial que en el patrón MV ocupa cuarenta líneas puede irse a ciento veinte, repartidas entre el estado, el enumerado de acciones, el reducer y el cableado con la vista. Los tiempos de compilación suben porque los reducers componen tipos genéricos anidados y el inferidor de Swift sufre. La curva de entrada es real: una persona con experiencia en SwiftUI pero sin exposición previa a reducers necesita semanas, no días, para dejar de escribir acciones que son en realidad setters disfrazados. Y hay un coste menos citado y más caro: la dependencia de un paquete externo cuyas versiones marcan el ritmo de tus migraciones.

⚠️
El síntoma de la adopción prematura

Si tu enumerado de acciones se parece a la lista de propiedades del estado, con un caso por cada campo y nada más, no has adoptado la arquitectura: has escrito un modelo de vista con tres veces más ceremonia. Las acciones deben nombrar sucesos del mundo, como que el usuario tocó guardar o que la respuesta llegó, no operaciones de asignación.

Migrar por islas, no por decreto

La adopción sensata es local. TCA convive sin fricción con pantallas en MV y con modelos de vista clásicos, porque el punto de contacto es una vista de SwiftUI corriente que recibe su store y que puede presentarse desde cualquier parte de la app.

// La isla formal se presenta desde una app que no lo es
struct AjustesView: View {          // pantalla en patron MV, sin reducers
    @Environment(Sesion.self) private var sesion
    @State private var mostrandoEditor = false

    var body: some View {
        Button("Editar nota") { mostrandoEditor = true }
            .sheet(isPresented: $mostrandoEditor) {
                EditorView(store: Store(initialState: Editor.State()) { Editor() })
            }
    }
}

Nada obliga a que la app entera comparta régimen: la frontera de la isla es la firma de una vista.

La estrategia que funciona consiste en elegir la pantalla más problemática, la que concentra los fallos irreproducibles, convertirla entera y medir durante un ciclo de desarrollo si el equipo escribe menos incidencias y más pruebas. Si la respuesta es sí, se avanza a la siguiente isla; si es no, has gastado una pantalla en aprender algo verdadero sobre tu proyecto y la reviertes sin drama.

Formalizar es renunciar a expresividad a cambio de poder decir qué es imposible

Toda arquitectura formal ejecuta el mismo movimiento que la lógica hizo con el lenguaje natural: recorta lo que se puede decir para poder demostrar algo sobre lo dicho. Cuando la mutación es una llamada a un método, tu app es un lenguaje sin gramática cerrada, capaz de expresar cualquier transición que se te ocurra escribir, y precisamente por eso incapaz de sostener ninguna afirmación general sobre su comportamiento. No puedes enumerar los cambios posibles porque no forman un conjunto, no puedes reproducir una historia porque no dejó rastro, no puedes probar exhaustivamente porque no hay totalidad que agotar. Al convertir la acción en un valor de un tipo cerrado, renuncias a esa libertad infinita y recibes a cambio la única moneda que la sustituye con ventaja: enunciados verdaderos sobre lo que tu sistema no puede hacer. Ahí está el verdadero criterio de adopción, y no en el gusto por la simetría. Una app cuyo mayor riesgo es tardar en salir no necesita ese poder, porque la incertidumbre que la amenaza es de mercado y no de estado. Una app cuyo mayor riesgo es un fallo que corrompe datos del usuario, o una máquina de pagos que no puede cobrar dos veces, sí lo necesita, y lo necesita antes de que ocurra el incidente que lo demuestre. La ceremonia no es el precio de la elegancia: es el precio de poder decir con rigor lo que jamás pasará. Págalo cuando esa frase valga dinero, y no antes.

⚔️ Decide con datos, no con gusto
  1. Revisa las incidencias de los últimos tres meses y clasifica cuántas son estados imposibles, efectos que se pisan o fallos irreproducibles. Ese número es tu argumento.
  2. Toma la pantalla que concentra más de esas incidencias y escribe solo su enumerado de acciones, sin implementar nada. Si te salen setters, revisa el diseño antes de seguir.
  3. Convierte esa pantalla a reducer completo con sus efectos, y escribe una prueba que afirme la transición entera, incluido lo que no debe cambiar.
  4. Mide dos cosas durante un ciclo: el tiempo de compilación del módulo antes y después, y las incidencias nuevas de esa pantalla.
  5. Escribe la decisión en un documento breve con las señales marcadas y el resultado de la medición, y fija la fecha en que la revisarás.