Cuándo NO usar TCA, y qué usar en su lugar
La lección que falta en casi toda la literatura sobre arquitecturas, porque escribirla exige argumentar contra la herramienta que uno defiende. Aquí se enumeran las señales objetivas de que la ceremonia no se va a amortizar —apps pequeñas, prototipos, plazos más cortos que la curva, dominios donde lo difícil no es el estado—, se explica por qué el factor decisivo es el apetito del equipo y no su capacidad, y se ordena una escalera de alternativas razonables que empieza en `@State` y termina en un bucle unidireccional propio, con la observación clave de que las librerías de dependencias y navegación se pueden adoptar sin adoptar la arquitectura entera.
Ninguna herramienta merece confianza hasta que alguien es capaz de decir con precisión dónde no sirve, y esa es una prueba que la literatura sobre arquitecturas casi nunca aprueba, porque quien escribe suele ser quien defiende. TCA es una apuesta muy concreta: paga por adelantado una cantidad de estructura a cambio de garantías que se cobran a lo largo de años de cambios. Como toda apuesta con coste inicial y retorno diferido, tiene un plazo mínimo por debajo del cual es sencillamente una mala operación, y el error más caro que se comete con ella no es rechazarla cuando convenía sino adoptarla a medias en un contexto que no la sostiene. Esta lección enumera las señales de que no toca, y ordena lo que sí conviene hacer en su lugar.
- Reconocer las señales objetivas de que el retorno de la ceremonia no va a llegar en el plazo del proyecto.
- Entender por qué un prototipo se penaliza a sí mismo con exhaustividad y por qué eso no es un defecto de la librería.
- Evaluar el apetito del equipo como variable decisiva, y anticipar cómo se degrada una adopción a medias.
- Manejar la escalera de alternativas, incluida la adopción parcial del ecosistema sin la arquitectura.
Las señales de que no toca
Hay un puñado de indicios que, cuando aparecen dos o tres juntos, bastan para decidir sin más deliberación. La app tiene menos de una decena de pantallas y ninguna coordinación asíncrona interesante. El plazo de entrega es más corto que la curva de aprendizaje del equipo, que es una comparación aritmética y no una opinión. El código nace con fecha de caducidad conocida: una campaña, una demo para una feria, un experimento que se tira si no convierte. El producto es un cliente delgado sobre una interfaz que dicta el servidor, donde el estado local apenas existe. O bien la dificultad del dominio no está en el estado sino en otra parte —gráficos, audio, cámara, geometría, rendimiento de dibujo— y la arquitectura de la lógica es un problema menor comparado con el que de verdad ocupa el proyecto.
Hay además dos señales que no son técnicas y pesan más que todas las anteriores: que una sola persona escriba y mantenga el proyecto con contexto completo en la cabeza, y que el equipo no tenga ningún apetito por la ceremonia. La primera hace que la garantía sobre-cubra un riesgo que ya es bajo; la segunda garantiza que la garantía no llegará a existir.
| Señal | Por qué inclina hacia el no |
|---|---|
| Pocas pantallas y poca asincronía | el coste fijo domina y no hay nada que coordinar |
| Plazo menor que la curva | el retorno llega después de la entrega |
| Código con caducidad conocida | la exhaustividad protege cambios que no habrá |
| Interfaz dictada por el servidor | apenas hay estado local que modelar |
| Persona única con contexto total | la disciplina humana ya cubre el riesgo |
Prototipos y apps pequeñas: la ceremonia no se amortiza
El valor de TCA se cobra en el cambio. Cada caso de enum que nombras, cada rama que agotas y cada dependencia que declaras es una inversión cuyo dividendo llega el día que alguien modifica ese código sin recordarlo y el compilador le señala los seis sitios que hay que revisar. Un prototipo, por definición, no tiene ese día: su propósito es responder una pregunta y desaparecer. Aplicarle exhaustividad es comprar un seguro para un coche que vas a desguazar el martes.
Peor todavía, la ceremonia penaliza justo la operación que un prototipo hace todo el rato, que es cambiar de idea. Añadir un evento nuevo cuesta un caso, una rama y a veces una actualización de tests; en la fase en que la forma del producto se descubre probando, esa fricción de segundos se multiplica por cientos y se convierte en un sesgo real contra experimentar. Cuando lo que quieres es velocidad de exploración, @State en una vista de SwiftUI no es una derrota: es la herramienta correcta.
// La respuesta correcta cuando la pregunta es si esto merece la pena
struct BuscadorRapido: View {
@State private var consulta = ""
@State private var resultados: [Item] = []
var body: some View {
List(resultados) { Text($0.nombre) }
.searchable(text: $consulta)
.task(id: consulta) {
try? await Task.sleep(for: .milliseconds(300))
resultados = (try? await API.compartida.buscar(consulta)) ?? []
}
}
}
Merece la pena mirar ese fragmento con atención porque desarma un argumento muy usado. El modificador con identidad cancela y relanza la tarea cada vez que la consulta cambia, de modo que el rebote y la cancelación —los dos motivos por los que en la lección anterior la versión imperativa parecía frágil— aquí salen gratis y son correctos. SwiftUI resuelve muy bien el caso de una pantalla aislada con una fuente asíncrona; lo que no resuelve, y por eso el argumento no se puede extrapolar, es qué ocurre cuando esa misma pantalla tiene tres fuentes que se contradicen, un destino de navegación que depende del resultado y un requisito de que nada de eso se pueda probar solo en el simulador.
Una arquitectura mal adoptada es peor que cualquiera de las dos alternativas puras, y su cuadro clínico es siempre el mismo: enums de acciones gigantes con casos que hacen varias cosas a la vez, efectos que escriben en singletons, dependencias sin valor de prueba, exhaustividad desactivada en todas partes y una carpeta de tests que dejó de compilar hace tres meses y nadie arregló. Ese proyecto paga la ceremonia entera y no cobra ni una sola garantía. Si sospechas que ese es tu destino probable, no adoptes: escribe el mejor MVVM que sepas y hazlo con orgullo.
El equipo: es apetito, no capacidad
Casi todas las conversaciones sobre adopción se plantean como una pregunta de capacidad —si el equipo será capaz de aprenderla— y esa es la pregunta equivocada, porque la respuesta es casi siempre que sí. Cualquier persona que maneja SwiftUI y concurrencia puede aprender TCA en semanas. La variable que de verdad predice el resultado es el apetito: si a la gente le parece que nombrar cada evento es un precio razonable o una burocracia absurda.
Esa distinción importa porque una arquitectura solo funciona si se respeta cuando nadie mira. Un equipo sin apetito no la rechaza abiertamente, la subvierte, y el resultado tiene un cuadro clínico tan reconocible que se puede escribir.
// Sintomas de una adopcion sin apetito, todos vistos en produccion
enum Action {
case hacerTodo(bloque: @Sendable () async -> Void) // el reducer deja de decidir
case reemplazarEstado(State) // la vista escribe el modelo entero
case coordinador(Coordinador) // una referencia mutable en el valor
case evento(nombre: String, datos: [String: Any]) // el enum deja de enumerar nada
}
Cada uno de esos casos compila, cada uno parece un atajo razonable el día que se introduce, y todos juntos vacían la propuesta por completo: el primero devuelve la lógica a quien envía la acción, el segundo elimina la única frontera de escritura, el tercero rompe la semántica de valor y con ella la comparación y los tests, y el cuarto convierte el enum en un diccionario con otro nombre. El compilador puede obligar a agotar un switch, pero no puede obligar a que los casos signifiquen algo. De ahí una recomendación concreta y muy poco heroica: antes de decidir, haz que dos personas escriban una feature real cada una y pregúntales después, con sinceridad y sin coste social, si querrían escribir cincuenta más así. Esa respuesta predice el destino del proyecto mucho mejor que cualquier comparación de líneas.
La escalera de alternativas
Entre @State y TCA completa hay al menos cuatro peldaños intermedios, y casi nadie los usa porque el debate se plantea como binario. Merecen conocerse porque varios de ellos capturan una fracción grande del beneficio a una fracción pequeña del coste.
El primero es SwiftUI a secas con @State y @Observable, sin más estructura, para pantallas con estado local y poca coordinación. El segundo consiste en quedarse en MVVM pero adoptar solo la librería de dependencias: sigues teniendo clases observables y métodos, y ganas relojes controlables, valores de prueba y previsualización sin red. Es, con diferencia, el peldaño con mejor relación entre beneficio y coste, y es una posibilidad que mucha gente ignora porque asume que el ecosistema viene en bloque.
// Sin reducers, sin store: solo la costura testeable
@Observable
final class PerfilModel {
@ObservationIgnored @Dependency(\.api) var api
@ObservationIgnored @Dependency(\.continuousClock) var reloj
var perfil: Perfil?
}
El tercero añade la librería de navegación para modelar los destinos como datos sin adoptar reducers: los enlaces profundos y la restauración dejan de ser un problema, las pantallas siguen siendo modelos observables y desaparece la peor categoría de fallos de estado, la de la navegación que miente. El cuarto es un bucle unidireccional propio, que cabe en una docena de líneas y que mucha gente escribe sin darse cuenta de lo cerca que está de la arquitectura entera.
// Peldano cuatro: el bucle a mano, sin ninguna libreria
@Observable
final class Bucle<Estado, Accion> {
private(set) var estado: Estado
private let reducir: (inout Estado, Accion) -> Void
init(_ inicial: Estado, reducir: @escaping (inout Estado, Accion) -> Void) {
self.estado = inicial
self.reducir = reducir
}
func enviar(_ accion: Accion) { reducir(&estado, accion) }
}
Ese peldaño captura la trazabilidad y la frontera de escritura, que es mucho, y deja fuera exactamente las tres cosas que son caras de construir: el efecto como valor devuelto con cancelación por identificador, la composición de un padre con sus hijos mediante rutas, y sobre todo el TestStore con su exhaustividad. Quien lo elige debe saber que ha firmado un contrato de mantenimiento: cada una de esas tres piezas volverá a hacer falta con el tiempo, y reinventarlas una a una suele acabar en una versión peor de la librería que se quiso evitar. Es una opción legítima cuando el motivo real es el riesgo de dependencia y no la ceremonia, y es una mala opción cuando el motivo es que la ceremonia molesta, porque el bucle propio la conserva entera.
Una app madura suele terminar con dos o tres pantallas críticas en TCA —pagos, sincronización, sesión— y veinte pantallas en @Observable con dependencias inyectadas. Eso no es una adopción fracasada sino una decisión tomada por módulo, que es la unidad correcta. La única regla que hay que respetar es que la frontera coincida con una frontera de módulo real y que el estado no se comparta a través de ella por la puerta de atrás.
El prototipo no tiene futuro
La exhaustividad protege cambios venideros. Si el código muere en dos semanas, no hay nada que proteger.
Apetito, no capacidad
Aprenderla es fácil. Sostenerla cuando nadie mira depende de que el precio parezca razonable.
Hay peldaños intermedios
Las dependencias y la navegación se adoptan por separado, sin reducers ni store.
Mezclar es normal
Una app puede tener dos pantallas críticas en TCA y veinte en @Observable. Ese suele ser el final feliz.
flowchart TD A[Cuanto vive este codigo] -->|Semanas| P[State y Observable a secas] A -->|Anos| B[Cuantas pantallas y cuanta asincronia] B -->|Pocas| C[Observable mas libreria de dependencias] B -->|Muchas| D[Tiene el equipo apetito por la ceremonia] D -->|No| E[Observable mas dependencias mas navegacion] D -->|Si| F[TCA completa] style P fill:#a6e3a1,color:#11111b style F fill:#89b4fa,color:#11111b style E fill:#f9e2af,color:#11111b
Toda decisión de arquitectura es, en el fondo, una predicción sobre el futuro del código, y casi siempre se toma sin enunciar esa predicción, que es exactamente por lo que las discusiones no convergen. Cuando alguien elige estructura está afirmando algo verificable: que este código va a cambiar muchas veces, que los cambios los harán personas que hoy no lo conocen, que ciertos estados no deben poder ocurrir nunca y que descubrir un fallo tarde costará mucho más que prevenirlo. Cuando alguien elige velocidad afirma lo contrario: que el código cambiará poco o morirá pronto, que quien lo toque tendrá el contexto entero, y que un fallo se arregla en una tarde sin consecuencias. Las dos afirmaciones son estimaciones sobre el mundo, no gustos, y por tanto se pueden equivocar y se pueden comprobar. La forma de comprobarlas está en tu propio repositorio y casi nadie la mira: el historial de cambios dice qué archivos se tocan cada semana y cuáles llevan un año quietos, dice cuántas personas distintas han editado cada módulo, dice qué proporción de los commits son correcciones de fallos y en qué zonas se concentran. Un módulo que ha sido editado por siete personas en dieciocho meses y acumula la mitad de las correcciones del proyecto está gritando su predicción sin ambigüedad, y es ahí donde la ceremonia se paga sola. Un módulo escrito hace dos años, tocado por una sola persona y sin un solo fallo reportado no necesita nada, y reescribirlo con estructura es puro coste. La conclusión práctica es que la pregunta no debería formularse nunca a nivel de proyecto sino a nivel de módulo, y que la respuesta correcta para casi cualquier app de tamaño real es mixta. Lo verdaderamente profesional no es adoptar ni rechazar: es ser capaz de señalar, en tu propio código y con datos, los tres sitios donde la apuesta gana y los quince donde perdería, y actuar en consecuencia sin necesitar que la decisión sea uniforme para sentirse coherente.
- Escribe cuántas pantallas tiene tu app, cuántas coordinan más de una fuente asíncrona y cuántas manejan dinero, datos de salud o acciones irreversibles.
- Compara el plazo de la próxima entrega con las semanas de formación de tu equipo. Si el plazo pierde, la decisión ya está tomada para esta entrega.
- Pide a dos personas que escriban una feature real cada una y pregúntales después si escribirían cincuenta iguales. Anota la respuesta literal.
- Abre el historial del repositorio y saca los cinco archivos más editados y los cinco con más correcciones. Marca cuáles se solapan: esos son tus candidatos, y probablemente los únicos.
- Prueba el peldaño intermedio antes que el final: adopta solo la librería de dependencias en un módulo y mide cuántos tests nuevos pudiste escribir esa semana.