El sistema que conecta todas las piezas de Angular
Cualquier aplicación Angular de tamaño medio depende de decenas de servicios: clientes HTTP, almacenes de estado, utilidades de registro, configuraciones por entorno y adaptadores hacia APIs externas. La pregunta de fondo es siempre la misma: ¿quién crea esos objetos y cómo llegan a los componentes que los necesitan? La respuesta de Angular es su sistema de inyección de dependencias, uno de los pilares del framework desde su primera versión y una de las razones por las que escala bien en equipos grandes.
La idea es sencilla de enunciar. En lugar de que una clase construya por sí misma los objetos que utiliza, los declara como dependencias y deja que un contenedor se los entregue ya listos. Ese desacoplamiento hace que el código sea más fácil de probar, de reutilizar y de modificar, porque cambiar una implementación ya no obliga a tocar todas las clases que la consumen.
En esta guía vamos a recorrer la inyección de dependencias en Angular con la sintaxis actual del framework. Angular 22 es la rama estable en este momento, con la serie 22.2 como versión más reciente, y la forma de trabajar ha cambiado bastante respecto a lo que se veía hace unos años: los componentes standalone son el modelo por defecto, la función inject() ha desplazado en buena medida a la inyección por constructor y los providers se configuran directamente al arrancar la aplicación, sin necesidad de NgModule.
Veremos cómo funcionan los inyectores, cómo se registran los servicios en distintos niveles, qué opciones de configuración ofrecen los providers, cómo usar tokens para valores que no son clases, cómo controlar la búsqueda de dependencias y cómo aprovechar todo esto en las pruebas. Terminaremos con errores frecuentes y buenas prácticas para proyectos reales.
Qué problema resuelve la inyección de dependencias
Para apreciar el valor del patrón conviene ver primero el código que intenta evitar. Imagina un componente que muestra el catálogo de una librería y que crea por su cuenta el servicio que obtiene los datos:
// Sin inyección de dependencias: acoplamiento fuerte
export class CatalogComponent {
private catalog = new CatalogService(
new ApiClient("https://api.mi-libreria.dev")
);
books = this.catalog.getBooks();
}
Este componente sabe demasiado. Conoce la clase concreta del servicio, sabe que ese servicio necesita un cliente HTTP y conoce incluso la URL de la API. Si mañana el cliente requiere un token de autenticación, habrá que modificar todos los lugares donde se construye. Y probar el componente sin hacer peticiones reales resulta complicado, porque no hay forma de sustituir el servicio por una versión simulada.
Con inyección de dependencias el componente solo declara qué necesita:
import { Component, inject } from "@angular/core";
import { CatalogService } from "./catalog";
@Component({
selector: "app-catalog",
template: `
@for (book of catalog.books(); track book.id) {
<article>{{ book.title }}</article>
}
`,
})
export class CatalogComponent {
protected catalog = inject(CatalogService);
}
Ahora el componente ignora cómo se construye CatalogService y qué dependencias tiene. Esa responsabilidad recae en el inyector, que es el objeto encargado de crear instancias y entregarlas a quien las pida.
Inyectores: el corazón del sistema
Un inyector es, en esencia, un registro que asocia tokens con instrucciones de creación. Cuando una clase solicita una dependencia, el inyector busca el token correspondiente, crea la instancia si todavía no existe y la devuelve. Si ya la había creado antes, entrega la misma, lo que convierte a cada servicio en un singleton dentro del ámbito de ese inyector.
Angular no tiene un único inyector, sino una jerarquía. En la aplicación conviven dos árboles:
- El árbol de inyectores de entorno, que incluye el inyector raíz de la aplicación y los inyectores que se crean para rutas cargadas de forma diferida.
- El árbol de inyectores de elementos, que sigue la estructura del DOM: cada componente y directiva que declara providers obtiene su propio inyector.
Cuando un componente pide una dependencia, Angular primero busca en el inyector del propio elemento. Si no la encuentra, sube al inyector del elemento padre y continúa ascendiendo por el árbol de elementos. Si llega a la raíz de ese árbol sin éxito, pasa al árbol de entorno y repite la búsqueda hasta el inyector raíz. Si nadie la tiene, lanza un error.
Inyector de plataforma
└── Inyector raíz de la aplicación (EnvironmentInjector)
├── Inyector de ruta diferida /admin
└── Árbol de elementos
└── AppComponent
├── HeaderComponent
└── CatalogComponent (providers: [CatalogFilters])
└── BookCardComponent
En este esquema, si BookCardComponent pide CatalogFilters, Angular lo encontrará en el inyector de su padre CatalogComponent. Si pide CatalogService, que está registrado en la raíz, la búsqueda recorrerá todo el árbol de elementos hasta llegar al inyector de la aplicación. Comprender esta búsqueda ascendente es lo que permite decidir con precisión dónde vive cada servicio y cuánto tiempo existe.
Crear un servicio con providedIn root
La forma más habitual de registrar un servicio es indicarlo en el propio decorador @Injectable mediante la propiedad providedIn. La CLI genera los servicios así por defecto:
ng generate service catalog/catalog
CREATE src/app/catalog/catalog.spec.ts (342 bytes)
CREATE src/app/catalog/catalog.ts (134 bytes)
Las versiones recientes de la CLI siguen la guía de estilo actualizada, que prescinde del sufijo .service en los nombres de archivo. Si tu equipo prefiere mantener la convención anterior, puedes configurarlo en angular.json. El contenido generado es similar a este, al que añadimos lógica propia:
// src/app/catalog/catalog.ts
import { Injectable, inject, signal } from "@angular/core";
import { HttpClient } from "@angular/common/http";
export interface Book {
id: number;
title: string;
author: string;
}
@Injectable({ providedIn: "root" })
export class CatalogService {
private http = inject(HttpClient);
private readonly _books = signal<Book[]>([]);
readonly books = this._books.asReadonly();
load(): void {
this.http
.get<Book[]>("/api/books")
.subscribe((data) => this._books.set(data));
}
}
El valor 'root' indica que el servicio se registra en el inyector raíz de la aplicación. Esto tiene dos consecuencias importantes. La primera es que existe una única instancia compartida por toda la aplicación. La segunda es que el servicio es eliminable por tree-shaking: si ninguna parte del código lo inyecta, el compilador lo excluye del paquete final, algo que no ocurre con los servicios declarados en arrays de providers.
Existe también el valor 'platform', que registra el servicio en un inyector compartido por varias aplicaciones Angular que se ejecutan en la misma página. Es un caso poco común, útil sobre todo en arquitecturas de microfrontends.
La función inject() frente a la inyección por constructor
Durante años la única forma de recibir dependencias fue declararlas como parámetros del constructor. Esa sintaxis sigue siendo válida y la verás en muchos proyectos existentes:
@Component({ selector: "app-catalog", template: "" })
export class CatalogComponent {
constructor(
private catalog: CatalogService,
private router: Router
) {}
}
Sin embargo, la función inject en Angular se ha convertido en la opción recomendada por la documentación oficial y por la guía de estilo. Sus ventajas son prácticas:
- Funciona sin depender de los metadatos de tipos que emite TypeScript, lo que simplifica la herencia y evita problemas con algunas configuraciones del compilador.
- Permite inicializar propiedades directamente en su declaración, de modo que las dependencias y el estado derivado quedan juntos.
- Se puede usar en funciones, no solo en clases, lo que habilita guards, resolvers e interceptores funcionales.
- Hace más legible la herencia, porque las subclases ya no tienen que repetir todos los parámetros del constructor padre.
import { Component, computed, inject } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { toSignal } from "@angular/core/rxjs-interop";
import { CatalogService } from "./catalog";
@Component({
selector: "app-book-detail",
template: `<h2>{{ book()?.title }}</h2>`,
})
export class BookDetailComponent {
private route = inject(ActivatedRoute);
private catalog = inject(CatalogService);
private params = toSignal(this.route.paramMap);
protected book = computed(() => {
const id = Number(this.params()?.get("id"));
return this.catalog.books().find((b) => b.id === id);
});
}
La CLI incluye una migración automática para convertir proyectos existentes de la inyección por constructor a inject():
ng generate @angular/core:inject
Hay una restricción esencial: inject() solo puede llamarse dentro de un contexto de inyección. Eso incluye la inicialización de propiedades de una clase gestionada por Angular, su constructor, las funciones de fábrica de los providers y funciones como guards o resolvers que el router ejecuta. Llamarla dentro de un método invocado más tarde, por ejemplo en un manejador de clics, produce un error. Volveremos sobre esto en la sección de solución de problemas.
Providers en la configuración de la aplicación
En las aplicaciones standalone, que son las que genera la CLI desde hace varias versiones, no existe un AppModule. Los servicios globales y las funcionalidades del framework se configuran en el archivo app.config.ts, que se pasa a bootstrapApplication:
// src/app/app.config.ts
import {
ApplicationConfig,
provideBrowserGlobalErrorListeners,
} from "@angular/core";
import { provideRouter, withComponentInputBinding } from "@angular/router";
import {
provideHttpClient,
withFetch,
withInterceptors,
} from "@angular/common/http";
import { routes } from "./app.routes";
import { authInterceptor } from "./core/auth.interceptor";
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes, withComponentInputBinding()),
provideHttpClient(withFetch(), withInterceptors([authInterceptor])),
],
};
// src/main.ts
import { bootstrapApplication } from "@angular/platform-browser";
import { AppComponent } from "./app/app";
import { appConfig } from "./app/app.config";
bootstrapApplication(AppComponent, appConfig).catch((err) =>
console.error(err)
);
Las funciones con prefijo provide son la forma moderna de registrar funcionalidades completas del framework. Cada una devuelve un conjunto de providers que configuran el inyector raíz. Este estilo sustituye a la importación de módulos como HttpClientModule o RouterModule.forRoot(), que siguen existiendo por compatibilidad pero ya no son el camino recomendado.
Si tu biblioteca interna necesita ofrecer una configuración similar, puedes crear tu propia función provide que agrupe varios providers bajo un nombre claro, algo que veremos más adelante.
Proveedores a nivel de componente
No todos los servicios deben ser globales. A veces queremos que cada instancia de un componente tenga su propio estado aislado. Para eso se usan los proveedores a nivel de componente, declarados en la propiedad providers del decorador:
import { Injectable, signal } from "@angular/core";
@Injectable()
export class CartState {
readonly items = signal<string[]>([]);
add(item: string): void {
this.items.update((list) => [...list, item]);
}
}
import { Component, inject } from "@angular/core";
import { CartState } from "./cart-state";
@Component({
selector: "app-cart-panel",
providers: [CartState],
template: `
<button (click)="cart.add('Libro ' + (cart.items().length + 1))">
Añadir
</button>
<p>{{ cart.items().length }} artículos</p>
`,
})
export class CartPanelComponent {
protected cart = inject(CartState);
}
Observa que CartState usa @Injectable() sin providedIn. Es una forma explícita de indicar que el servicio no está disponible globalmente y solo existe donde se declare en un array de providers.
Si colocamos dos <app-cart-panel> en la misma página, cada uno tendrá su propia instancia de CartState. Además, todos los componentes hijos de cada panel recibirán la instancia de su panel, lo que permite compartir estado dentro de un subárbol sin exponerlo al resto de la aplicación.
El ciclo de vida del servicio queda ligado al del componente: se crea cuando el componente se crea y se destruye con él. Si el servicio necesita liberar recursos, puede registrar una función de limpieza:
import { DestroyRef, Injectable, inject } from "@angular/core";
@Injectable()
export class PollingService {
private intervalId = setInterval(() => this.refresh(), 5000);
constructor() {
inject(DestroyRef).onDestroy(() => clearInterval(this.intervalId));
}
private refresh(): void {
// Consultar datos actualizados
}
}
Los decoradores de componente admiten además viewProviders. Funcionan igual que providers, pero los servicios declarados ahí solo son visibles para la vista del componente y no para el contenido proyectado mediante <ng-content>. Es útil cuando se construyen componentes de biblioteca que no deben filtrar su estado interno a lo que el consumidor proyecta dentro.
Providers en rutas para funcionalidades aisladas
Un punto intermedio entre lo global y lo local son los providers de ruta. Al declararlos en la configuración del router, Angular crea un inyector de entorno para esa rama de rutas. Los servicios viven mientras esas rutas estén en uso y son compartidos por todos los componentes de la rama.
// src/app/app.routes.ts
import { Routes } from "@angular/router";
export const routes: Routes = [
{
path: "admin",
loadChildren: () =>
import("./admin/admin.routes").then((m) => m.ADMIN_ROUTES),
},
];
// src/app/admin/admin.routes.ts
import { Routes } from "@angular/router";
import { AdminReportsService } from "./admin-reports";
import { DashboardComponent } from "./dashboard";
export const ADMIN_ROUTES: Routes = [
{
path: "",
providers: [AdminReportsService],
children: [{ path: "", component: DashboardComponent }],
},
];
Combinado con la carga diferida, este enfoque tiene una ventaja importante de rendimiento: el código de AdminReportsService solo se descarga cuando el usuario navega a la sección de administración. Es la alternativa moderna a los módulos de funcionalidad con sus propios providers.
Recetas de providers: useClass, useValue, useFactory y useExisting
Hasta ahora hemos registrado servicios escribiendo solo el nombre de la clase. Esa forma abreviada equivale a un objeto de configuración más explícito:
providers: [CatalogService];
// es lo mismo que
providers: [{ provide: CatalogService, useClass: CatalogService }];
El objeto separa dos conceptos: el token que se solicita, indicado en provide, y la receta para crear el valor. Angular ofrece cuatro recetas.
Con useClass se entrega una instancia de una clase distinta a la solicitada. Es la base para intercambiar implementaciones:
export abstract class Logger {
abstract log(message: string): void;
}
@Injectable()
export class ConsoleLogger extends Logger {
log(message: string): void {
console.log(`[app] ${message}`);
}
}
@Injectable()
export class RemoteLogger extends Logger {
private http = inject(HttpClient);
log(message: string): void {
this.http.post("/api/logs", { message }).subscribe();
}
}
// app.config.ts
providers: [
{
provide: Logger,
useClass: environment.production ? RemoteLogger : ConsoleLogger,
},
];
Usar una clase abstracta como token es un truco muy práctico: sirve de contrato para TypeScript y de token para el inyector al mismo tiempo, algo que una interfaz no puede hacer porque desaparece al compilar.
Con useValue se entrega un valor ya construido, como un objeto de configuración o un doble de pruebas:
providers: [{ provide: Logger, useValue: { log: () => {} } }];
Con useFactory se ejecuta una función que construye el valor. Dentro de la fábrica se puede usar inject() para obtener otras dependencias:
providers: [
{
provide: Logger,
useFactory: () => {
const config = inject(APP_CONFIG);
return config.remoteLogging
? new RemoteLogger()
: new ConsoleLogger();
},
},
];
Con useExisting se crea un alias: al solicitar un token se recibe la misma instancia registrada con otro.
providers: [ConsoleLogger, { provide: Logger, useExisting: ConsoleLogger }];
La diferencia entre useClass y useExisting es sutil pero importante. La primera crearía una segunda instancia de ConsoleLogger; la segunda reutiliza la que ya existe.
InjectionToken para valores que no son clases
Las clases sirven como tokens de forma natural, pero ¿qué pasa con una cadena de texto, un número o un objeto de configuración? No podemos usar string como token porque es demasiado genérico y provocaría colisiones. Para estos casos existe InjectionToken, que crea un identificador único y tipado:
// src/app/core/app-config.ts
import { InjectionToken } from "@angular/core";
export interface AppConfig {
apiUrl: string;
remoteLogging: boolean;
pageSize: number;
}
export const APP_CONFIG = new InjectionToken<AppConfig>("APP_CONFIG");
// app.config.ts
import { APP_CONFIG } from "./core/app-config";
import { environment } from "../environments/environment";
export const appConfig: ApplicationConfig = {
providers: [{ provide: APP_CONFIG, useValue: environment.appConfig }],
};
@Injectable({ providedIn: "root" })
export class BooksApi {
private config = inject(APP_CONFIG);
private http = inject(HttpClient);
list(page: number) {
return this.http.get<Book[]>(`${this.config.apiUrl}/books`, {
params: { page, size: this.config.pageSize },
});
}
}
El InjectionToken con valor predeterminado es una variante muy útil. Al pasarle una fábrica, el token se registra automáticamente en la raíz y no hace falta declararlo en ningún array de providers, aunque cualquiera puede sobrescribirlo:
export const PAGE_SIZE = new InjectionToken<number>("PAGE_SIZE", {
providedIn: "root",
factory: () => 20,
});
Esta técnica también sirve para envolver APIs del navegador. Inyectar window o localStorage a través de un token facilita las pruebas y permite ofrecer alternativas en entornos donde esos objetos no existen, como el renderizado en servidor:
import { DOCUMENT } from "@angular/common";
export const WINDOW = new InjectionToken<Window | null>("WINDOW", {
providedIn: "root",
factory: () => inject(DOCUMENT).defaultView,
});
Providers múltiples con multi true
Algunos tokens representan colecciones de valores aportados desde distintos lugares. Con la opción multi: true, cada registro añade un elemento a un array en lugar de reemplazar al anterior:
export interface HealthCheck {
name: string;
check: () => Promise<boolean>;
}
export const HEALTH_CHECKS = new InjectionToken<HealthCheck[]>("HEALTH_CHECKS");
// app.config.ts
providers: [
{
provide: HEALTH_CHECKS,
multi: true,
useValue: { name: "api", check: pingApi },
},
{
provide: HEALTH_CHECKS,
multi: true,
useValue: { name: "storage", check: checkStorage },
},
];
@Injectable({ providedIn: "root" })
export class HealthService {
private checks = inject(HEALTH_CHECKS);
async runAll(): Promise<Record<string, boolean>> {
const results = await Promise.all(
this.checks.map(async (c) => [c.name, await c.check()])
);
return Object.fromEntries(results);
}
}
El propio framework usa este mecanismo internamente. Por ejemplo, la inicialización de la aplicación admite funciones que se ejecutan antes del arranque mediante provideAppInitializer, la forma actual que sustituye al antiguo token APP_INITIALIZER:
import { provideAppInitializer, inject } from "@angular/core";
providers: [provideAppInitializer(() => inject(FeatureFlagsService).load())];
Si la función devuelve una promesa o un observable, Angular espera a que se complete antes de renderizar la aplicación.
Controlar la búsqueda con opciones de resolución
Por defecto, la búsqueda de una dependencia recorre los inyectores hacia arriba y lanza un error si no la encuentra. La función inject() acepta un segundo argumento con opciones que modifican ese comportamiento. Estas opciones sustituyen a los decoradores @Optional, @Self, @SkipSelf y @Host de la inyección por constructor.
La opción optional devuelve null en lugar de lanzar un error:
@Component({ selector: "app-analytics-badge", template: "" })
export class AnalyticsBadgeComponent {
private tracker = inject(AnalyticsTracker, { optional: true });
track(event: string): void {
this.tracker?.send(event);
}
}
La opción self limita la búsqueda al inyector del propio elemento. Si la dependencia no está registrada allí, no se busca en los padres:
private local = inject(FormFieldState, { self: true });
La opción skipSelf hace lo contrario: empieza la búsqueda en el padre, ignorando el inyector propio. Es la clave para construir estructuras recursivas, como un árbol de menús en el que cada nodo necesita acceder al nodo padre:
@Component({
selector: "app-menu-node",
providers: [MenuNode],
template: `<ng-content />`,
})
export class MenuNodeComponent {
protected node = inject(MenuNode);
protected parent = inject(MenuNode, { skipSelf: true, optional: true });
}
La opción host detiene la búsqueda en el componente anfitrión del elemento actual. Es útil en directivas que deben colaborar con el componente sobre el que se aplican sin alcanzar servicios de niveles superiores.
Las opciones se pueden combinar. { skipSelf: true, optional: true }, como en el ejemplo del menú, es una combinación muy habitual: el nodo raíz no tiene padre y recibe null en lugar de provocar un error.
Referencias del framework disponibles por inyección
Además de nuestros servicios, Angular registra automáticamente varias referencias útiles que cualquier componente o directiva puede inyectar sin declararlas en ningún array de providers. Entre las más usadas están ElementRef, que da acceso al elemento del DOM anfitrión; Renderer2, que permite manipularlo de forma segura; DestroyRef, para registrar limpieza; Injector, el propio inyector, y ChangeDetectorRef.
Un ejemplo clásico es una directiva que resalta su elemento anfitrión. Esta versión usa una entrada basada en signals y un efecto para reaccionar a los cambios del color:
import {
Directive,
ElementRef,
Renderer2,
effect,
inject,
input,
} from "@angular/core";
@Directive({ selector: "[appHighlight]" })
export class HighlightDirective {
private host = inject<ElementRef<HTMLElement>>(ElementRef);
private renderer = inject(Renderer2);
color = input("lightyellow", { alias: "appHighlight" });
constructor() {
effect(() => {
this.renderer.setStyle(
this.host.nativeElement,
"background-color",
this.color()
);
});
}
}
<p [appHighlight]="'#d1fae5'">Libro recomendado del mes</p>
Siempre que sea posible, conviene preferir los bindings de host declarados en el decorador frente a la manipulación directa del DOM. El mismo resultado puede expresarse de forma más declarativa:
@Directive({
selector: "[appHighlight]",
host: { "[style.background-color]": "color()" },
})
export class HighlightDirective {
color = input("lightyellow", { alias: "appHighlight" });
}
Renderer2 sigue siendo la herramienta adecuada cuando la lógica es más compleja que un simple binding, por ejemplo al crear o mover elementos dinámicamente.
Ejecutar código fuera del contexto de inyección
A veces necesitamos usar inject() en un momento posterior a la construcción, por ejemplo dentro de una función que se ejecuta como respuesta a un evento. Para eso existe runInInjectionContext, que ejecuta una función como si estuviera dentro del contexto de un inyector concreto:
import {
Component,
EnvironmentInjector,
inject,
runInInjectionContext,
} from "@angular/core";
@Component({
selector: "app-export-button",
template: '<button (click)="export()">Exportar</button>',
})
export class ExportButtonComponent {
private injector = inject(EnvironmentInjector);
export(): void {
runInInjectionContext(this.injector, () => {
const exporter = inject(ReportExporter);
exporter.downloadCsv();
});
}
}
Para comprobar desde una función utilitaria que se está ejecutando en el contexto correcto, Angular ofrece assertInInjectionContext, que lanza un mensaje claro si no es así. Es una buena práctica incluirlo en funciones reutilizables que dependan de inject().
También es posible crear inyectores manualmente con Injector.create, algo útil al instanciar componentes de forma dinámica con providers específicos:
const childInjector = Injector.create({
providers: [{ provide: DIALOG_DATA, useValue: { bookId: 42 } }],
parent: this.injector,
});
Crear funciones provide reutilizables
Cuando una funcionalidad requiere registrar varios providers relacionados, exponer un array suelto obliga al consumidor a recordar todos los detalles. Es mejor encapsularlos en una función con un nombre descriptivo, igual que hace el propio framework con provideRouter o provideHttpClient:
// src/app/core/logging/provide-logging.ts
import {
EnvironmentProviders,
InjectionToken,
makeEnvironmentProviders,
} from "@angular/core";
export interface LoggingOptions {
remote: boolean;
endpoint?: string;
}
export const LOGGING_OPTIONS = new InjectionToken<LoggingOptions>(
"LOGGING_OPTIONS"
);
export function provideLogging(options: LoggingOptions): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: LOGGING_OPTIONS, useValue: options },
{
provide: Logger,
useClass: options.remote ? RemoteLogger : ConsoleLogger,
},
]);
}
// app.config.ts
providers: [provideLogging({ remote: true, endpoint: "/api/logs" })];
El uso de makeEnvironmentProviders tiene una ventaja adicional: el tipo EnvironmentProviders impide que estos providers se declaren por error en el array de un componente, ya que solo son válidos en inyectores de entorno, como la configuración de la aplicación o las rutas.
Pruebas unitarias aprovechando la inyección
El beneficio más tangible de la inyección de dependencias aparece al escribir pruebas. Como los componentes no crean sus dependencias, podemos sustituirlas por dobles controlados. Las pruebas unitarias con TestBed permiten configurar un inyector de pruebas con los providers que necesitemos:
// catalog.component.spec.ts
import { TestBed } from "@angular/core/testing";
import { signal } from "@angular/core";
import { CatalogComponent } from "./catalog.component";
import { CatalogService } from "./catalog";
describe("CatalogComponent", () => {
it("muestra los libros del servicio", () => {
const fakeCatalog = {
books: signal([
{ id: 1, title: "Código limpio", author: "R. Martin" },
]),
load: () => {},
};
TestBed.configureTestingModule({
imports: [CatalogComponent],
providers: [{ provide: CatalogService, useValue: fakeCatalog }],
});
const fixture = TestBed.createComponent(CatalogComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain("Código limpio");
});
});
Para probar servicios que usan HttpClient, Angular ofrece providers de pruebas que interceptan las peticiones:
import { provideHttpClient } from "@angular/common/http";
import {
provideHttpClientTesting,
HttpTestingController,
} from "@angular/common/http/testing";
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
});
it("carga los libros", () => {
const service = TestBed.inject(CatalogService);
const http = TestBed.inject(HttpTestingController);
service.load();
http.expectOne("/api/books").flush([
{ id: 7, title: "Refactoring", author: "M. Fowler" },
]);
expect(service.books().length).toBe(1);
http.verify();
});
Si un componente declara sus propios providers, configureTestingModule no los sustituye, porque el inyector del componente tiene prioridad. En ese caso hay que usar TestBed.overrideComponent para reemplazarlos:
TestBed.overrideComponent(CartPanelComponent, {
set: { providers: [{ provide: CartState, useValue: fakeCart }] },
});
Las versiones recientes de la CLI utilizan Vitest como ejecutor de pruebas por defecto en proyectos nuevos, aunque la API de TestBed es la misma con cualquier ejecutor.
Errores comunes y cómo resolverlos
El sistema de inyección es robusto, pero hay errores que aparecen una y otra vez. Angular incluye un código en cada mensaje, lo que facilita buscar la causa en la documentación oficial.
El más frecuente es la falta de provider. Ocurre cuando se inyecta un servicio que no está registrado en ningún inyector alcanzable:
ERROR NullInjectorError: R3InjectorError(Environment Injector)[CartState -> CartState]:
NullInjectorError: No provider for CartState!
Las causas típicas son olvidar providedIn: 'root', declarar el servicio en un componente que no es ancestro del que lo pide o registrarlo en una ruta distinta. La solución es revisar la cadena de inyectores y registrar el servicio en el nivel adecuado.
El segundo error clásico es llamar a inject() fuera de un contexto de inyección:
NG0203: inject() must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Suele ocurrir al mover una llamada a inject() dentro de un método, de ngOnInit o de una función asíncrona. La solución es inyectar la dependencia como propiedad de la clase y usarla después, o recurrir a runInInjectionContext si realmente se necesita resolverla más tarde.
El tercero son las dependencias circulares, cuando el servicio A necesita al B y el B necesita al A:
NG0200: Circular dependency in DI detected for AuthService.
Este error casi siempre revela un problema de diseño. Lo habitual es extraer la lógica compartida a un tercer servicio del que dependan ambos, o invertir la relación mediante eventos u observables.
Un problema más silencioso son las instancias duplicadas. Si un servicio tiene providedIn: 'root' y además se añade al array providers de un componente, ese componente y sus hijos recibirán una instancia nueva, distinta de la global. Los síntomas son estados que “no se sincronizan” entre partes de la aplicación. La solución es eliminar el registro redundante.
Finalmente, inyectar una interfaz de TypeScript no funciona porque las interfaces desaparecen al compilar. Hay que usar una clase abstracta o un InjectionToken como identificador.
Buenas prácticas para proyectos reales
Después de ver todas las piezas, conviene resumir los criterios que funcionan bien en aplicaciones de producción.
Usa providedIn: 'root' por defecto para servicios sin estado o con estado verdaderamente global. Es la opción más simple, permite el tree-shaking y evita instancias duplicadas.
Reserva los providers de componente para estado que debe ser independiente por instancia, como el estado de un formulario complejo, un asistente de varios pasos o un panel reutilizable. Usa @Injectable() sin providedIn en esos servicios para dejar clara la intención.
Aprovecha los providers de ruta para funcionalidades completas que solo se usan en una sección de la aplicación, combinados con carga diferida para reducir el tamaño del paquete inicial.
Prefiere inject() en el código nuevo y migra gradualmente el existente con el esquema automático de la CLI. Mantén las llamadas a inject() agrupadas al inicio de la clase para que las dependencias sean visibles de un vistazo.
Depende de abstracciones cuando una implementación pueda variar por entorno o en pruebas. Una clase abstracta como token ofrece tipado y flexibilidad sin coste adicional.
Encapsula configuraciones complejas en funciones provide propias, de modo que los consumidores no tengan que conocer los detalles internos.
Evita los servicios gigantes. Si un servicio inyecta diez dependencias, probablemente está asumiendo demasiadas responsabilidades y conviene dividirlo.
Inyección de dependencias en Angular frente a otros frameworks
Angular es el único de los grandes frameworks frontend con un sistema de inyección de dependencias completo integrado de serie. Comparar enfoques ayuda a entender por qué.
React no tiene un contenedor de inyección. Su mecanismo más cercano es el Context, que permite pasar valores a través del árbol de componentes sin hacerlo prop por prop. Cumple una función parecida a los providers de componente, pero no ofrece recetas como fábricas o alias, ni gestiona el ciclo de vida de los servicios. Para necesidades más avanzadas se recurre a librerías externas.
Vue ofrece provide e inject, un mecanismo jerárquico inspirado directamente en Angular pero más ligero. Permite compartir valores entre componentes ancestros y descendientes, aunque sin el registro global con tree-shaking ni la variedad de opciones de resolución.
En el lado del servidor, NestJS adopta un sistema prácticamente idéntico al de Angular, con decoradores @Injectable, módulos y providers. Por eso los equipos que trabajan con ambos frameworks pueden reutilizar buena parte de sus conocimientos y patrones de arquitectura.
La conclusión es que el sistema de Angular exige algo más de aprendizaje inicial, pero a cambio ofrece una estructura muy explícita que resulta especialmente valiosa en aplicaciones grandes y equipos numerosos.
Conclusiones
La inyección de dependencias es mucho más que una forma de recibir servicios en un componente. Es el mecanismo que define cómo se organiza una aplicación Angular, cuánto vive cada pieza de estado y qué partes pueden sustituirse sin tocar el resto del código.
Hemos visto que Angular mantiene una jerarquía de inyectores de entorno y de elementos, y que la búsqueda de dependencias asciende por esa jerarquía hasta encontrar un provider. Esa búsqueda es la que nos permite elegir el alcance correcto: global con providedIn: 'root', por funcionalidad con los providers de ruta o por instancia con los providers de componente.
La sintaxis actual hace todo esto más directo. La función inject() simplifica la declaración de dependencias y habilita patrones funcionales; las funciones provide sustituyen a los módulos en la configuración; los InjectionToken permiten inyectar configuraciones y APIs del navegador con tipado completo, y las opciones de resolución dan un control preciso sobre dónde se busca cada dependencia.
Por último, el beneficio más tangible aparece en las pruebas: sustituir un servicio real por un doble es cuestión de una línea en la configuración de TestBed. Dominar este sistema es, en buena medida, dominar la arquitectura de Angular, y es una inversión que se nota en cada proyecto que crece más allá de unas pocas pantallas.
