Por qué esto no es solo inyección de dependencias
Descartar el sistema con un 'ya conozco la inyección de dependencias' es perderse lo específico. La inyección clásica resuelve la sustituibilidad pero deja intacto el problema de la distribución: alguien tiene que llevar la dependencia desde donde se construye hasta donde se usa, y en un árbol de features eso significa atravesar reducers que no la necesitan. El sistema Dependency resuelve ambos a la vez mediante inyección implícita por contexto, emparentada con el Environment de SwiftUI y con el Context de React más que con los contenedores de servicios. Esta lección compara los tres enfoques, explica por qué esto no es un singleton pese al parecido superficial, y examina con honestidad el precio que se paga por la ergonomía.
Hay una reacción previsible al ver @Dependency por primera vez, y viene de la gente con más experiencia: esto es inyección de dependencias de toda la vida, con otro nombre. La objeción es comprensible y es incorrecta, y entender por qué separa el uso mecánico del uso informado. La inyección clásica resuelve un problema —que la implementación sea sustituible— y deja intacto el otro, que es el de la distribución: alguien tiene que llevar la dependencia desde donde se construye hasta donde se consume, y en un árbol de features compuestas ese trayecto atraviesa reducers que no la necesitan y que solo hacen de porteadores. El sistema de TCA resuelve los dos a la vez, y lo hace con una idea que no procede del mundo de los contenedores de servicios sino de otro linaje: el de @Environment en SwiftUI y el del contexto en React. Inyección implícita, no explícita. Esta lección explica qué gana con eso, qué la distingue de un singleton y qué precio cobra.
- Separar los dos problemas que la inyección de dependencias mezcla: la sustituibilidad y la distribución.
- Comparar el paso por inicializador, el contenedor de servicios y la inyección implícita por contexto.
- Argumentar por qué el contenedor de TCA no es un singleton pese a su parecido superficial en el punto de acceso.
- Reconocer con honestidad los costes de la inyección implícita y cuándo la explícita sigue siendo preferible.
Dos problemas distintos bajo un mismo nombre
Cuando alguien dice inyección de dependencias casi siempre se refiere al primero de estos dos problemas y rara vez distingue el segundo, que es el que de verdad duele a escala.
El primero es la sustituibilidad: que la implementación concreta que usa un componente pueda cambiarse sin tocar su código. Se resuelve programando contra una abstracción —un protocolo, un struct de clausuras— en vez de contra una clase concreta. Este problema está resuelto desde hace décadas y todo el mundo sabe resolverlo. Es, de hecho, el que enseñan todos los libros, y por eso mucha gente cree que ahí termina la historia.
El segundo es la distribución: cómo llega la implementación elegida desde donde se decide hasta donde se usa. Aquí es donde los enfoques divergen, y donde el paso por inicializador falla de forma tan característica. Nótese que este problema no existe en una aplicación plana con tres objetos, y por eso la mayoría de los tutoriales de inyección de dependencias nunca lo mencionan: aparece con la profundidad, y crece linealmente con ella.
| Enfoque | Cómo distribuye | Coste principal |
|---|---|---|
| Paso por inicializador | Cada padre entrega al hijo | Los intermediarios transportan lo que no usan | las firmas se contagian |
| Contenedor de servicios | Registro global y resolución por búsqueda | Falla en ejecución si falta un registro | estado global compartido |
| Contexto implícito | El ámbito de ejecución lo provee | Menos visible en la firma | exige entender el alcance dinámico |
La distinción importa porque los dos problemas tienen soluciones independientes y se pueden resolver por separado. Puedes tener sustituibilidad perfecta y distribución desastrosa —es exactamente el caso del paso por inicializador en un árbol profundo—, y puedes imaginar lo contrario. Cuando alguien dice ya hago inyección de dependencias casi siempre quiere decir que resolvió el primero, y la respuesta honesta es preguntarle cuántos archivos toca al añadir una dependencia nueva a una pantalla de tercer nivel. Ese número es la medida del segundo problema.
La fila del medio merece un comentario, porque es la que la gente tiene en mente cuando dice inyección de dependencias en el sentido de framework. Un contenedor de servicios clásico requiere una fase de registro en el arranque, resuelve por búsqueda y puede fallar en tiempo de ejecución si olvidas registrar algo. El contenedor de TCA no puede fallar así: la clave es un tipo, el valor por defecto viaja con el tipo, y no hay fase de registro que olvidar. Es una diferencia de garantías, no de estilo.
Inyección implícita: el proveedor y el consumidor sin intermediarios
La aportación específica es que el proveedor y el consumidor se comunican sin que los intermediarios participen. Quien envuelve la construcción con withDependencies está en la raíz; quien lee con @Dependency está en una hoja a cinco niveles; los cuatro reducers intermedios no declaran nada, no transportan nada y no se enteran de que la conversación existe.
Piénsalo como un canal lateral que atraviesa el árbol sin pasar por sus aristas. La composición de reducers define un camino de padres a hijos por el que viajan el estado y las acciones; las dependencias viajan por otro camino, paralelo y ortogonal, que conecta directamente el ámbito con quien lee. Que sean dos canales distintos no es un accidente de implementación sino la decisión de fondo: el estado y las acciones son asunto de la jerarquía, mientras que el entorno no lo es, y forzarlos por el mismo conducto es lo que produce el acoplamiento vertical.
// En la raiz: se decide el mundo
withDependencies {
$0.clienteAPI = .enMemoria
} operation: {
AppFeature() // construye toda la jerarquia
}
// Cinco niveles mas abajo, sin que nadie lo haya pasado
@Reducer
struct DetalleArticulo {
@Dependency(\.clienteAPI) var clienteAPI
}
Compara ese fragmento con lo que exigiría la versión explícita: AppFeature recibiría el cliente por inicializador, lo pasaría a su hijo, este al suyo, y así hasta la quinta generación, con cuatro firmas modificadas y cuatro archivos tocados por una decisión que solo concierne a la raíz y a la hoja. Aquí hay dos archivos implicados y son exactamente los dos que tienen algo que decir sobre el asunto.
El linaje de esta idea es reconocible y vale la pena situarlo, porque explica por qué el diseño resulta familiar a quien viene de SwiftUI y extraño a quien viene de los contenedores de servicios.
Environment de SwiftUI
Una vista profunda lee el esquema de color, el tamaño dinámico de fuente o el contexto de modelo que estableció un ancestro lejano, sin que las vistas intermedias lo pasen. Misma estructura, mismo intercambio.
Context de React
El proveedor envuelve un subárbol y cualquier componente descendiente consume el valor sin recibirlo por propiedades. Nació justamente para eliminar el transporte por toda la rama.
La mónada lectora
La formalización funcional de la misma idea: una computación que se ejecuta bajo un entorno del que puede leer sin recibirlo por parámetro en cada paso. Es el mismo diseño con cincuenta años y notación distinta.
Que tres comunidades muy distintas —la de las interfaces declarativas de Apple, la del ecosistema web y la de la programación funcional tipada— hayan convergido en la misma estructura es una señal fuerte. No es una moda de una biblioteca: es la solución que aparece siempre que hay un árbol profundo y un entorno que varía por contexto de ejecución.
La consecuencia arquitectónica es la que importa: añadir una dependencia a una feature profunda es un cambio local. Tocas un archivo. Sin ese mecanismo, tocarías todos los archivos de la rama, y esa diferencia es la que decide si la composición de features escala o si acaba desalentando la modularización.
Hay una segunda consecuencia, menos evidente y tan valiosa como la primera: los reducers intermedios se vuelven reutilizables. Un reducer que transporta una dependencia que no usa está acoplado a un hijo concreto; cámbialo de sitio en el árbol, o reutilízalo en otra rama, y su firma deja de tener sentido. Un reducer que no transporta nada es intercambiable, porque su interfaz describe solo lo suyo. La inyección implícita no solo abarata añadir dependencias: preserva la propiedad que hace que la composición valga la pena, que es que las piezas encajen en más de un sitio.
Merece la pena nombrar el precio de este linaje, porque también lo tiene y es el mismo en las tres tecnologías. Leyendo la firma de la hoja no sabes quién le está proveyendo el valor; hay que subir hasta encontrar el ámbito. La comunidad de SwiftUI convive con esa opacidad desde hace años a cambio de no arrastrar el contexto de modelo por veinte inicializadores, y el juicio agregado ha sido claro. Aquí el intercambio es idéntico y la respuesta también.
Por qué no es un singleton
La objeción siguiente también es previsible, y viene de la misma gente experimentada: si cualquier reducer accede al contenedor sin recibirlo, eso es un singleton global con mejor sintaxis, y los singletons llevan treinta años siendo el ejemplo canónico de mal diseño. La objeción merece respeto porque el parecido superficial es real: en ambos casos el consumidor obtiene un valor sin que nadie se lo pase. Pero hay tres diferencias, y son decisivas.
Es sobrescribible por ámbito
Un singleton tiene un valor y punto. El contenedor tiene un valor por ámbito: dos tests que corren en paralelo ven mundos distintos, y ninguno puede contaminar al otro.
Es reversible
Toda sustitución muere al salir de su clausura, incluso ante una excepción. Un singleton mutado se queda mutado, y de ahí vienen los tests que fallan según el orden en que se ejecutan.
Es declarado
El consumidor escribe arriba qué necesita. Con un singleton, el acoplamiento está enterrado en una línea cualquiera del cuerpo y solo se descubre leyendo todo el archivo.
Ninguna de las tres es cosmética, pero la primera es la que decide la comparación. Un singleton tiene, por definición, una única instancia para todo el proceso; el contenedor tiene tantas configuraciones simultáneas como ámbitos abiertos haya. Llamarlo singleton confunde el punto de acceso —que sí es global y ambiental, como en un singleton— con el valor accedido, que aquí depende del contexto de ejecución. Es la misma diferencia que hay entre una variable global y un parámetro: ambas se leen escribiendo un nombre, pero solo una tiene el mismo contenido para todos.
La tercera es la más fácil de infravalorar y la más útil en el día a día. Un Analytics.shared.track escondido en la línea ciento veinte de un reducer es invisible en la revisión de código; una propiedad marcada con @Dependency en la cabecera es un anuncio. Cuando el acoplamiento con el exterior está enumerado en el encabezado, revisar una feature deja de exigir leerla entera para saber qué toca del mundo, y aparece una posibilidad que antes no existía: convertir esa lista en una regla de revisión. Que una feature de presentación declare de pronto un cliente de base de datos es una señal arquitectónica visible en el diff, no un descubrimiento arqueológico de dentro de dos años.
// Singleton: un unico mundo, mutable desde cualquier sitio
Analytics.shared.endpoint = .falso // afecta a todo el proceso, para siempre
// Contenedor por ambito: un mundo por clausura, restaurado al salir
withDependencies { $0.analytics = .falso } operation: { /* ... */ }
// fuera de aqui, analytics vuelve a ser lo que era
La diferencia entre esas dos líneas no es de estilo sino de alcance temporal. La primera es una mutación permanente de estado global cuyo efecto sobrevive a quien la escribió; la segunda es una sustitución con vida limitada al bloque. Todo el catálogo de patologías del singleton en tests —el orden que importa, el test que solo falla al ejecutar la suite entera, el estado que se filtra entre casos— nace de esa permanencia, y desaparece cuando la sustitución tiene un final garantizado.
Hay una cuarta diferencia, más técnica pero definitiva: el contenedor de TCA se propaga por el contexto de la tarea asíncrona, no por una variable global compartida entre hilos. Dos tareas concurrentes bajo ámbitos distintos ven contenedores distintos sin sincronización ni carreras. Un singleton mutable, para lograr eso, necesitaría cerraduras y aun así serializaría lo que aquí es naturalmente paralelo.
El precio de la ergonomía
Sería deshonesto cerrar sin nombrar lo que se paga, porque toda decisión de diseño cobra algo y presentar una técnica como pura ganancia es la señal más fiable de que quien la presenta no la ha usado lo bastante.
Lo primero es visibilidad en la firma. Un inicializador con parámetros dice a gritos qué necesita el tipo, y no hay manera de construirlo sin proveerlo: el compilador es el guardián. Con @Dependency la información está en la cabecera del struct, no en la firma, y el compilador no te obliga a nada: un reducer con dependencias se construye escribiendo Feature(), igual que uno sin ellas. Es un intercambio consciente de garantía estática por ergonomía, y la biblioteca lo compensa desplazando la detección del error del tiempo de compilación al tiempo de test, donde el testValue no implementado grita si alguien usa algo que no preparó. No es la misma garantía —el compilador es infalible y el test depende de que exista— pero cubre el hueco allí donde importa.
Lo segundo es la carga conceptual del alcance dinámico. Hay que entender que el valor se captura al construir, que la sobrescritura debe envolver la construcción y que la propagación viaja por el contexto de tarea. Quien no lo entiende escribe tests que fallan de forma desconcertante, y es exactamente el error que diagnosticamos en la lección anterior. No es una carga enorme, pero es real, y explica por qué un equipo que adopta el sistema sin estudiarlo tarda unas semanas en dejar de pelearse con él.
Lo tercero es que la disciplina sigue siendo tuya. Nada impide escribir Date() dentro de un reducer: el sistema lo hace fácil de evitar, no imposible. La biblioteca lo compensa donde puede —el testValue no implementado que genera @DependencyClient hace fallar en voz alta cualquier operación que un test no haya preparado—, pero ese refuerzo cubre las dependencias que ya declaraste, no las que decidiste no declarar.
El juicio maduro no es que un enfoque sustituya al otro en todos los casos. Para una función pequeña y autocontenida que se llama desde un solo sitio, un parámetro explícito es más claro, más comprobable por el compilador y no exige entender ningún alcance. La inyección implícita rinde cuando hay profundidad —un árbol que atravesar— y cuando hay multiplicidad de mundos —producción, test, preview, demostración—. Sin profundidad y sin multiplicidad, su ventaja se evapora y solo queda su coste. Elige por la forma del problema, no por lealtad a un patrón.
flowchart TD subgraph Explicita R1[Raiz] --> A1[Intermedio transporta] --> B1[Intermedio transporta] --> H1[Hoja usa] end subgraph Implicita R2[Raiz define el ambito] -.contexto.-> H2[Hoja lee con Dependency] R2 --> A2[Intermedio ignora todo] --> H2 end style H1 fill:#f38ba8,color:#11111b style H2 fill:#a6e3a1,color:#11111b
El desplazamiento conceptual que hay aquí es más profundo que un cambio de sintaxis, y se aprecia mejor preguntando dónde vive la información sobre el entorno. En la inyección explícita vive en el grafo de objetos: quién construyó a quién, con qué argumentos, en qué orden. Ese grafo hay que construirlo entero y mantenerlo, y toda modificación del entorno es una modificación estructural del grafo, con su reescritura de firmas y su onda expansiva por la rama. En la inyección implícita, en cambio, vive en el contexto de ejecución: no en cómo se construyeron los objetos, sino bajo qué ámbito corren. Y esa es una diferencia de categoría, no de grado. El grafo de objetos es estático y costoso de cambiar; el contexto de ejecución es dinámico y baratísimo, porque cambiarlo es abrir una clausura. Por eso puedes tener veinte tests con veinte mundos distintos sin construir veinte grafos, cuatro previews de la misma vista con cuatro entornos, y una rama entera de tu app corriendo contra una base de datos en memoria sin una sola condición en la lógica. Aquí es donde se cierra el arco de todo el nivel. Empezamos con un Date() incrustado, que era un hecho ambiental: ocurría, no lo elegías, y por eso no podías testear ni razonar. Lo convertimos en un argumento, que ya se podía elegir pero exigía transportarlo por todo el árbol y contaminaba las firmas de quien solo hacía de portero. Y terminamos convirtiéndolo en una propiedad del ámbito de ejecución: elegible desde la raíz, invisible para los intermediarios, declarada en la hoja que la consume, reversible al salir y propagada incluso a través de las fronteras asíncronas. Ese tercer estadio es el que hace que controlar el mundo deje de ser una técnica de testing —algo que haces cuando tienes tiempo— y pase a ser una propiedad estructural del sistema, algo que ya está ahí porque el diseño no admitía otra cosa. Y una vez que el mundo entero es elegible por ámbito, el TestStore exhaustivo, la preview instantánea y el bug reproducible dejan de ser logros y se vuelven consecuencias.
- Escribe la misma feature de tres formas: con la dependencia pasada por inicializador, con un singleton accedido directamente y con
@Dependency. Compara cuántos archivos toca cada una al añadir una segunda dependencia a la hoja. - Demuestra la reversibilidad: escribe dos tests que sobrescriban la misma dependencia con valores distintos, ejecútalos en ambos órdenes y comprueba que el resultado no depende del orden.
- Con la versión de singleton, provoca deliberadamente el fallo de contaminación entre tests. Documenta el mensaje de error y por qué el contenedor por ámbito lo hace imposible.
- Localiza en tu proyecto un uso de
@Environmentde SwiftUI y explica en qué se parece estructuralmente a@Dependencyy en qué se diferencia su alcance. - Escribe un párrafo defendiendo el caso contrario: una situación concreta de tu código donde la inyección explícita por inicializador sea preferible, y justifica por qué el coste de visibilidad supera allí a la ergonomía.