La estrategia incremental: de la hoja hacia la raíz
Ninguna app con usuarios se migra a TCA de una vez, y las que lo intentan suelen morir en la rama larga. Esta lección formula la adopción como un estrangulamiento: se elige una pantalla hoja, se le instala un `Store` dentro sin tocar nada por encima, y la frontera entre el mundo nuevo y el viejo se empuja hacia la raíz una pantalla cada vez. Define qué es exactamente una hoja en términos del grafo de navegación y del estado mutable compartido, muestra el punto de anclaje concreto que hace posible el injerto, y explica por qué la dirección de abajo hacia arriba no es una preferencia estética sino la única que produce incrementos publicables.
La pregunta que abre este nivel no es cómo se escribe una feature en TCA, cosa que llevas veintiocho niveles sabiendo, sino qué se hace con las cuarenta pantallas que ya existen y que son las que pagan las nóminas. La respuesta que aparece en la primera reunión es siempre reescribir, y es casi siempre la equivocada, por razones que tienen menos que ver con la arquitectura que con la economía de los proyectos largos. La respuesta buena es más modesta y bastante más difícil de defender ante un calendario: instalar la arquitectura nueva en un rincón donde no moleste, comprobar que sobrevive al contacto con el código real y dejar que crezca hacia el centro. Esta lección trata de cómo se elige ese rincón, qué forma tiene el injerto y por qué la dirección importa más que la velocidad.
- Formular la adopción como estrangulamiento y entender qué propiedad del
Storehace que TCA lo admita. - Definir pantalla hoja de forma operativa, en términos del grafo de navegación y del estado mutable compartido.
- Instalar el primer
Storedentro de una pantalla existente sin modificar nada por encima de ella. - Reconocer cuándo la frontera ya no puede bajar más y hay que empezar a absorber pantallas hacia la raíz.
El argumento contra la reescritura
Conviene tener el argumento afilado, porque lo vas a tener que sostener varias veces. El primero es económico: durante una reescritura conviven dos productos, el que se mantiene y el que se construye, y el segundo no entrega nada hasta que alcanza la paridad con el primero. La curva de valor es plana durante meses y todo el riesgo se concentra en el único día en que por fin se puede comprobar si aquello funciona. El segundo argumento es epistemológico y es el que más se subestima: una parte grande del conocimiento del dominio no está documentada, está en el código, y sobre todo está en las líneas que parecen absurdas. Cada condición rara que nadie recuerda haber escrito suele ser un incidente que alguien vivió a las tres de la mañana. Reescribir es tirar ese archivo y volver a comprarlo al mismo precio.
La alternativa se llama estrangulamiento por analogía con la higuera que crece alrededor de un árbol hasta sustituirlo: se instala la estructura nueva junto a la vieja, se traslada funcionalidad pieza a pieza y en todo momento el sistema entero es publicable. Lo que hace a TCA inusualmente apto para esta forma de crecer es una propiedad concreta y fácil de pasar por alto: el Store es un objeto corriente que se construye donde quieras. No exige un contenedor de aplicación, ni un árbol raíz, ni un registro global inicializado al arrancar. Una pantalla puede tener su propio Store mientras las otras treinta y nueve no saben que existe, y eso no es un apaño tolerado sino el modo normal de operar de la librería.
Si un paso de tu plan de migración no se puede publicar solo, no es un paso: es media reescritura con otro nombre. La prueba es brutal y funciona: al final de cada incremento, la app debe poder ir a la tienda tal y como está, sin banderas a medias ni pantallas rotas detrás de un interruptor.
Qué es exactamente una hoja
La intuición dice que una hoja es una pantalla pequeña, pero el tamaño no tiene nada que ver. La definición operativa tiene dos condiciones, y la segunda es la que se olvida. Primera: grado de salida cero en el grafo de navegación, es decir, la pantalla no presenta ni empuja ninguna otra. Segunda: no escribe en estado mutable que otras pantallas lean, lo que descarta la pantalla que actualiza la sesión, la que invalida una caché compartida o la que toca un singleton de configuración que media app consulta.
Cumplen las dos condiciones muchas más pantallas de las que parece: un detalle de solo lectura, una edición de un campo aislado, la pantalla de una preferencia, un paso de un asistente, una ficha de ayuda. Empezar por ahí no es cobardía metodológica; es que en una hoja el injerto se prueba entero —modelado, efectos, dependencias, tests— con un radio de daño de una pantalla.
@Reducer
struct EditarNota {
@ObservableState
struct State: Equatable {
var texto: String
var guardando = false
var error: String?
var guardado: Nota?
}
enum Action {
case textoCambiado(String)
case guardarPulsado
case guardadoRecibido(Result<Nota, Error>)
case delegate(Delegate)
enum Delegate { case seGuardo(Nota) }
}
@Dependency(\.notasCliente) var notas
var body: some ReducerOf<Self> {
Reduce { estado, accion in
switch accion {
case let .textoCambiado(t):
estado.texto = t
return .none
case .guardarPulsado:
estado.guardando = true
return .run { [texto = estado.texto] envio in
await envio(.guardadoRecibido(Result { try await notas.guardar(texto) }))
}
case let .guardadoRecibido(.success(nota)):
estado.guardando = false
estado.guardado = nota
return .send(.delegate(.seGuardo(nota)))
case let .guardadoRecibido(.failure(fallo)):
estado.guardando = false
estado.error = fallo.localizedDescription
return .none
case .delegate:
return .none
}
}
}
}
La forma de la frontera
El injerto necesita un punto de anclaje, y el punto de anclaje es siempre el mismo: una vista envoltorio que construye el Store, dibuja la feature y traduce la acción delegate en el vocabulario que el mundo viejo entiende, que casi siempre es un cierre. Hacia dentro entran valores planos; hacia fuera salen hechos consumados. Nada más cruza.
struct EditarNotaPuente: View {
@State var store: StoreOf<EditarNota>
let alGuardar: (Nota) -> Void
init(texto: String, alGuardar: @escaping (Nota) -> Void) {
self.store = Store(initialState: EditarNota.State(texto: texto)) { EditarNota() }
self.alGuardar = alGuardar
}
var body: some View {
EditarNotaView(store: store)
.onChange(of: store.guardado) { _, nueva in
if let nueva { alGuardar(nueva) }
}
}
}
// Desde el mundo viejo, sin enterarse de nada
let vc = UIHostingController(
rootView: EditarNotaPuente(texto: nota.texto) { [weak self] guardada in
self?.viewModel.actualizar(guardada)
}
)
El puente observa un campo del estado y no la acción, porque el flujo de acciones de un Store no es un canal público al que se pueda suscribir nadie desde fuera; el hecho consumado se publica dejándolo escrito en el estado, que sí es observable. Fíjate en la simetría: ese cierre es la versión degradada de la acción delegate del Nivel 14. El padre viejo recibe por cierre exactamente lo mismo que un padre en TCA recibiría por acción, y por eso el día que el padre se migre bastará con sustituir el cierre por el enrutado de la acción y borrar el puente. La frontera está diseñada desde el principio para desaparecer.
Hacia la raíz: la coalescencia de islas
Cuando dos hojas hermanas ya viven en TCA, el padre pasa de ser un trabajo grande a ser uno pequeño: su estado es la suma de dos estados ya modelados más un enum de destinos, y su reducer es un Scope por hijo más el enrutado de sus acciones delegate. Ese es el efecto que hace que la migración se acelere en vez de agotarse, y es la razón profunda por la que se sube en lugar de bajar. Si empiezas por la raíz tienes que modelar todo el árbol de destinos antes de poder publicar la primera línea; si empiezas por las hojas, cada paso es un incremento aislado y el trabajo del padre lo pagan los hijos ya hechos.
| Fase | Qué se migra | Qué se gana | Puentes activos |
|---|---|---|---|
| Cabeza de puente | Una hoja sin estado compartido | El injerto probado de punta a punta | 1 |
| Expansión | Las hojas hermanas de un mismo flujo | Tests de lógica sin interfaz | Varios |
| Coalescencia | El padre de esas hojas | Navegación como dato, enlaces profundos | Baja a 1 |
| Absorción | El flujo entero hacia la raíz | Estado inspeccionable de la app | Tiende a 0 |
Injertar, no trasplantar
Un Store se construye en cualquier sitio. La pantalla migrada no le pide permiso a la raíz.
Grado de salida cero
Hoja es la que no presenta a nadie y no escribe estado que otros lean. Las dos condiciones.
La frontera es un cierre
delegate hacia fuera, valores hacia dentro. Diseñada para poder borrarse después.
Se sube, no se baja
Los hijos ya hechos abaratan al padre. Empezar por la raíz obliga a modelarlo todo antes de publicar nada.
flowchart BT H1[Hoja migrada uno] --> P[Padre aun en el mundo viejo] H2[Hoja migrada dos] --> P P --> R[Raiz de la app] H1 -.puente de cierre.-> P H2 -.puente de cierre.-> P P ==absorcion posterior==> P2[Padre migrado con Scope y destinos] style H1 fill:#a6e3a1,color:#11111b style H2 fill:#a6e3a1,color:#11111b style P2 fill:#89b4fa,color:#11111b
Hay una lectura de la estrategia incremental que la presenta como el mal menor, la opción prudente que se elige porque no hay presupuesto para hacerlo bien. Es una lectura falsa, y quien la sostiene suele acabar con una reescritura fracasada y una moraleja equivocada. La migración por hojas es superior por una razón que no tiene que ver con el riesgo sino con el conocimiento. Cuando escribes tu primera feature en TCA sobre una pantalla real de tu app, descubres en dos semanas cosas que ningún tutorial te puede enseñar: que tu modelo de usuario no es Equatable porque arrastra una referencia a un objeto de la capa de datos; que tu capa de red devuelve un tipo que no es Sendable y que Swift 6 no te va a dejar cruzar con él; que hay un singleton escuchando notificaciones que muta estado a tus espaldas; que la pantalla que creías sencilla tiene once estados posibles porque el diseño acumuló casos durante tres años. Cada uno de esos descubrimientos es una restricción del problema, y las restricciones del problema son lo único que determina si un diseño es correcto. Una reescritura las descubre todas también, pero las descubre tarde, después de haber comprometido decisiones estructurales que se tomaron cuando no se sabían, y el coste de rectificar entonces es el que hunde el proyecto. La hoja es un laboratorio con las mismas leyes físicas que la app entera y un radio de daño de una pantalla; la reescritura es un experimento de un año sin punto de control intermedio. Y hay un segundo rendimiento, más silencioso, que solo se ve a los seis meses: la frontera entre los dos mundos, que al principio parece deuda pura, resulta ser el mejor documento de arquitectura que tendrás nunca. Cada puente hace explícito qué información necesita de verdad una pantalla y qué le devuelve al resto del sistema, escrito en una firma que el compilador verifica. Esa lista de puentes es el mapa real de acoplamientos de tu aplicación, el que no aparece en ningún diagrama porque nadie fue capaz de escribirlo mirando el código viejo. Migrar por hojas no es solo llegar al mismo sitio más despacio y con menos riesgo: es llegar sabiendo por qué el sitio es ese.
- Dibuja el grafo de navegación de tu app y marca todas las pantallas con grado de salida cero. Suelen ser más de las que esperas.
- De esa lista, tacha las que escriban en un singleton, en la sesión o en una caché que otras pantallas lean. Lo que quede son hojas verdaderas.
- Elige una y escribe su
Statesin mirar el código actual, solo mirando la pantalla. Después compara: cada campo que te falte es un acoplamiento oculto que acabas de descubrir. - Implementa la feature con su acción
delegatey móntala con una vista puente que traduzca eldelegateen el cierre que el mundo viejo espera. No toques ni una línea por encima de ella. - Publica ese cambio solo, sin nada más. Si no puedes, apunta qué te lo impidió: eso es exactamente lo que hay que arreglar antes de continuar.