DependencyKey: registrar la dependencia y darle liveValue
El contenedor de dependencias es un diccionario heterogéneo indexado por tipos, y DependencyKey es la llave que abre cada casilla. Esta lección cubre el protocolo y su associatedtype Value, qué debe y qué no debe contener liveValue, la extensión de DependencyValues que crea la ruta de clave, la diferencia entre usar el propio cliente como llave o una llave aparte, y el alcance dinámico que hace que una sobrescritura se propague por el árbol de tareas.
Ya tienes la dependencia modelada como un valor. Falta la otra mitad del contrato: que la arquitectura sepa encontrarla. TCA no usa un registro con cadenas ni un grafo de anotaciones; usa un diccionario heterogéneo indexado por tipos, donde cada tipo que conforma DependencyKey es a la vez la llave y el anuncio de qué se guarda bajo ella. Esa elección compra dos cosas a la vez: seguridad de tipos total —jamás sacarás de la casilla algo distinto de lo que declaraste— y un valor por defecto garantizado, de modo que no existe el estado dependencia no registrada. Registrar bien es media lección; la otra media es entender cuándo se lee el valor, porque el contenedor tiene alcance dinámico y eso cambia lo que puedes esperar de él.
- Conformar
DependencyKeyy explicar el papel de suassociatedtype Value. - Decidir qué pertenece a
liveValuey por qué debe ser la implementación real. - Extender
DependencyValuescon una ruta de clave y usarla desde el reducer. - Razonar sobre el alcance dinámico: propagación por el árbol de tareas y sus fugas.
El contenedor es un diccionario indexado por tipos
DependencyValues es, en esencia, un almacén cuyo índice no es una cadena sino un tipo. El protocolo que lo hace posible es minúsculo.
public protocol DependencyKey {
associatedtype Value
static var liveValue: Value { get }
}
El tipo conformante actúa como nombre y su Value declara qué se guarda. Como el subíndice del contenedor está tipado por esa relación, leer self[ClienteArticulos.self] solo puede devolver un ClienteArticulos: no hay conversión forzada, ni opcional, ni posibilidad de colisión entre dos módulos que casualmente eligieran la misma cadena. Y como liveValue es un requisito estático y no opcional, la pregunta y si nadie la registró simplemente no existe: el defecto está garantizado por el compilador.
Lo más habitual es que el propio cliente sea su llave, porque en el noventa por ciento de los casos hay una sola registración por tipo.
extension ClienteArticulos: DependencyKey {
static let liveValue = ClienteArticulos(
cargar: { try await Backend.articulos() },
buscar: { texto in try await Backend.buscar(texto) },
guardar: { articulo in try await Backend.enviar(articulo) },
borrar: { id in try await Backend.borrar(id) }
)
}
Que el cliente sea su propia llave es cómodo pero conviene ver que son dos papeles distintos superpuestos: el tipo ClienteArticulos funciona a la vez como nombre de la casilla y como tipo de lo que guarda. Mientras haya una sola registración por tipo, la coincidencia es inofensiva y ahorra un enum; en cuanto necesitas dos, los papeles se separan y hay que darle a cada casilla un nombre propio.
Cuando necesites dos registraciones del mismo tipo —dos bases de datos, dos endpoints de una misma API— la llave deja de poder ser el propio tipo y se convierte en un enum vacío que solo existe para nombrar la casilla. Es el mecanismo completo, y el caso frecuente es solo su atajo.
enum BaseDatosPrincipal: DependencyKey {
static let liveValue = Base(url: .principal)
}
enum BaseDatosAnalitica: DependencyKey {
static let liveValue = Base(url: .analitica)
}
liveValue: la implementación de verdad, y nada más
La regla es tajante: liveValue es lo que corre en la app que descarga un usuario. Nunca un valor de mentira, nunca datos de muestra, nunca un if que mire si estamos en tests. Si el liveValue miente, todas las garantías del sistema se evaporan, porque el único valor que nadie sobrescribe deja de describir la realidad.
La prohibición del condicional merece énfasis porque la tentación reaparece con disfraces respetables: una comprobación de si estamos en el simulador, una variable de entorno que activa datos falsos, una bandera de compilación para modo demo. Todas comparten el mismo defecto: hacen que el comportamiento de producción dependa de algo que no está en la firma, con lo que dejas de poder razonar sobre el liveValue leyéndolo. Si necesitas un modo demo, es otra dependencia o es una sobrescritura declarada en el punto de entrada, nunca una rama escondida dentro del valor vivo.
Dos matices técnicos importan. Primero, static let en Swift es perezoso y se inicializa a lo sumo una vez, con garantía de ejecución única; eso significa que construir el cliente vivo no cuesta nada hasta que alguien lo pide, y que puedes abrir conexiones o preparar recursos ahí sin penalizar el arranque de los tests, que nunca lo tocarán. Segundo, liveValue no debería depender de otras dependencias leyéndolas en su propia inicialización: si el cliente vivo necesita el reloj o el generador de identificadores, decláralos con @Dependency dentro de las closures, donde se resolverán en el contexto correcto, y no en el cuerpo estático, que se evalúa una sola vez y fuera de todo ámbito sobrescrito.
Si un TestStore intenta usar una dependencia que no sobrescribiste, la librería no cae al liveValue: usa el testValue, que por defecto falla. Es deliberado. Un test que accidentalmente hiciera una petición real sería lento, intermitente y capaz de escribir en producción. Esa separación estricta es la razón de que la tríada exista, y el tema de la lección siguiente.
La ruta de clave: extender DependencyValues
Conformar la llave registra el valor, pero todavía no hay una forma cómoda de pedirlo. Esa forma es una propiedad calculada en una extensión del contenedor, cuyo get y set delegan en el subíndice tipado.
extension DependencyValues {
var clienteArticulos: ClienteArticulos {
get { self[ClienteArticulos.self] }
set { self[ClienteArticulos.self] = newValue }
}
}
Esa propiedad es lo que hace existir la ruta de clave \.clienteArticulos, y a partir de ella la dependencia se comporta exactamente igual que las que trae la librería.
@Reducer
struct Lista {
@Dependency(\.clienteArticulos) var cliente
// dentro de reduce:
// return .run { send in await send(.recibidos(Result { try await cliente.cargar() })) }
}
Nombra esa propiedad con el mismo criterio con el que nombrarías cualquier API pública, porque eso es: la ruta de clave aparecerá en decenas de reducers y en todos los bloques de sobrescritura de la suite. Un nombre que describa la capacidad envejece bien; uno que describa la implementación te obligará a renombrar el día que cambie el proveedor.
El set no es decorativo: es lo que permite escribir $0.clienteArticulos.cargar = ... en un withDependencies, porque asignar a un campo del valor exige poder volver a guardarlo entero en el contenedor. Sin set, la dependencia sería de solo lectura y perderías toda la sustitución granular que ganaste en la lección anterior.
flowchart TB A[struct ClienteArticulos] --> B[conforma DependencyKey] B --> C[liveValue estatico y perezoso] B --> D[casilla en DependencyValues indexada por el tipo] D --> E[extension con propiedad calculada] E --> F[ruta de clave punto clienteArticulos] F --> G[Dependency dentro del reducer] style D fill:#89b4fa,color:#11111b style G fill:#a6e3a1,color:#11111b
Alcance dinámico: dónde vive el valor actual
El contenedor no es un singleton fijo: el valor vigente se guarda en una variable de tarea local, así que una sobrescritura hecha con withDependencies rige para todo lo que ocurra dentro de esa operación, incluidas las tareas hijas que se creen estructuradamente. Eso es alcance dinámico, no léxico: lo que determina qué cliente ves no es dónde está escrito tu código sino bajo qué ámbito se está ejecutando.
De ahí se deducen dos consecuencias prácticas. Una: las sobrescrituras se anidan y la más interna gana, lo que permite que un test global fije el reloj y un caso concreto lo refine sin tocar el resto. Otra: si escapas del árbol de tareas —una closure que se guarda para más tarde, una tarea no estructurada que sobrevive a la operación— pierdes el contexto y vuelves a ver los valores por defecto; para esos casos la librería ofrece withEscapedDependencies, que captura el contexto vigente y lo restaura después.
Para configurar la app entera una sola vez, el sitio correcto es el punto de entrada, con prepareDependencies, que fija valores en el contexto raíz antes de que se construya el primer Store.
withDependencies
Sobrescribe para un ámbito acotado y anidable. Es la herramienta del día a día: tests, previews y cualquier rama del árbol de features que necesite un entorno distinto del de sus hermanas.
prepareDependencies
Fija el contexto raíz una sola vez, en el arranque de la app. Sirve para configuración global —una URL base, un modo de depuración— y no debe usarse para simular nada.
withEscapedDependencies
Captura el contexto vigente para restaurarlo dentro de trabajo que escapa del árbol de tareas. Es el parche explícito al único punto ciego del alcance dinámico.
Hay un detalle de secuencia que produce errores difíciles de diagnosticar: @Dependency resuelve su valor cuando la propiedad se lee, no cuando se declara el reducer, y por eso leer una dependencia dentro del init de un reducer suele darte el valor equivocado. La regla práctica es simple: consulta las dependencias dentro de reduce o dentro de las closures de los efectos, nunca en la construcción del tipo.
DependencyKey es un protocolo mínimo con un associatedtype Value y un liveValue estático, y el tipo conformante actúa a la vez de llave y de anuncio de qué guarda esa casilla. El contenedor DependencyValues queda así indexado por tipos, con lectura total y sin colisiones entre módulos. La ruta de clave la crea una propiedad calculada con get y set en una extensión del contenedor, y el set es imprescindible para poder parchear un solo endpoint. El valor vigente vive en almacenamiento local de tarea, lo que da alcance dinámico con propagación estructurada y una única fuga conocida: el trabajo que escapa del árbol de tareas.
La decisión de indexar el contenedor por tipos en lugar de por cadenas o por identificadores de tiempo de ejecución es lo que separa a este diseño de la mayoría de los contenedores de inyección que ha visto la industria, y merece verse con precisión. Un registro basado en cadenas es un mapa parcial: la operación de lectura tiene tipo clave a valor opcional, así que todo consumidor arrastra o bien un desempaquetado forzoso o bien un camino de error que en la práctica nadie prueba, y los conflictos de nombre entre módulos son un riesgo real que crece con el tamaño del equipo. Al usar el tipo como llave y ligar su Value mediante un associatedtype, la lectura pasa a ser una función total: el compilador conoce el tipo del resultado, la casilla no puede estar vacía porque liveValue es un requisito estático, y dos módulos no pueden colisionar porque los tipos están cualificados por su módulo. El registro deja de ser un contrato verificado en arranque —el clásico crash al primer uso porque alguien olvidó registrar— y pasa a ser una propiedad establecida en compilación. Encima de eso se apila la segunda mitad del diseño: el valor vigente vive en almacenamiento local de tarea, lo que da alcance dinámico con propagación estructurada. Es la reconciliación de dos cosas que solían ser incompatibles: la ergonomía de un global —no hay que enhebrar el entorno por veinte inicializadores— con la seguridad de un parámetro —cualquier ámbito puede redefinirlo sin afectar a sus hermanos—. En términos de teoría de lenguajes, es un lector implícito con reemplazo local, la misma estructura que una mónada Reader con local, implementada sobre el sistema de concurrencia en vez de sobre un tipo envolvente. Y el precio de esa elegancia es exactamente uno, hay que conocerlo: lo que escapa del árbol de tareas escapa también del ámbito, y por eso existe withEscapedDependencies. Cuando entiendes que el contenedor es un lector con alcance dinámico y no un almacén global, dejas de tratarlo como un registro y empiezas a diseñarlo como parte del sistema de tipos.
- Conforma
DependencyKeyen tu cliente y escribe unliveValueque hable de verdad con el servicio, sin ninguna rama condicional para tests. - Añade la extensión de
DependencyValuescongetyset, y comprueba que la ruta de clave aparece en el autocompletado. - Léela desde un reducer con
@Dependencyy ejecútala en la app real para confirmar que el camino vivo funciona. - Crea dos llaves
enumdistintas para dos configuraciones del mismo tipo y explica por qué aquí el atajo de la llave propia no sirve. - Envuelve una llamada en
withDependenciescon una sobrescritura, lanza dentro una tarea no estructurada y observa que pierde el contexto; arréglalo conwithEscapedDependencies.