El ecosistema Point-Free: las piezas de TCA que se usan sin TCA
The Composable Architecture no es una biblioteca monolítica sino el ensamblaje de una docena de bibliotecas independientes, y esa es la noticia más útil del track para quien no puede adoptarla entera. Esta lección abre el manifiesto, recorre las cuatro piezas mayores —swift-dependencies, swift-case-paths, swift-navigation y swift-sharing—, muestra una app escrita con todas ellas y con `@Observable` en lugar de reducers, y termina con el criterio para decidir cuánta arquitectura comprar: un gradiente de adopción donde cada escalón se paga por separado y cada uno rinde antes de llegar al siguiente.
Hay una lectura de este track que se le escapa a casi todo el mundo hasta que abre el manifiesto del paquete: TCA no es una biblioteca grande, es un ensamblaje de bibliotecas pequeñas que existen por separado, se versionan por separado y —esto es lo importante— se pueden adoptar por separado sin escribir un solo reducer. Quien trabaja en una base de código que jamás aceptará una arquitectura completa puede llevarse igualmente la inyección de dependencias testeable, la navegación dirigida por estado, el estado compartido persistente y la ergonomía de los enums, cuatro victorias que no dependen unas de otras. Esta lección es el inventario de ese botín y el criterio para saber cuánto conviene tomar.
- Leer TCA como un ensamblaje y saber qué biblioteca aporta cada capacidad que creías del framework.
- Usar
swift-dependencies,swift-case-paths,swift-navigationyswift-sharingfuera de TCA. - Escribir una pantalla con
@Observableque aproveche las cuatro sin un solo reducer. - Decidir en qué escalón del gradiente de adopción está tu proyecto y qué cuesta subir al siguiente.
TCA es un ensamblaje, no un monolito
Si abres el Package.swift de la biblioteca encontrarás una lista de dependencias que sorprende por lo larga y por lo poco que suena a arquitectura. Cada una resuelve un problema concreto de Swift que Point-Free se encontró de camino, lo extrajo y lo publicó como paquete autónomo. El resultado es que buena parte de lo que atribuías al framework no es del framework: es de una biblioteca que puedes añadir mañana a un proyecto con UIKit y MVVM, o incluso a un servidor sin interfaz.
dependencies: [
.package(url: "https://github.com/pointfreeco/swift-case-paths", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-identified-collections", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-navigation", from: "2.0.0"),
.package(url: "https://github.com/pointfreeco/swift-perception", from: "1.0.0"),
.package(url: "https://github.com/pointfreeco/swift-sharing", from: "2.0.0"),
]
| Biblioteca | Qué aporta | Sirve sin TCA | A qué sustituye |
|---|---|---|---|
swift-dependencies |
@Dependency, DependencyKey, withDependencies |
Sí, en cualquier Swift | Singletons y protocolos con dobles a mano |
swift-case-paths |
@CasePathable, acceso a casos por key path |
Sí, incluso en servidor | Cascadas de if case let |
swift-navigation |
Navegación por estado en SwiftUI, UIKit y AppKit | Sí, con @Observable |
Banderas booleanas y pushViewController |
swift-sharing |
@Shared, persistencia y estado compartido |
Sí, en modelos y vistas | UserDefaults suelto y singletons de datos |
swift-clocks |
TestClock, ImmediateClock, .timeout |
Sí, en cualquier async | Task.sleep y esperas reales en tests |
swift-custom-dump |
diff, expectNoDifference |
Sí, en cualquier test | Comparaciones ilegibles de valores grandes |
swift-perception |
Observation retroportado a versiones antiguas |
Sí, en apps con soporte largo | Subir el objetivo de despliegue |
swift-identified-collections |
IdentifiedArray |
Sí, en cualquier modelo | Arrays con búsquedas por índice frágil |
flowchart TD TCA[swift-composable-architecture] --> DEP[swift-dependencies] TCA --> CP[swift-case-paths] TCA --> NAV[swift-navigation] TCA --> SH[swift-sharing] TCA --> CLK[swift-clocks] TCA --> CD[swift-custom-dump] TCA --> IC[swift-identified-collections] NAV --> PER[swift-perception] NAV --> CP SH --> DEP DEP --> IR[swift-issue-reporting] style TCA fill:#f38ba8,color:#11111b style DEP fill:#89b4fa,color:#11111b style NAV fill:#a6e3a1,color:#11111b style SH fill:#fab387,color:#11111b
Las cuatro mayores, de una en una
swift-dependencies es la que casi todo el mundo debería adoptar primero, porque no exige cambiar ni una línea de arquitectura. Declaras una clave, das el valor vivo, el de previews y el de tests, y a partir de ahí cualquier ámbito puede sustituir el mundo entero para lo que ocurra dentro de él. Funciona en una ViewModel, en un UIViewController, en un paquete sin interfaz y en un servidor. La ganancia inmediata no es filosófica: es que el reloj, la red y el generador de identificadores dejan de ser la causa de los tests intermitentes.
@DependencyClient
struct Analitica: Sendable {
var registrar: @Sendable (_ evento: String) async -> Void
}
extension Analitica: DependencyKey {
static let liveValue = Analitica { await SDKExterno.log($0) }
}
// En cualquier clase, sin reducers de por medio ni extension de DependencyValues
final class Carrito {
@Dependency(Analitica.self) var analitica
@Dependency(\.uuid) var uuid
@Dependency(\.date.now) var ahora
}
swift-case-paths es la más pequeña y la que más cambia el estilo de escribir Swift. Un enum marcado con @CasePathable gana lo que las struct siempre tuvieron: acceso a sus casos por key path, con lectura, escritura y comprobación de pertenencia. swift-navigation construye sobre eso la navegación dirigida por estado fuera de TCA, con la misma idea de que un destino es un valor opcional del modelo. Y swift-sharing, extraída del framework para vivir sola, lleva @Shared con sus estrategias de persistencia a cualquier clase observable.
@CasePathable
enum Estado {
case cargando
case error(String)
case listo([Fila])
}
let e = Estado.error("sin red")
e.error // Optional<String>, sin escribir un if case
e.is(\.listo) // Bool
var m = e; m.error?.append(" o sin permiso") // escritura in situ
dependencies
Sustituye el mundo por ámbito. Es la pieza que hace deterministas los tests que ya tienes.
case-paths
Da a los enums la ergonomía de las structs. Se nota en la primera hora y no se puede desaprender.
navigation
Destinos como valores en SwiftUI, UIKit y AppKit. Adiós a los booleanos de presentación.
sharing
Estado compartido con semántica de valor y persistencia declarada en el punto de uso.
Una pantalla moderna sin un solo reducer
El modo más rápido de convencer a un equipo escéptico es enseñarle este archivo: una pantalla escrita con @Observable de toda la vida, sin State, sin Action y sin Store, que sin embargo tiene dependencias sustituibles, navegación por estado, persistencia compartida y destinos modelados como un enum con casos exclusivos. Es el estilo que Point-Free llama SwiftUI moderno, y es el escalón intermedio real entre no tener arquitectura y tener TCA.
@MainActor @Observable
final class ListadoModel {
@ObservationIgnored @Dependency(\.api) var api
@ObservationIgnored @Shared(.appStorage("orden")) var orden = Orden.fecha
var destino: Destino?
var filas: [Fila] = []
@CasePathable
enum Destino {
case editor(EditorModel)
case borrado(AlertState<Confirmacion>)
}
func tocoEditar(_ fila: Fila) { destino = .editor(EditorModel(fila: fila)) }
}
struct ListadoView: View {
@Bindable var model: ListadoModel
var body: some View {
List { /* ... */ }
.sheet(item: $model.destino.editor) { EditorView(model: $0) }
.alert($model.destino.borrado) { await model.confirmo($0) }
}
}
La línea decisiva es la del enum de destinos, porque es la que impide el bug que ninguna cantidad de cuidado evita con banderas booleanas: dos pantallas presentadas a la vez. Y la de @Shared con appStorage es la que retira UserDefaults.standard de la mitad del proyecto sustituyéndolo por un valor observable que se puede sobrescribir en un test. Nada de esto necesita reducers, y todo esto se puede introducir pantalla a pantalla en una base de código de diez años.
Empieza por swift-dependencies en un solo módulo y usa como argumento un test intermitente concreto que deje de serlo. Sigue por swift-case-paths cuando alguien se queje de una cascada de if case let. Introduce swift-navigation la primera vez que aparezca un bug de dos hojas presentadas a la vez. Y deja swift-sharing para cuando haya que persistir algo que hoy vive en un singleton. Cuatro adopciones, cuatro problemas reales resueltos, cero conversaciones sobre arquitectura. Si después de eso alguien propone TCA, ya conocéis las cuatro quintas partes de su superficie.
Cuánta arquitectura comprar
Las piezas forman un gradiente y cada escalón se paga por separado. Abajo del todo está el proyecto sin nada, donde el coste de entrada es cero y el de mantenimiento crece sin límite. Arriba del todo está TCA completa, con un coste de entrada alto y un coste marginal casi plano a partir de la quinta feature. En medio hay tres escalones habitables, y una app perfectamente sana puede quedarse en cualquiera de ellos durante años.
| Escalón | Qué añades | Qué ganas | Qué cuesta |
|---|---|---|---|
| 1 | dependencies y clocks |
Tests deterministas y previews con datos | Una clave por dependencia |
| 2 | case-paths e identified-collections |
Enums y listas manejables | Nada apreciable |
| 3 | navigation y sharing |
Navegación por estado y persistencia | Modelar destinos como valores |
| 4 | TCA completa | Composición, testing exhaustivo, modularidad | Ceremonia de State y Action |
El criterio para subir de escalón no es la ambición sino el dolor. Se sube cuando el problema que resuelve el escalón siguiente ya te ha costado tiempo dos veces, y se evita subir por si acaso, porque cada escalón añade vocabulario que todo el equipo debe compartir. La ventaja de que las piezas estén separadas es exactamente esta: puedes quedarte a mitad de camino indefinidamente sin que nada quede a medias, algo que un framework monolítico no permite.
Conviene entender por qué este ecosistema tiene la forma que tiene, porque revela algo sobre cómo se construye software duradero. Ninguna de estas bibliotecas nació de un plan: todas nacieron de un obstáculo encontrado mientras se construía otra cosa. Hacía falta hablar de un caso de un enum con la misma naturalidad con que se habla de un campo de una struct, y de ahí salió swift-case-paths. Hacía falta que un test no dependiera del reloj del sistema, y de ahí salieron swift-dependencies y swift-clocks. Hacía falta comparar dos valores enormes y ver solo lo que cambió, y de ahí salió swift-custom-dump. En cada caso la decisión siguiente fue la que importa: extraerlo a un paquete autónomo en lugar de dejarlo dentro. Esa decisión, repetida durante años, tiene una consecuencia que va mucho más allá de la comodidad de los usuarios, y es que somete cada idea a una prueba durísima. Una abstracción que solo funciona dentro de su framework puede estar mal y nadie lo notará, porque el resto del framework la sostiene; una abstracción que se publica sola tiene que ganarse la vida en contextos que su autor no eligió, con gente que no comparte sus supuestos, y si estaba mal se rompe en público. Que swift-dependencies se use hoy en proyectos que jamás han oído hablar de reducers, o que @Shared funcione igual en una clase @Observable que dentro de un State, no es un detalle de empaquetado: es la evidencia empírica de que esas ideas eran correctas al margen de la arquitectura que las originó. Y de ahí sale el consejo más valioso que este nivel puede darte, que no es sobre TCA sino sobre tu propio código: cada vez que resuelvas un problema general dentro de un sistema particular, sepáralo. No por generosidad ni por reutilización, sino porque separarlo es el único experimento que puede decirte si lo que escribiste era una idea o solo un parche que encajaba en el hueco que tú mismo habías hecho.
- Elige un proyecto tuyo que no use TCA y localiza el test más intermitente que tenga. Diagnostica qué parte del mundo entra sin permiso: el reloj, la red, el azar o el disco.
- Añade
swift-dependencies, declara esa única dependencia con sus tres valores y sustitúyela en el test. Comprueba que deja de fallar y que el resto del proyecto no se enteró. - Busca el
enumcon más cascadas deif case lety márcalo con@CasePathable. Reescribe tres usos con key paths y mide cuántas líneas desaparecen. - Elige una pantalla con dos banderas booleanas de presentación y conviértelas en un
enumde destino conswift-navigation. Escribe el caso imposible que ahora no compila. - Sitúa el proyecto en el gradiente, escribe qué dolor concreto justificaría subir al escalón siguiente y guárdalo. Si dentro de tres meses ese dolor no ha aparecido, tenías razón en no subir.