wandres.dev
NIVEL DIOS: SÍNTESIS · componer todo

Arquitectar una app completa: el plano entero, del árbol de features al testing

El ejercicio culminante del track: tomar una app que todavía no existe y producir su plano completo en cuatro movimientos deducidos unos de otros. Primero el árbol de features, que no es una lista de pantallas sino un árbol de estado con una cardinalidad decidida en cada arista. Después el grafo de módulos, que es ese árbol proyectado sobre objetivos de SPM. Luego la raíz, que concentra la navegación, el arranque y la inyección de lo vivo. Y por último el plan de testing, que resulta ser el mismo árbol leído por tercera vez. Ninguno de los cuatro se inventa: los tres últimos se derivan del primero.

⏱ 24 min

La pregunta que separa a quien sabe usar TCA de quien sabe arquitecturar con TCA no es cómo se escribe un reducer, sino qué se hace el primer día, cuando no hay ni una línea y hay que decidir en qué se parte una app entera. Esta lección responde a esa pregunta con un procedimiento reproducible de cuatro movimientos, y con una tesis fuerte detrás: solo el primero de los cuatro es una decisión de diseño genuina. El grafo de módulos, la navegación y el plan de pruebas no son elecciones independientes que haya que armonizar después, sino tres lecturas distintas del mismo árbol de estado. Si el árbol está bien, los tres salen casi solos; si está mal, ninguna cantidad de disciplina posterior lo arregla.

🎯 Al terminar esta lección sabrás
  • Derivar un árbol de features a partir del dominio decidiendo la cardinalidad exacta de cada arista.
  • Proyectar ese árbol sobre un grafo de objetivos de SPM sin crear dependencias entre hermanos.
  • Concentrar en la raíz la navegación, el arranque, el deep linking y la inyección de las implementaciones vivas.
  • Escribir el plan de testing por capas y saber qué se prueba con TestStore y qué no merece la pena probar así.

Del dominio al árbol de features

El error inaugural es empezar por las pantallas. Las pantallas son una proyección del estado sobre un dispositivo concreto y cambian cuando llega el iPad, el widget o el reloj; el árbol de estado no. Se empieza, por tanto, por el dominio: qué entidades hay, qué relaciones tienen, qué se comparte. Y sobre ese dominio se decide lo único que importa en esta fase, que es la cardinalidad de cada arista entre un padre y sus hijos.

@Reducer
struct AppFeature {
  @ObservableState
  struct State: Equatable {
    var pestana: Pestana = .sesiones
    var listado = Listado.State()            // hijo permanente
    var ajustes = Ajustes.State()            // hijo permanente
    @Presents var destino: Destino.State?    // hijo opcional, uno de varios
    var ruta = StackState<Ruta.State>()      // hijos apilados

    @Shared(.fileStorage(.documentsDirectory.appending(component: "sesiones.json")))
    var sesiones: IdentifiedArrayOf<Sesion> = []   // dominio compartido, no feature
  }
}

La última línea es la que más decisiones ahorra a medio plazo. El dominio compartido —lo que varias features leen y alguna escribe— no se duplica en cada State ni se pasa a mano por el árbol: se declara una vez con @Shared y se sincroniza sin que ningún padre tenga que reenviarlo hacia abajo. Distinguir eso del estado propio de cada feature es la frontera que mantiene el árbol poco profundo, porque casi todo el anidamiento innecesario de una app grande nace de intentar que los datos viajen por las mismas aristas que la navegación.

Relación con el hijo Cómo se declara en el State Operador Cuándo nace y muere el hijo
Siempre presente Campo no opcional Scope Vive exactamente lo que vive el padre
Puede no existir @Presents var x: X.State? ifLet Nace al asignar, muere al anular
Uno de entre varios enum con @CasePathable ifCaseLet Nace y muere al cambiar de caso
Muchos, por identidad IdentifiedArrayOf<X.State> forEach Por elemento, según la colección
Muchos, apilados StackState<Ruta.State> forEach de pila Con cada empujón y cada retirada
flowchart TD
ROOT[AppFeature] --> LIS[Listado]
ROOT --> AJU[Ajustes]
ROOT --> DES[Destino como enum]
ROOT --> PIL[Pila de rutas]
DES --> ALE[Alerta]
DES --> EDI[Editor]
PIL --> DET[Detalle]
DET --> RES[Resumen]
SH[Sesiones compartidas] -.-> LIS
SH -.-> DET
SH -.-> RES
style ROOT fill:#89b4fa,color:#11111b
style SH fill:#a6e3a1,color:#11111b
style DES fill:#fab387,color:#11111b

Del árbol de features al grafo de módulos

El segundo movimiento es mecánico si el primero está hecho. Cada nodo del árbol que tenga estado propio, acciones propias y algún efecto se convierte en un objetivo de SPM; cada arista se convierte en una dependencia declarada del padre hacia el hijo, nunca al revés y nunca entre hermanos. Debajo del árbol quedan dos capas que no son features: el dominio, con los tipos de valor, y las interfaces de los clientes. Encima queda la raíz, y encima de la raíz el objetivo de app, que debe caber en una pantalla.

targets: [
  .target(name: "Dominio"),                                        // valores puros
  .target(name: "SesionesCliente", dependencies: ["Dominio", .tca]), // interfaz
  .target(name: "SesionesClienteLive", dependencies: ["SesionesCliente"]),

  .target(name: "ListadoFeature", dependencies: ["SesionesCliente", .tca]),
  .target(name: "DetalleFeature", dependencies: ["SesionesCliente", .tca]),
  .target(name: "AjustesFeature", dependencies: ["Dominio", .tca]),

  .target(
    name: "AppFeature",
    dependencies: ["ListadoFeature", "DetalleFeature", "AjustesFeature", .tca]
  ),
]

Fíjate en que ListadoFeature y DetalleFeature no se conocen, aunque en la app una empuje a la otra. Esa es exactamente la propiedad que hace utilizable el plano: cualquiera de las dos se abre en un preview aislado, se compila en segundos y se testea sin arrastrar la mitad del sistema. La coordinación entre ambas existe, pero vive donde le corresponde, que es en el único objetivo que las conoce a las dos.

🌳

El árbol manda

Los módulos no se inventan por temas ni por equipos: son los nodos del árbol de estado, uno por uno.

🚫

Hermanos incomunicados

Ninguna feature depende de otra feature. Lo que parece necesitarlo era dominio, cliente o coordinación.

🪢

Lo compartido baja

Si dos features leen lo mismo, ese dato pertenece al dominio y llega por @Shared, no por parámetros.

🎬

La raíz solo ensambla

Scope, ifLet, forEach, la traducción de delegate y la inyección de lo vivo. Nada más.

La raíz: navegación, arranque y lo vivo

La raíz es el único sitio donde tres cosas ocurren, y conviene enunciarlo como regla porque cada una de las tres es una tentación constante de dispersar. Primero, la traducción de acciones delegate: cuando el detalle anuncia que el usuario borró una sesión, es la raíz la que decide si eso saca una pantalla de la pila, actualiza el listado o abre una alerta. Segundo, el deep linking, que en TCA no es un enrutador sino la construcción directa de un estado inicial. Y tercero, la sustitución de las implementaciones vivas, que es lo único que impide que un objetivo de feature acabe conociendo el mundo real.

@main
struct MiApp: App {
  static let store = Store(initialState: AppFeature.State()) {
    AppFeature()
  }

  var body: some Scene {
    WindowGroup {
      AppView(store: Self.store)
        .onOpenURL { url in Self.store.send(.abrioEnlace(url)) }
    }
  }
}

// En AppFeature: el enlace no navega, construye estado
case let .abrioEnlace(url):
  guard let id = Sesion.ID(url.lastPathComponent) else { return .none }
  state.ruta.append(.detalle(Detalle.State(id: id)))
  return .none

Que abrir un enlace sea añadir un valor a una pila y no ejecutar una transición tiene una consecuencia que se cobra el día del lanzamiento: el deep linking se testea sin simulador, sin URL reales y sin esperar animaciones, porque probar que un enlace lleva a la pantalla correcta es probar que una función construye el estado correcto. La restauración de sesión, la continuidad entre dispositivos y las pruebas de regresión de navegación son el mismo test escrito tres veces.

⚠️
Una raíz con lógica de negocio es una feature que nunca se extrajo

El síntoma es fácil de detectar y difícil de aceptar: si el reducer de la raíz tiene más de un puñado de casos que no sean traducciones de delegate o de navegación, hay una feature escondida ahí dentro. Suele ocurrir con la sesión del usuario, con la sincronización o con las notificaciones, que empiezan siendo cuatro líneas en el sitio que ya lo conoce todo y acaban siendo la parte del sistema que nadie puede tocar sin romper la app entera, porque es la única que no se puede compilar ni probar por separado.

El plan de testing, que es el árbol otra vez

El cuarto movimiento tampoco es una decisión nueva. Cada nodo del árbol se prueba con un TestStore exhaustivo, y como el nodo contiene toda su lógica, esa prueba unitaria cubre lo que en otras arquitecturas exigiría una prueba de integración. Las aristas se prueban por separado, con exhaustividad reducida, afirmando solo que el padre reacciona como debe al delegate del hijo. Y las hojas del sistema —los clientes vivos— se prueban aparte, contra el mundo real, en pruebas que no pertenecen a la app.

// Arista, no nodo: exhaustividad reducida porque el hijo ya tiene sus tests
let store = TestStore(initialState: AppFeature.State(ruta: .init([.detalle(.init(id: id))]))) {
  AppFeature()
}
store.exhaustivity = .off(showSkippedAssertions: true)

await store.send(\.ruta[id: 0].detalle.delegate.borro)
store.assert { $0.ruta.removeLast() }

La forma resultante no es la pirámide clásica, y conviene decirlo porque desconcierta a quien llega de otras arquitecturas: aquí la base ancha son pruebas de reducer que se ejecutan en milisegundos y afirman comportamiento completo, no pruebas de funciones auxiliares. Lo que se adelgaza es la capa de arriba. Las pruebas de interfaz quedan para lo que solo la interfaz puede fallar —accesibilidad, tamaños dinámicos, un snapshot de una vista compleja— y dejan de ser el sitio donde se descubre que la lógica estaba mal.

Capa Con qué se prueba Exhaustividad Cuántas
Nodo del árbol TestStore con dependencias sustituidas Total Muchas, es donde vive el valor
Arista padre e hijo TestStore de la raíz Reducida Una por contrato de delegate
Cliente vivo Pruebas de contrato fuera de la app No aplica Pocas y lentas, en otro objetivo
Vista snapshot o pruebas de interfaz No aplica Mínimas, solo lo visual
Una app en TCA no tiene arquitectura: tiene un árbol de estado, y todo lo demás es una proyección suya

Merece la pena detenerse en lo que acaba de ocurrir, porque es la observación que justifica el nivel entero. Hemos producido cuatro artefactos que en cualquier otro proyecto se discuten por separado, en reuniones distintas, con personas distintas y a menudo con conclusiones incompatibles: el diseño de features, la estructura de módulos, el esquema de navegación y la estrategia de pruebas. Aquí los cuatro salieron del mismo dibujo. El grafo de módulos es el árbol proyectado sobre objetivos de compilación. El esquema de navegación es el árbol proyectado sobre el tiempo, porque navegar es cambiar qué ramas están vivas. El plan de pruebas es el árbol proyectado sobre el aislamiento, un test por nodo y uno por arista. Y hasta el deep linking es el árbol proyectado sobre las cadenas de texto. Cuatro proyecciones de una sola estructura, y por eso son consistentes entre sí sin que nadie tenga que trabajar para armonizarlas: no hay dos fuentes de verdad que sincronizar. Esto explica también por qué en TCA los errores de arquitectura duelen tan pronto. En una arquitectura convencional, un mal reparto de responsabilidades tarda meses en manifestarse, porque cada capa puede compensar los defectos de la anterior con un poco de pegamento imperativo. Aquí no hay dónde esconderlo: si la cardinalidad de una arista está mal elegida, el operador no compila; si dos features se necesitan de verdad, la dependencia hay que declararla y se ve en el manifiesto; si un dato está en el sitio equivocado, aparece duplicado en dos State y el compilador de Equatable te lo recuerda. El árbol no admite mentiras piadosas, y esa intolerancia —que en las primeras semanas se vive como rigidez insoportable— es precisamente lo que convierte el plano en un documento vivo en lugar de en un diagrama que envejece en una wiki. Hay una última consecuencia, la más valiosa para un equipo: como el plano es ejecutable, discutir arquitectura deja de ser discutir opiniones. La pregunta dónde debería vivir esto tiene respuesta comprobable, porque cada ubicación posible produce un grafo distinto, unos tiempos de compilación distintos y unas pruebas distintas, y las tres cosas se pueden medir el mismo día.

⚔️ Produce el plano completo de una app tuya en cuatro movimientos
  1. Dibuja el árbol de estado de una app que quieras construir sin nombrar ni una pantalla, y anota sobre cada arista su cardinalidad y el operador que le corresponde.
  2. Marca qué datos leen dos o más nodos. Esos bajan al dominio y viajan por @Shared; si te queda alguno viajando como parámetro por tres niveles, el árbol está mal.
  3. Escribe el Package.swift que proyecta ese árbol y comprueba que ninguna feature aparece en las dependencias de otra feature.
  4. Escribe la raíz completa: la traducción de cada delegate, el caso de deep linking y la inyección de lo vivo. Si pasa de una pantalla, extrae la feature escondida.
  5. Redacta el plan de pruebas como una tabla de nodos y aristas, y escribe hoy la primera prueba de arista. Es la que descubre los contratos de delegate que habías dado por obvios.