DependencyClient: la macro que escribe el cliente por ti
La macro DependencyClient genera el inicializador con valores por defecto, convierte cada endpoint en una implementación inerte que falla con un mensaje que lo nombra y sintetiza métodos con etiquetas de argumento. Esta lección detalla qué expande exactamente, cómo hace que el testValue sea una línea, qué exige de cada firma, cómo renombrar endpoints con DependencyEndpoint y cuál es la garantía de compilación que pierdes a cambio de la ergonomía.
A estas alturas sabes escribir una dependencia entera a mano: la struct de closures, el liveValue, el testValue con cada endpoint no implementado, el inicializador. Y también sabes que ese código es mecánico, repetitivo y, por eso mismo, un imán de erratas: un endpoint que olvidaste poner en el testValue es una petición real disfrazada de test verde. @DependencyClient existe para que ese código lo escriba el compilador. Pero una macro no es magia: es una transformación sintáctica con reglas, exigencias sobre las firmas y un intercambio explícito de garantías. Esta lección abre la caja, mira la expansión y nombra el precio, porque usar una macro sin saber qué genera es delegar tu arquitectura a un ritual.
- Enumerar las tres cosas que expande
@DependencyClienty para qué sirve cada una. - Derivar el
testValueen una sola línea y leer el mensaje de fallo que nombra el endpoint. - Cumplir lo que la macro exige de cada firma y usar
@DependencyEndpointcuando haga falta. - Reconocer la garantía de compilación que se pierde y la disciplina que la compensa.
Tres expansiones, no una
Anota la struct y añade la conformidad; el resto aparece.
@DependencyClient
struct ClienteArticulos: Sendable {
var cargar: @Sendable () async throws -> [Articulo]
var buscar: @Sendable (_ texto: String, _ pagina: Int) async throws -> [Articulo]
var guardar: @Sendable (_ articulo: Articulo) async throws -> Void
}
Inicializador con defectos
Cada campo recibe un valor por defecto, así que construir un cliente parcial deja de exigir nombrar los endpoints que no te interesan. Es lo que hace posible montar un escenario mencionando solo lo relevante.
Endpoints inertes
Esos valores por defecto son implementaciones que, al ser invocadas, reportan un fallo con el nombre del endpoint incrustado en el mensaje y lanzan si la firma lo permite.
Métodos etiquetados
Por cada closure con etiquetas de argumento aparece un método que las conserva, de modo que la llamada se verifica por nombre y no solo por posición.
La primera expansión es la que cambia la ergonomía; la segunda es la que sostiene toda la disciplina de la lección anterior sin obligarte a mantener una lista paralela; la tercera es la que menos se anuncia y más errores evita en el día a día.
// Sin la macro, la closure se llama por posición:
try await cliente.buscar("swift", 2)
// Con la macro existe además el método con etiquetas:
try await cliente.buscar(texto: "swift", pagina: 2)
Ese detalle parece cosmético y no lo es: los guiones bajos de la firma dejan de ser documentación decorativa y pasan a ser parte de la interfaz que el compilador verifica, con lo que invertir dos argumentos del mismo tipo deja de ser un error silencioso.
Importa entender que el método sintetizado convive con el campo en lugar de reemplazarlo. Al llamar usas el método, con sus etiquetas; al sobrescribir sigues asignando al campo, porque un método no es reasignable. Esa dualidad —campo para parchear, método para invocar— es la que permite tener a la vez la ergonomía de una API con nombres y la maleabilidad de un valor.
El testValue en una línea
Como todos los campos tienen valor por defecto inerte, el testValue completo se reduce a construir el cliente vacío. Ya no hay una lista de unimplemented que mantener en paralelo con la lista de endpoints, y por tanto ya no puede desincronizarse.
extension ClienteArticulos: TestDependencyKey {
static let testValue = ClienteArticulos() // todo inerte: nada funciona
}
extension ClienteArticulos: DependencyKey {
static let liveValue = ClienteArticulos(
cargar: { try await Backend.articulos() },
buscar: { texto, pagina in try await Backend.buscar(texto, pagina) },
guardar: { articulo in try await Backend.enviar(articulo) }
)
}
Observa que las dos conformidades pueden vivir en módulos distintos, tal como viste en la lección anterior: la de TestDependencyKey acompaña a la struct en el módulo de interfaz y la de DependencyKey viaja con el módulo pesado. La macro no interfiere en ese reparto, porque solo actúa sobre la declaración del tipo.
La ganancia no es solo de líneas escritas sino de líneas que ya no pueden equivocarse. En la versión manual, la lista de unimplemented y la lista de campos eran dos textos independientes que un humano debía mantener sincronizados; en la versión con macro son el mismo texto leído dos veces por el compilador, y una clase entera de errores desaparece por construcción en vez de por atención.
Y el mismo valor por defecto habilita la construcción parcial en cualquier ámbito, que es la forma más limpia de montar un escenario: nombras lo que el escenario necesita y el resto queda armado para denunciar cualquier uso imprevisto.
withDependencies {
$0.clienteArticulos = ClienteArticulos(cargar: { [.demo] })
}
flowchart TB M[anotacion DependencyClient] --> I[inicializador con valores por defecto] M --> U[endpoints inertes que reportan y lanzan] M --> E[metodos con etiquetas de argumento] I --> TV[testValue igual a cliente vacio] U --> MSG[mensaje de fallo con el nombre del endpoint] E --> SEG[llamadas verificadas por el compilador] style M fill:#89b4fa,color:#11111b style TV fill:#a6e3a1,color:#11111b
Lo que la macro exige de tus firmas
La expansión no puede inventar valores de la nada, y de ahí salen sus reglas. Todos los campos deben ser var y deben ser closures; una propiedad almacenada normal no se puede volver inerte. Cada closure que devuelve algo distinto de Void debe poder lanzar o traer un valor por defecto explícito, porque si no, la implementación inerte no tendría nada que devolver tras reportar el fallo. Conviene tratar los errores de la macro como lo que son, mensajes de un compilador que razona sobre tu declaración, y leerlos enteros: casi siempre dicen exactamente qué falta.
@DependencyClient
struct Ajustes: Sendable {
var idioma: @Sendable () -> String = { "es" } // sin throws: exige defecto
var version: @Sendable () throws -> String // con throws: la macro se apaña
var registrar: @Sendable (_ evento: String) -> Void // Void: no hay problema
}
Cuando el nombre del método sintetizado no te convenga o choque con otro, @DependencyEndpoint te deja renombrarlo sin tocar el campo. Y si un endpoint necesita una implementación inerte distinta —devolver un valor concreto en vez de fallar— basta con darle un valor por defecto en la declaración, que es el mecanismo general del que la macro depende.
Esa asimetría entre throws y no throws no es un capricho de la implementación sino una consecuencia lógica. Una implementación inerte tiene que hacer dos cosas: denunciar y devolver el control. Si puede lanzar, denunciar y lanzar cierra el asunto sin inventar datos. Si no puede lanzar y debe producir un valor, la macro tendría que fabricar un habitante del tipo de retorno, y no existe forma general y honesta de hacerlo; por eso te pide que lo elijas tú. Verlo así te evita leer el error de la macro como un obstáculo y te deja leerlo como lo que es: la pregunta qué debería devolver esto cuando nadie lo ha implementado, que solo tú puedes responder.
Merece la pena recordar, además, que la macro se ocupa de la forma del cliente y no de su registro. Seguirás escribiendo la conformidad a DependencyKey, el liveValue y la extensión de DependencyValues exactamente igual que en la segunda lección: lo que desaparece es el trabajo repetitivo, no las decisiones.
Al tener el inicializador un valor por defecto para cada campo, añadir un endpoint nuevo ya no rompe la compilación del liveValue. Antes, con el inicializador por miembros de Swift, olvidarte de implementarlo era un error de compilación; ahora es un endpoint inerte que solo grita cuando alguien lo invoca en ejecución. Es un intercambio consciente, y la contrapartida es disciplina: revisa el liveValue cada vez que amplíes el cliente, y respáldalo con un test de integración de la implementación viva, que es el tema de la lección siguiente.
Cuándo no usarla
Una macro que genera código es una dependencia más de tu proyecto: exige el paquete de macros, alarga la compilación limpia y añade una capa entre lo que escribes y lo que se compila. Para clientes de tres endpoints en un módulo pequeño, escribir el testValue a mano con unimplemented es perfectamente razonable y deja el código completamente literal. Para clientes que crecen, se comparten entre features y cambian a menudo, la macro se paga sola en la primera desincronización que evita. La señal que suele decidir es la volatilidad: si la lista de endpoints se toca cada sprint, deja que la escriba el compilador.
Tampoco es una decisión que haya que tomar de una vez para todo el proyecto: la macro se aplica tipo a tipo, así que puedes anotar los clientes grandes y volátiles y dejar a mano los tres endpoints de un cliente estable. Lo importante es que la elección sea consciente y no inercia.
Sea cual sea tu elección, adquiere el hábito de expandir la macro en el editor de vez en cuando y leer el resultado. No es curiosidad ociosa: cuando un error de compilación apunte a código que tú no escribiste, la única forma de interpretarlo es saber qué había ahí, y esa lectura convierte la macro de caja negra en una abreviatura que dominas.
@DependencyClient expande tres cosas: un inicializador con valor por defecto para cada campo, implementaciones inertes que reportan un fallo con el nombre del endpoint, y métodos que conservan las etiquetas de argumento de cada closure. Gracias a ello el testValue se reduce a construir el cliente vacío y nunca puede quedarse desincronizado respecto a la struct. Exige campos var que sean closures y un valor por defecto para las que no lanzan y devuelven algo distinto de Void. A cambio pierdes el error de compilación que te obligaba a implementar cada endpoint nuevo en el liveValue, y esa red hay que reponerla con pruebas de la implementación real.
Merece la pena mirar de frente lo que ocurre aquí, porque es un caso de estudio de un dilema que reaparece en todo diseño de herramientas. La versión manual apoyaba una propiedad fuerte: el inicializador por miembros de Swift es total, exige un valor para cada campo, y por tanto el conjunto de endpoints implementados en el liveValue coincidía por construcción con el conjunto de endpoints declarados; añadir uno rompía la compilación y no había forma de olvidarlo. @DependencyClient debilita esa totalidad al dar valores por defecto, y con ello traslada la comprobación del tiempo de compilación al tiempo de ejecución. Cambiar una garantía estática por una dinámica es normalmente un mal negocio, y aquí no lo es por una razón precisa: el fallo resultante es máximamente observable —reporta con el nombre del endpoint, en el punto exacto de la llamada— y ocurre en un contexto donde alguien está mirando, porque la implementación inerte solo se alcanza cuando el código realmente ejercita esa ruta. Es decir, la macro no oculta el error: cambia el momento y la forma en que aparece, y a cambio elimina una clase entera de errores distinta y más insidiosa, la de un testValue que se quedó atrás respecto a la struct y dejó pasar peticiones reales sin decir nada. La enseñanza general es que las garantías no se comparan por su fuerza nominal sino por el producto de su fuerza y su alcance: una comprobación estática que se mantiene manualmente y se desincroniza deja de valer lo que dice valer, mientras que un fallo dinámico sistemáticamente generado por una macro nunca se desincroniza porque no lo escribe una persona. Aceptar el trato conlleva, eso sí, una obligación concreta: como nadie te obligará ya a implementar el endpoint nuevo en el liveValue, debes crear tú la fuerza que lo verifique, y esa fuerza es un test de integración que ejercita la implementación real. Toda automatización que retira una restricción del compilador debe reponerla en otro sitio; si no la repones, no simplificaste el sistema, solo moviste su fragilidad a un lugar donde no la miras.
- Anota tu cliente con
@DependencyClienty usa la acción de expandir la macro en Xcode para leer el código generado línea a línea. - Reduce tu
testValuea la construcción del cliente vacío y verifica que los tests siguen pasando exactamente igual. - Provoca un fallo llamando desde un test a un endpoint no sobrescrito y comprueba que el mensaje nombra el endpoint.
- Declara una closure sin
throwsque devuelva un valor noVoid, lee el error de la macro y arréglalo con un valor por defecto. - Añade un endpoint nuevo sin tocar el
liveValue, confirma que compila y escribe la prueba que habrías necesitado para detectarlo.