wandres.dev
DEPENDENCYKEY · live, test y preview

Definir tu dependencia: el struct de closures

Antes de registrar nada en el contenedor hay que decidir la forma de la dependencia. TCA no modela sus clientes con un protocolo y varias conformidades, sino con una struct cuyos campos son closures: una interfaz que es un valor y no un tipo. Esta lección explica por qué esa elección no es estilística sino estructural, qué problemas del protocolo evita, cómo modelar cada endpoint con Sendable, async y throws, y dónde está el límite real de la técnica.

⏱ 18 min

Cuando un desarrollador de Swift necesita abstraer un servicio externo, el reflejo aprendido es escribir un protocolo y luego dos o tres tipos que lo conformen: uno vivo, uno falso, quizá uno que siempre falla. TCA rompe ese reflejo desde la primera línea. Sus dependencias son struct cuyos campos son closures, y ese cambio de forma —de tipo a valor, de conformidad a campo— tiene consecuencias que se notan en cada test que escribas durante los próximos años. No es azúcar sintáctico ni preferencia de estilo: es la decisión de que la unidad de sustitución sea el endpoint y no la implementación completa. Entender por qué, y no solo copiar la plantilla, es lo que separa a quien usa TCA de quien la diseña bien.

🎯 Al terminar esta lección sabrás
  • Diagnosticar la factura oculta del protocolo con conformidades para modelar dependencias.
  • Escribir una dependencia como struct de closures con @Sendable, async y throws.
  • Sustituir un único endpoint sin construir un tipo nuevo, y componer decoradores como valores.
  • Reconocer el límite real de la técnica: una closure almacenada no puede ser genérica.

El reflejo del protocolo y su factura

La versión canónica que casi todos escribimos alguna vez tiene esta pinta: un protocolo con las operaciones y un tipo por cada modo de comportamiento.

protocol ClienteArticulos {
  func cargar() async throws -> [Articulo]
  func guardar(_ articulo: Articulo) async throws
}

struct ClienteArticulosLive: ClienteArticulos { /* red de verdad */ }
struct ClienteArticulosMock: ClienteArticulos { /* datos fijos */ }
struct ClienteArticulosFallo: ClienteArticulos { /* siempre lanza */ }

Parece limpio y factura tres costes que solo aparecen cuando el proyecto crece. El primero es combinatorio: cada test quiere una mezcla distinta —cargar bien pero guardar mal, cargar lento y guardar bien— y cada mezcla exige un tipo nuevo o una subclase con propiedades configurables que acaban siendo un contenedor de closures mal disimulado. El segundo es que el protocolo no admite parcheo local: no existe forma de decir usa la implementación viva pero con guardar cambiado sin escribir a mano un decorador que reenvíe todos los métodos restantes. El tercero es de tipos: en cuanto guardas la dependencia en el contenedor necesitas un existencial any ClienteArticulos, con su caja y su indirección, o bien genéricos que contaminan la firma del reducer.

Nada de esto es una acusación contra los protocolos, que siguen siendo la herramienta correcta cuando lo que modelas es de verdad una familia abierta de tipos con implementaciones que conviven en producción. La cuestión es que una dependencia rara vez es eso: en la app publicada hay exactamente una implementación viva, y todas las demás existen para pruebas y previews. Usar un mecanismo de polimorfismo abierto para un problema de sustitución cerrada es lo que genera la fricción.

Hay un cuarto coste, más sutil y más caro: el protocolo confunde dos cosas distintas. Un protocolo declara que un tipo pertenece a una familia; una dependencia solo quiere declarar que existe una manera de hacer estas operaciones. La primera es una afirmación sobre identidad y herencia nominal; la segunda es un simple paquete de funciones. Reificar ese paquete es una técnica clásica: es exactamente lo que hace un compilador de Haskell cuando convierte una restricción de typeclass en un diccionario de funciones que se pasa explícitamente. TCA hace lo mismo a mano y en Swift, y a ese diccionario se le llama testigo de protocolo.

La dependencia como valor

La forma canónica es una struct con un campo por operación. Cada campo es una closure @Sendable, porque va a viajar dentro de efectos que cruzan dominios de concurrencia, y se declara con var —no con let— precisamente para poder reasignarla.

struct ClienteArticulos: Sendable {
  var cargar: @Sendable () async throws -> [Articulo]
  var buscar: @Sendable (_ texto: String) async throws -> [Articulo]
  var guardar: @Sendable (_ articulo: Articulo) async throws -> Void
  var borrar: @Sendable (_ id: Articulo.ID) async throws -> Void
}

Cada decisión de esa firma dice algo, y conviene desglosarlas porque ninguna es accidental.

🧵

Por qué @Sendable

La closure viajará dentro de un efecto que puede ejecutarse en otro contexto de concurrencia. La anotación hace que el compilador verifique que no arrastra estado mutable sin proteger, y convierte en error de compilación una carrera que de otro modo aparecería como un test intermitente.

✏️

Por qué var y no let

Un campo de solo lectura no se puede reasignar, y toda la sustitución granular depende de poder escribir una asignación sobre un endpoint concreto. Declarar var es lo que convierte la interfaz en algo parcheable sin construir un valor entero desde cero.

⚠️

Por qué async throws

async reconoce que la operación tarda y libera el hilo; throws mantiene el fallo dentro del sistema de tipos en vez de esconderlo en un opcional que nadie inspecciona. Ambos obligan a quien llama a decidir explícitamente qué hace con la espera y con el error.

Las etiquetas de argumento con guion bajo cumplen un papel discreto: documentan cada parámetro en la declaración sin obligar a nombrarlo en la llamada, y serán exactamente la información que aproveche la macro de la cuarta lección para sintetizar métodos etiquetados. Y el hecho de que todo esto sea un tipo valor significa que copiar la dependencia y modificar la copia no afecta a nadie más: la sustitución es local por construcción, sin sincronización ni copias defensivas.

Fíjate en lo que no aparece. No hay URLSession, ni JSONDecoder, ni códigos de estado HTTP. El dominio de la app habla de artículos, no de peticiones; esa frontera es el tema de la lección cinco de este nivel, pero conviene verla ya desde la primera línea de la firma.

Sustituir un campo, no un tipo

Aquí es donde la elección paga. Como la interfaz es un valor y no un tipo, sustituir una operación concreta es asignar a un campo, y todo lo demás sigue como estaba.

await withDependencies {
  $0.clienteArticulos.cargar = { [.demo, .otro] }   // solo este endpoint
} operation: {
  // el resto del cliente conserva el valor que tuviera
}

Un test que solo ejercita la carga declara la carga; si por error toca guardar, se encontrará con el valor no implementado y fallará en voz alta. La granularidad de la sustitución bajó del tipo al campo, y con ella bajó el coste marginal de escribir un test más.

La misma propiedad regala los decoradores. Envolver un cliente para registrar, cachear o reintentar no requiere herencia ni un tipo nuevo: es una función que toma un valor y devuelve otro con un campo reemplazado.

extension ClienteArticulos {
  func conRegistro() -> Self {
    var copia = self
    let cargarOriginal = self.cargar
    copia.cargar = {
      let resultado = try await cargarOriginal()
      print("cargados \(resultado.count) articulos")
      return resultado
    }
    return copia
  }
}

Y como decorar es ahora una función de valor a valor, decorar dos veces es componer dos funciones: cliente.conRegistro().conReintentos() produce un valor nuevo sin que ninguna de las dos capas sepa de la existencia de la otra. Con herencia, esa misma composición exigiría una jerarquía o un patrón de cadena; aquí es la operación más elemental que existe en un lenguaje con funciones.

flowchart LR
P[protocolo] --> T1[tipo live]
P --> T2[tipo mock]
P --> T3[tipo que falla]
S[struct de closures] --> V1[valor live]
S --> V2[valor test]
V2 --> C[reasignar un solo campo]
style S fill:#89b4fa,color:#11111b
style C fill:#a6e3a1,color:#11111b

Lo que cuesta y dónde está el límite

Ninguna técnica es gratis, y conviene nombrar el precio antes de que lo descubras tú. La tabla resume las tres diferencias que de verdad importan en el día a día.

Aspecto Protocolo con conformidades struct de closures
Unidad de sustitución El tipo entero El campo individual
Añadir una operación Rompe todas las conformidades Rompe el inicializador, no los parches
Operación genérica Se expresa con naturalidad No se puede almacenar tal cual

La última fila es el límite duro y merece explicación. Swift no tiene valores de función polimórficos: una closure almacenada tiene un tipo concreto, así que un requisito como func decodificar<T: Decodable>() -> T no se puede convertir en un campo. Las salidas honestas son monomorfizar —un campo por tipo concreto que de verdad uses— o pasar el tipo como parámetro de valor y devolver Data, dejando la decodificación fuera del cliente. Si te descubres peleando contra esto, casi siempre la señal es que la dependencia está intentando ser un framework en vez de un servicio.

La segunda fila también esconde un matiz que conviene anticipar. Con el inicializador por miembros que sintetiza Swift, añadir un campo rompe la compilación de todo valor construido explícitamente, lo cual es una red de seguridad excelente: te obliga a implementar el endpoint nuevo en la versión viva. En la lección cuatro verás que la macro @DependencyClient cambia ese comportamiento al dar valores por defecto a cada campo, y por qué ese intercambio exige una disciplina compensatoria.

📝
Lo esencial

Una dependencia en TCA se modela como una struct de closures @Sendable declaradas con var, no como un protocolo con conformidades. Esa forma reifica la interfaz como un valor —el testigo del protocolo— y baja la unidad de sustitución del tipo entero al endpoint individual, con lo que un test puede cambiar una sola operación sin fabricar un tipo nuevo y un decorador se vuelve una función de valor a valor. El precio es real y acotado: pierdes la expresión directa de operaciones genéricas, porque una closure almacenada no puede ser polimórfica.

💡
Conformar Sendable y evitar la captura tramposa

Marca la struct como Sendable y las closures como @Sendable. El compilador te avisará si una implementación captura una clase mutable sin protección, que es exactamente el error que quieres que salte en compilación y no en un test intermitente. Si necesitas estado interno —una caché, un contador— encapsúlalo en un actor creado dentro de la implementación viva y captúralo por la closure: el estado queda aislado y la dependencia sigue siendo un valor.

Sustituir herencia por valores es mover la unidad de sustitución del tipo al campo

Lo que ocurre al pasar del protocolo a la struct de closures no es una simplificación sintáctica sino un cambio en la granularidad de la variabilidad, y por eso sus efectos son tan desproporcionados respecto a lo poco que cambia el código. Con un protocolo, la menor cosa que puedes variar es una implementación completa: para alterar un método debes producir un tipo entero, con lo que el espacio de comportamientos que tu suite necesita —cargar bien y guardar mal, buscar vacío y borrar lento— se enfrenta a una explosión combinatoria que solo se contiene añadiendo a los dobles propiedades configurables, es decir, reinventando el struct de closures con peor forma. Con la struct, la menor cosa que puedes variar es un endpoint, y el conjunto de comportamientos posibles pasa a ser el producto de los campos en vez del conjunto de los tipos que alguien se molestó en escribir. Detrás hay una equivalencia teórica conocida: un protocolo es una interfaz nominal y su testigo reificado es el diccionario de funciones que el compilador pasaría igualmente por debajo; al hacerlo explícito no pierdes expresividad salvo en el caso genuinamente polimórfico, y ganas la posibilidad de manipular esa interfaz como cualquier otro dato —copiarla, parchearla, envolverla, componerla—. Esa es también la razón profunda de que los decoradores dejen de necesitar herencia: cuando la interfaz es un valor, decorar es una función de valor a valor, y la composición de decoradores es simple composición de funciones, asociativa y sin ceremonia. Verás la misma jugada en toda la librería: los efectos son valores en vez de llamadas, la navegación es un valor en vez de una orden imperativa y ahora las dependencias son valores en vez de tipos. TCA no tiene tres buenas ideas, tiene una sola aplicada con obstinación: convierte en dato todo lo que otras arquitecturas dejan como estructura, porque el dato se puede inspeccionar, sustituir y afirmar en un test, y la estructura no.

⚔️ Convierte un protocolo en un valor
  1. Toma un protocolo de servicio de tu proyecto y cuenta cuántos tipos lo conforman solo para efectos de test; ese número es la factura que estás pagando.
  2. Reescríbelo como struct de closures con @Sendable, async y throws, y comprueba que la implementación viva cabe en un solo valor.
  3. Escribe dos escenarios de test que difieran en un único endpoint y verifica que ninguno necesita un tipo nuevo.
  4. Implementa un decorador conRegistro que envuelva una sola operación y demuestra que se puede aplicar dos veces sin herencia.
  5. Busca en tu protocolo un método genérico; explica por qué no se puede almacenar como campo y decide si monomorfizarlo o sacarlo del cliente.