wandres.dev
MIGRAR A TCA · una app existente

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.

⏱ 19 min

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.

🎯 Al terminar esta lección sabrás
  • Formular la adopción como estrangulamiento y entender qué propiedad del Store hace 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 Store dentro 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.

💡
El criterio de la publicación

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
La migración incremental no es una concesión al calendario: es la única forma de aprender la arquitectura sobre el problema real

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.

⚔️ Encuentra tu cabeza de puente y ancla la primera
  1. 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.
  2. 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.
  3. Elige una y escribe su State sin 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.
  4. Implementa la feature con su acción delegate y móntala con una vista puente que traduzca el delegate en el cierre que el mundo viejo espera. No toques ni una línea por encima de ella.
  5. 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.