wandres.dev
EL SISTEMA @DEPENDENCY · inyección sin ceremonia

@Dependency: declarar lo que la feature necesita

El property wrapper Dependency invierte quién manda: en lugar de que el reducer vaya a buscar el mundo, declara qué trozos del mundo necesita y el entorno se los entrega. Esta lección disecciona el mecanismo —el contenedor DependencyValues, las rutas de clave que lo indexan, el property wrapper que las lee— y recorre el catálogo de dependencias que la biblioteca swift-dependencies trae ya registradas: reloj, fecha, UUID, colas, apertura de URLs, contexto de ejecución. También examina un detalle crítico y contraintuitivo: cuándo exactamente se resuelve el valor de una dependencia, por qué se captura en la inicialización del reducer y qué consecuencias tiene eso para la sobrescritura en ámbitos anidados.

⏱ 18 min

La solución de TCA al mundo cableado cabe en una inversión gramatical: el reducer deja de tomar el mundo y pasa a recibirlo. En vez de escribir Date() —un verbo en voz activa, el código yendo a buscar el reloj—, escribes una declaración de necesidad: esta feature requiere saber la hora, y quien la ejecute decidirá qué reloj le entrega. El property wrapper @Dependency es la sintaxis de esa declaración, y DependencyValues es el almacén del que lee. Lo notable es la desproporción entre lo poco que cuesta —una línea por dependencia, sin protocolos, sin fábricas, sin contenedor que registrar en el arranque— y lo mucho que compra: determinismo, previews vivas y la capacidad de sustituir el entorno completo en un ámbito acotado. Esta lección explica cómo funciona esa maquinaria y qué trae ya montado.

🎯 Al terminar esta lección sabrás
  • Declarar dependencias con @Dependency y comprender el papel de las rutas de clave sobre DependencyValues.
  • Distinguir entre leer una capacidad completa y leer una propiedad concreta de ella, y cuándo conviene cada opción.
  • Recorrer el catálogo de dependencias que swift-dependencies registra por defecto y saber qué resuelve cada una.
  • Entender cuándo se resuelve el valor de una dependencia y por qué ese instante determina qué sobrescrituras la alcanzan.

Declarar la necesidad en vez de satisfacerla

La declaración vive en el struct del reducer, junto a las propiedades, y queda accesible desde reduce como si fuera un campo más. La diferencia con un campo normal es que nadie la pasa por el inicializador: el valor se resuelve consultando el contenedor de dependencias vigente en ese momento.

@Reducer
struct Feature {
  @Dependency(\.uuid) var uuid
  @Dependency(\.date.now) var ahora
  @Dependency(\.continuousClock) var clock

  @ObservableState
  struct State: Equatable { var items: [Item] = [] }

  enum Action { case guardarPulsado }

  func reduce(into state: inout State, action: Action) -> Effect<Action> {
    switch action {
    case .guardarPulsado:
      state.items.append(Item(id: uuid(), creado: ahora))
      return .none
    }
  }
}

Léelo como se lee una firma de función y verás lo que de verdad está pasando. Las tres primeras líneas son la lista de argumentos que el lenguaje no te obligaba a escribir: este reducer necesita generar identificadores, saber la hora y esperar. Cualquiera que abra el archivo conoce en tres segundos el acoplamiento completo de la feature con el exterior, sin leer una sola línea de reduce.

La lectura ingenua es que esto no cambió nada: antes escribías UUID() y ahora uuid(), un carácter de diferencia. La lectura correcta es que cambió el sujeto. UUID() invoca al generador del sistema, siempre, sin alternativa. uuid() invoca a lo que el contenedor tenga registrado en este ámbito, que en producción es el generador del sistema y en un test es lo que tú decidas. La expresión dejó de nombrar una implementación y pasó a nombrar una capacidad. Un carácter de diferencia en el texto, un cambio de categoría en el significado.

Conviene detenerse en lo que no aparece en ese código, porque es tan informativo como lo que aparece. No hay un protocolo RelojProtocolo con una implementación real y otra falsa. No hay una fábrica. No hay un contenedor que registrar en el punto de entrada de la app. No hay un inicializador con tres parámetros que alguien tenga que rellenar. El reducer se construye escribiendo Feature(), exactamente igual que si no tuviera dependencias, y sin embargo las tiene y son sustituibles. Esa ausencia de ceremonia no es una comodidad menor: es lo que determina si el mecanismo se usa siempre o solo cuando hay tiempo.

Fíjate en la asimetría de las dos primeras líneas del ejemplo. \.uuid lee el generador entero —un valor invocable, por eso luego escribes uuid()—, mientras que \.date.now desciende un nivel más y lee directamente el instante. Puedes hacer ambas cosas porque una ruta de clave puede apuntar a cualquier profundidad. La regla práctica: lee lo más específico que necesites. Si tu reducer solo quiere el instante actual, \.date.now deja esa intención escrita en la declaración y evita que alguien use el generador para otra cosa.

DependencyValues: un contenedor indexado por tipos

Detrás de esas rutas de clave hay una estructura sorprendentemente simple. DependencyValues es un diccionario heterogéneo donde la clave no es una cadena de texto sino un tipo que conforma DependencyKey, y el valor es lo que ese tipo declara como su valor por defecto. La extensión con la propiedad calculada es solo azúcar para que la ruta de clave exista y el acceso sea legible.

extension DependencyValues {
  var clienteAPI: ClienteAPI {
    get { self[ClienteAPI.self] }
    set { self[ClienteAPI.self] = newValue }
  }
}

Es útil interiorizar que ahí no hay magia de compilación ni tabla oculta: DependencyValues es una estructura de datos corriente, la ruta de clave es una propiedad calculada corriente y el property wrapper solo hace una lectura. Todo el sistema se podría reescribir a mano en una tarde; lo que aporta la biblioteca no es potencia inaccesible sino un diseño cuidado de esas piezas triviales.

Que la clave sea un tipo tiene tres consecuencias que conviene nombrar. Primera: no hay colisiones posibles, porque dos módulos distintos no pueden declarar el mismo tipo, mientras que dos cadenas de texto sí pueden coincidir. Segunda: no hay registro en tiempo de arranque —el valor por defecto viaja con el tipo, así que una dependencia definida en un módulo funciona en cuanto lo importas, sin tocar el punto de entrada de la app—. Tercera: el acceso es de tipo seguro y sin conversiones, porque el compilador conoce el tipo del valor a partir de la clave.

💡
La ruta de clave es un nombre publico, la clave es privada

Nota la separación de responsabilidades entre las dos piezas. El tipo que conforma DependencyKey es el identificador real y puede ser interno al módulo; la propiedad calculada sobre DependencyValues es el nombre público con el que el resto del código la lee. Eso te deja renombrar la ruta sin tocar la clave, o exponer varias rutas convenientes sobre una misma clave. Es la misma disciplina que aplicas al separar el almacenamiento de la interfaz en cualquier tipo bien diseñado.

Compáralo con el arquetipo del contenedor de inyección clásico, donde registras implementaciones contra protocolos en el arranque y resuelves por búsqueda. Aquel esquema falla en tiempo de ejecución si olvidas un registro; este no puede fallar, porque toda dependencia tiene un valor por defecto por construcción. La ausencia de una fase de configuración global no es una simplificación cosmética: es lo que permite que un módulo de biblioteca declare sus dependencias sin obligar a sus consumidores a saberlo.

El catálogo incluido

swift-dependencies no te deja empezar de cero: registra las capacidades del entorno que casi toda app necesita, ya listas para leer y para sustituir. Conocerlas evita el error de definir a mano una dependencia que ya existe.

continuousClock y suspendingClock

Relojes que sustituyen a Task.sleep. El continuo cuenta también mientras el dispositivo duerme; el suspendido se detiene. En tests se cambian por TestClock o ImmediateClock.

📅

date y calendar

\.date.now da el instante actual sin llamar a Date(). A su lado viajan \.calendar, \.timeZone y \.locale, que son las tres consultas al entorno que más tests rompen al cruzar máquinas.

🆔

uuid y withRandomNumberGenerator

El generador de identificadores y el de números aleatorios. Sustituibles por versiones incrementales o por un generador con semilla fija, que convierten el azar en una secuencia reproducible.

🧵

mainQueue y mainRunLoop

Planificadores de Combine para el hilo principal. Sustituibles por un planificador de test que permite avanzar el tiempo a mano, igual que el reloj hace con async.

🔗

openURL y openSettings

Acciones sobre el sistema: abrir un enlace, ir a los ajustes. En un test se sustituyen por una versión que registra la llamada en vez de ejecutarla.

🧭

context

Indica si el código corre en producción, en test o en preview. Es la dependencia que decide, por debajo, cuál de los tres valores de cada clave se usa.

Antes de definir una dependencia propia conviene comprobar si ya existe aquí, porque el error más común de quien empieza es escribir un cliente de fecha a mano cuando \.date lleva registrado desde siempre. Y hay un segundo error, simétrico: creer que el catálogo cubre todo. No cubre tu API, tu base de datos ni tu capa de analítica; esas las define tu módulo, y su definición es materia de la lección siguiente del bloque. La regla es simple: lo que pertenece al sistema operativo suele estar en el catálogo, lo que pertenece a tu dominio lo registras tú.

La última es la más conceptual y la más fácil de pasar por alto: \.context es la dependencia que gobierna a las demás. Cuando el contenedor tiene que resolver una clave, mira el contexto vigente y entrega el valor vivo, el de test o el de preview según corresponda. Es decir, el mecanismo de selección de entorno es él mismo una dependencia sobrescribible, lo que te permite, por ejemplo, forzar contexto de producción dentro de un test de integración concreto.

Cuándo se resuelve el valor

Aquí está el detalle que provoca más confusión y que separa el uso mecánico del uso informado. Una propiedad marcada con @Dependency captura su valor en el momento en que se inicializa el reducer, no en el momento en que se lee dentro de reduce. El property wrapper guarda una instantánea del contenedor vigente en la construcción.

// El valor de uuid queda fijado aquí, al construir Feature
let store = Store(initialState: Feature.State()) {
  Feature()
}

Esta es probablemente la fuente número uno de tests que parecen estar sobrescribiendo una dependencia y no lo están. La sintaxis no da ninguna pista: la propiedad se lee dentro de reduce y uno asume, razonablemente, que se resuelve ahí. No es así.

La consecuencia práctica es que sobrescribir una dependencia después de haber construido el reducer no afecta a lo ya capturado. Por eso el inicializador del TestStore acepta una cláusula de sobrescritura: para que las sustituciones ocurran mientras el reducer se construye, y no después. Y por eso, en la lección siguiente, el ámbito de withDependencies tendrá que envolver la creación del reducer, no solo el envío de acciones.

Hay una razón de diseño detrás de esa elección, y no es arbitraria. Si la dependencia se resolviera en cada lectura dentro de reduce, un reducer podría ver dos valores distintos de la misma dependencia durante el procesamiento de una sola acción, según qué ámbito estuviera vigente en cada línea. Capturar al construir garantiza que, durante toda la vida de ese reducer, la dependencia es una y no cambia bajo sus pies. Es la misma razón por la que prefieres un valor inmutable a una variable global: la estabilidad dentro del alcance es lo que permite razonar.

Existe la variante deliberadamente diferida: leer la dependencia dentro del cuerpo de un efecto, donde el contenedor vigente vuelve a consultarse en el momento de ejecución. La biblioteca propaga el contenedor a través de las tareas asíncronas que arrancan dentro de un ámbito sobrescrito, de manera que un efecto lanzado desde un test hereda las dependencias de ese test aunque corra más tarde y en otro hilo. Esa propagación por contexto de tarea es lo que hace que el sistema funcione sin que tengas que pasar nada a mano, y es también lo que lo distingue de un simple singleton mutable.

La regla operativa que resume las dos páginas anteriores cabe en una frase: lo que se lee como propiedad se fija al construir, lo que se lee dentro de un efecto se resuelve al ejecutar, y ambas cosas viajan por el ámbito, nunca por el inicializador. Interiorizarla ahorra la mayor parte de los desconciertos que produce este sistema durante las primeras semanas.

flowchart LR
F[Feature con propiedades Dependency] -->|lee por ruta de clave| DV[DependencyValues]
DV -->|clave es un tipo| K[DependencyKey con valor por defecto]
DV --> C[Contexto vigente]
C -->|produccion| L[liveValue]
C -->|test| T[testValue]
C -->|preview| P[previewValue]
style DV fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
Una dependencia declarada es una firma honesta que el lenguaje no obligaba a escribir

Lo que de verdad hace @Dependency no es facilitar los tests: es restaurar la honestidad de la firma. Recuerda el diagnóstico de la lección anterior: un reducer que llama a Date() tiene un parámetro oculto que el lenguaje le permitió no declarar. Las propiedades marcadas con @Dependency son exactamente esos parámetros ocultos, sacados a la luz y escritos arriba, donde cualquiera que abra el archivo los lee en tres segundos. Esa lista es la respuesta documentada a la pregunta más importante que se puede hacer sobre una feature: qué trozos del mundo toca. Antes esa respuesta requería leer cada línea del reducer y de todos sus efectos; ahora está en la cabecera. Y observa que el beneficio no depende de escribir un solo test: aunque nunca testearas, la declaración explícita ya te dio un mapa de acoplamiento con el exterior que antes no existía. Hay algo más, y es la razón por la que este sistema gana donde la inyección manual pierde. En la inyección por inicializador, declarar una dependencia impone un coste a terceros —cada padre en la cadena tiene que transportarla—, de modo que el programador tiene un incentivo real para no declararla y tirar de la variante cableada. Con @Dependency el coste de declarar es local y se paga entero en el archivo que la usa: nadie más se entera. Al eliminar la externalidad, el sistema alinea el incentivo individual con la salud global, y la opción correcta pasa a ser también la cómoda. Los buenos diseños no son los que prohíben lo malo; son los que hacen que lo bueno sea el camino de menor resistencia, y por eso este sobrevive al calendario de entregas.

⚔️ Declara y explora el contenedor
  1. Toma la feature más impura que encontraste en la auditoría anterior y sustituye cada llamada directa al mundo por una propiedad marcada con @Dependency. Comprueba que el comportamiento en el simulador es idéntico.
  2. Para una fecha, prueba las dos granularidades: leer el generador completo con \.date y leer solo el instante con \.date.now. Razona cuál comunica mejor la intención de tu reducer.
  3. Recorre el catálogo incluido y localiza al menos dos dependencias que tu proyecto ya está usando de forma cableada sin saberlo, muy probablemente entre \.locale, \.timeZone o \.calendar.
  4. Escribe un pequeño experimento que demuestre el momento de captura: sobrescribe una dependencia después de construir el reducer y confirma que el valor capturado no cambió.
  5. Explica con tus palabras por qué que la clave del contenedor sea un tipo y no una cadena elimina toda una clase de fallos en tiempo de ejecución que sí sufren los contenedores de inyección clásicos.