Envolver lo existente: servicios y singletons como dependencias
El código heredado que da miedo tocar no hay que tocarlo: hay que abrirle una costura. Esta lección convierte servicios, singletons y managers actuales en dependencias de TCA sin reescribir ni una línea de su implementación, usando un `liveValue` que se limita a delegar. Cubre el diseño de la interfaz que se extrae —que debe hablar el idioma del dominio y no el de la clase envuelta—, el paso de delegados y `NotificationCenter` a flujos asíncronos, los problemas reales de aislamiento que aparecen con Swift 6, y la disciplina de envolver solo lo que la pantalla migrada consume.
En toda app con años hay una clase que nadie quiere abrir. Tiene mil quinientas líneas, un nombre acabado en Manager, una propiedad estática compartida, media docena de responsabilidades que se fueron pegando y un comentario de 2020 que dice que hay que refactorizarla. La buena noticia es que para adoptar TCA no hace falta refactorizarla, ni entenderla entera, ni siquiera abrirla. Hace falta algo mucho más barato y bastante más útil: describir con precisión qué le pide tu pantalla, escribir esa descripción como un tipo, y hacer que la implementación viva se limite a reenviarle las llamadas. Eso es una costura, y una costura bien puesta convierte un obstáculo insalvable en un detalle de implementación que se puede sustituir el día que apetezca.
- Envolver un singleton existente en un
structde closures cuyoliveValuesolo delega, sin modificar la clase original. - Diseñar la interfaz extraída en el vocabulario del dominio y no en el de la clase envuelta.
- Convertir delegados, cierres de finalización y notificaciones en
AsyncStreamy funcionesasync. - Resolver los problemas de aislamiento y
Sendableque aparecen al cruzar código heredado hacia Swift 6.
La costura: envolver en lugar de reescribir
El patrón es el del Nivel 13 aplicado a código que no controlas. Se declara el struct de closures que describe lo que necesitas, y el liveValue no implementa nada: reenvía a lo que ya existe. La clase original no se entera de que la están usando desde otra arquitectura, y ese es exactamente el objetivo.
@DependencyClient
struct SesionCliente {
var usuarioActual: @Sendable () -> Usuario?
var iniciar: @Sendable (_ correo: String, _ clave: String) async throws -> Usuario
var cerrar: @Sendable () async -> Void
var cambios: @Sendable () -> AsyncStream<Usuario?> = { .never }
}
extension SesionCliente: DependencyKey {
static let liveValue = Self(
usuarioActual: { SessionManager.shared.currentUser.map(Usuario.init) },
iniciar: { correo, clave in
Usuario(try await SessionManager.shared.login(email: correo, password: clave))
},
cerrar: { await SessionManager.shared.logout() },
cambios: {
AsyncStream { continuacion in
let token = SessionManager.shared.observe { continuacion.yield($0.map(Usuario.init)) }
continuacion.onTermination = { _ in SessionManager.shared.removeObserver(token) }
}
}
)
static let testValue = Self()
}
Tres decisiones merecen atención. El liveValue no contiene lógica, solo traducción, y esa disciplina es lo que garantiza que envolver no pueda introducir defectos nuevos. El testValue vacío que genera @DependencyClient falla ruidosamente si alguien invoca algo que el test no declaró, con lo que la costura viene con su propio detector de dependencias ocultas. Y el tipo Usuario del dominio se construye en la frontera, de modo que el modelo heredado —probablemente una clase con referencias y opcionales por todas partes— no entra en tu State.
La tentación de reproducir los cuarenta métodos del Manager en el cliente es fuerte y es un error caro: te obliga a entender todo antes de migrar nada y produce una interfaz que nadie ha diseñado. Envuelve solo lo que la pantalla que estás migrando invoca hoy. El cliente crecerá una función cada vez, y cuando ya no crezca sabrás que has encontrado su superficie real.
Diseñar la interfaz que te hubiera gustado tener
Aquí está el beneficio inesperado. La costura no está obligada a parecerse a lo que envuelve: es una oportunidad de rediseño con coste cero, porque cambiar la firma de la costura no cuesta nada mientras la implementación siga siendo un reenvío. Un cliente que expone sincronizar con un cierre de finalización, una bandera booleana y un parámetro force que nadie sabe explicar puede envolverse en una función async que devuelve un valor o lanza, y ese cambio hace que el reducer se escriba en dos líneas en vez de en quince.
// Lo que existe, con su historia encima
final class SyncManager {
static let shared = SyncManager()
func sync(force: Bool, completion: @escaping (NSError?) -> Void) { ... }
}
// La interfaz que querías: dominio, no mecánica
@DependencyClient
struct Sincronizacion {
var ejecutar: @Sendable (_ forzada: Bool) async throws -> Void
}
extension Sincronizacion: DependencyKey {
static let liveValue = Self(
ejecutar: { forzada in
try await withCheckedThrowingContinuation { c in
SyncManager.shared.sync(force: forzada) { fallo in
if let fallo { c.resume(throwing: fallo) } else { c.resume(returning: ()) }
}
}
}
)
}
Dos criterios ayudan a decidir la forma de la interfaz. El primero es que hable de intenciones del dominio y no de pasos técnicos: guardarBorrador en vez de write más flush más invalidateCache. El segundo es que sea la mínima que satisface a quien la usa hoy, porque una interfaz estrecha es fácil de sustituir y una ancha se convierte en otro singleton con otra cara.
withCheckedContinuation exige exactamente una reanudación. Muchos cierres de finalización heredados no cumplen ese contrato: hay rutas que no llaman al cierre, y hay otras que lo llaman dos veces porque alguien arregló un fallo añadiendo una llamada. La versión comprobada avisa en consola de la doble reanudación, pero de la ausencia no avisa nadie: se manifiesta como un efecto que nunca termina y una pantalla que se queda cargando. Antes de envolver un cierre, lee sus caminos de salida.
Delegados, notificaciones y todo lo que empuja
El código heredado no solo se llama, también avisa. Delegados, NotificationCenter, KVO y observadores propios son fuentes que empujan valores, y su forma natural en la costura es un AsyncStream que el reducer consume dentro de un .run, tal y como aprendiste con los efectos de larga vida del Nivel 10.
@DependencyClient
struct Conectividad {
var estados: @Sendable () -> AsyncStream<Bool> = { .never }
}
extension Conectividad: DependencyKey {
static let liveValue = Self(
estados: {
AsyncStream { continuacion in
let observador = NotificationCenter.default.addObserver(
forName: .reachabilityChanged, object: nil, queue: nil
) { nota in
continuacion.yield(nota.userInfo?["online"] as? Bool ?? false)
}
continuacion.onTermination = { _ in
NotificationCenter.default.removeObserver(observador)
}
}
}
)
}
El bloque onTermination no es opcional ni cosmético: es lo que hace que cancelar el efecto desmonte el observador. Sin él, cada aparición de la pantalla registra un oyente más y el defecto tarda semanas en manifestarse, en forma de trabajo duplicado que crece con la sesión.
| Forma heredada | Forma en la costura | Qué hay que cuidar |
|---|---|---|
| Cierre de finalización | Función async throws |
Que toda ruta reanude la continuación una vez |
| Delegado con varios métodos | AsyncStream de un enum |
Retener el objeto delegado mientras dure el flujo |
| Notificación | AsyncStream con onTermination |
Retirar el observador al cancelar |
| Propiedad observada | AsyncStream o lectura directa |
No copiarla al State si el dueño es el otro mundo |
Aislamiento: el peaje de Swift 6
La costura es también la aduana de concurrencia, y ahí aparecen la mayoría de los errores de compilación de una migración real. La clase heredada casi nunca es Sendable, muchas veces exige el hilo principal sin declararlo y sus modelos son clases mutables compartidas. Las tres respuestas prácticas, por orden de preferencia: aislar la closure con @MainActor cuando la clase realmente lo necesita, convertir en la frontera el modelo heredado a un valor Sendable de tu dominio, y solo en último término apoyarse en @preconcurrency o en un nonisolated(unsafe) documentado con el motivo y con fecha de revisión.
En la práctica, la mayoría de los casos se resuelven envolviendo la lectura en MainActor.assumeIsolated dentro del liveValue, que es una afirmación verificable en tiempo de ejecución y no una promesa vacía. La regla de fondo es que lo no seguro se queda dentro de la costura. Si el liveValue es el único punto del sistema que toca la clase heredada, el análisis de riesgos se reduce a auditar un archivo pequeño, en vez de rastrear cuarenta llamadas repartidas por la app.
Costura, no refactor
Cambiar el comportamiento sin editar el sitio donde ocurre. La clase envuelta no se toca.
Rediseño gratis
La firma de la costura la eliges tú. Es la interfaz que querías, con la implementación que hay.
Lo que empuja, un flujo
Delegados y notificaciones se vuelven AsyncStream. onTermination desmonta el oyente.
La aduana de concurrencia
Lo que no es Sendable muere en el liveValue. Al State solo entran valores.
flowchart LR R[Reducer] --> D[Dependencia declarada] D --> L[liveValue que solo delega] D --> T[testValue sin implementar] D --> P[previewValue con datos fijos] L --> S[Singleton heredado sin tocar] S --> N[NotificationCenter y delegados] N --> A[AsyncStream con onTermination] style D fill:#89b4fa,color:#11111b style S fill:#fab387,color:#11111b
Existe una asimetría persistente entre lo que una clase heredada ofrece y lo que su aplicación realmente consume, y envolverla es el procedimiento que la mide. Cuando terminas de migrar tres pantallas y miras el cliente que ha ido creciendo, encuentras casi siempre lo mismo: seis o siete funciones frente a las cuarenta que la clase publica. Esa diferencia no es un defecto de tu envoltorio, es un dato empírico sobre el sistema, y es un dato que nadie tenía antes porque no había forma de obtenerlo leyendo el código: los métodos públicos que nadie llama y los que se llaman desde cinco sitios se parecen mucho en un archivo. La costura los separa por el único criterio que importa, que es el uso, y produce como subproducto la especificación que hará falta el día que alguien se decida a reescribir la clase de verdad. Merece la pena detenerse en lo que eso significa para el orden de las decisiones. La sabiduría convencional dice que primero se rediseña el servicio y después se migra la interfaz que lo usa, porque el servicio es la base. Envolver invierte ese orden y con razón: primero se migra la interfaz, que es donde está el conocimiento sobre qué se necesita, y el rediseño del servicio queda para cuando ese conocimiento ya existe y está escrito en un tipo que el compilador verifica. Rediseñar antes es diseñar a ciegas, con la agravante de que un servicio rediseñado obliga a cambiar a todos sus clientes a la vez, que es justo el cambio grande y no publicable que toda esta estrategia trata de evitar. Y hay un rendimiento final que solo se cobra tarde. Cuando llegue el día de sustituir el singleton por una implementación nueva, la operación consistirá en escribir otro liveValue y cambiar una línea; y podrás hacerlo pantalla a pantalla, sobrescribiendo la dependencia por ámbito como aprendiste en el Nivel 12, con la implementación nueva conviviendo con la vieja mientras dure la duda. El código heredado deja de ser un bloque que hay que levantar entero y pasa a ser algo que se sustituye por partes, que es la definición operativa de haber recuperado el control sobre él.
- Elige el
Managerque nadie quiere tocar y cuenta sus métodos públicos. Apunta el número; lo vas a comparar al final. - Toma la pantalla que estás migrando y escribe el
structde closures con lo que esa pantalla invoca, y solo eso. Diseña las firmas como te gustaría que fueran, no como son. - Escribe el
liveValuereenviando a la clase. Si en algún punto te ves escribiendo lógica, párate: esa lógica pertenece al reducer o a la clase, nunca a la costura. - Convierte al menos un delegado o una notificación en un
AsyncStreamy comprueba con un contador que al cerrar la pantalla el oyente se retira de verdad. - Escribe un test de la feature con el
testValuesin implementar. Cada fallo por dependencia no declarada es una llamada oculta que acabas de descubrir. Compara ahora la cuenta de funciones con la del paso uno.