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.
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.
- 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
SPMsin 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
TestStorey 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.
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 |
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.
- 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.
- 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. - Escribe el
Package.swiftque proyecta ese árbol y comprueba que ninguna feature aparece en las dependencias de otra feature. - 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. - 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
delegateque habías dado por obvios.