Módulo 08: Servicios e inyección de dependencias
Objetivos del módulo
- Entender qué problema resuelve la inyección de dependencias y qué hace un inyector por dentro
- Crear servicios con estado en señales y consumirlos con
inject() - Usar
@Servicey sus opcionesfactoryyautoProvided, y saber cuándo sigue haciendo falta@Injectable - Reconocer el contexto de inyección y resolver el error NG0203
- Razonar sobre la jerarquía de inyectores: plataforma, raíz, rutas y elementos
- Distinguir
providersdeviewProvidersy usar los modificadoresoptional,self,skipSelfyhost - Crear tokens con
InjectionTokeny proveedores conuseClass,useValue,useFactory,useExistingymulti - Agrupar la configuración de una funcionalidad en una función
provide... - Controlar el ciclo de vida de un servicio con
ngOnDestroy,DestroyRefycreateEnvironmentInjector - Cargar servicios bajo demanda con
injectAsyncy precargarlos cononIdle - Sustituir dependencias en las pruebas y diagnosticar los errores habituales de la inyección
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Los ejemplos de este módulo usan el decorador
@Service. Si usas la LTS: escribe@Injectable({ providedIn: 'root' })donde veas@Service()y@Injectable()donde veas@Service({ autoProvided: false }). Con ese cambio, todo el código del módulo compila y pasa sus pruebas en Angular 21.2, salvo lo marcado expresamente en cada apartado.
1. Por qué inyección de dependencias
Una clase rara vez trabaja sola. Una lista de empleados necesita pedir los datos a una API, y esa API necesita registrar los errores en algún sitio. A esas clases que otra necesita para funcionar las llamamos dependencias. La pregunta es quién las crea.
La respuesta más directa es que cada clase cree las suyas con new:
export class Registro {
error(mensaje: string) {
console.error(mensaje);
}
}
export class ApiEmpleados {
private readonly registro = new Registro();
private readonly url = 'http://localhost:3000/api/empleados';
}
export class ListaEmpleados {
private readonly api = new ApiEmpleados();
}
Funciona, pero en cuanto la aplicación crece aparecen estos problemas:
- Instancias duplicadas. Si
FichaEmpleadotambién hacenew ApiEmpleados(), hay dos objetos distintos. Si uno guarda datos en memoria, el otro no los ve. - Acoplamiento.
ListaEmpleadosconoce la clase concreta. Cambiarla por otra obliga a tocar todos los sitios que hacennew. - Pruebas difíciles. No hay manera de probar
ListaEmpleadossin hablar con el servidor real. - Conocimiento en cadena. Si
ApiEmpleadosnecesita un parámetro nuevo en el constructor, hay que modificar a todos los que la crean.
La inyección de dependencias (dependency injection, DI) le da la vuelta al planteamiento: la clase deja de crear lo que usa y simplemente lo pide. Otra pieza se encarga de crearlo y entregarlo. Es como un cocinero que pide tomates al almacén en lugar de cultivarlos: no le importa de dónde vienen, y el almacén puede cambiar de proveedor sin que la cocina se entere.
Figura 1. Sin inyección cada clase fabrica sus dependencias; con inyección las pide y el inyector las comparte.
Las cuatro piezas
📘 Concepto: Un token es la clave con la que se pide algo. Un proveedor (provider) es la receta que dice cómo conseguir el valor de ese token. Un inyector (injector) guarda las recetas y los objetos ya creados. Un consumidor es cualquier clase que pide un token sin saber cómo se crea.
Figura 2. Las cuatro piezas de la inyección de dependencias.
Qué hace un inyector por dentro
Un inyector no es magia. Este programa implementa lo esencial en unas 30 líneas de TypeScript, sin Angular. Guárdalo como mini-inyector.ts y ejecútalo con node mini-inyector.ts (Node 24 ejecuta TypeScript directamente).
type Fabrica = (inyector: MiniInyector) => unknown;
class MiniInyector {
private readonly fabricas = new Map<unknown, Fabrica>();
private readonly instancias = new Map<unknown, unknown>();
private readonly padre?: MiniInyector;
constructor(padre?: MiniInyector) {
this.padre = padre;
}
registrar(token: unknown, fabrica: Fabrica): void {
this.fabricas.set(token, fabrica);
}
obtener<T>(token: unknown): T {
// 1. ¿Ya la creé? Devuelvo la misma: por eso hay una sola instancia
if (this.instancias.has(token)) {
return this.instancias.get(token) as T;
}
// 2. ¿Sé crearla? La creo y la guardo
const fabrica = this.fabricas.get(token);
if (fabrica) {
const instancia = fabrica(this);
this.instancias.set(token, instancia);
return instancia as T;
}
// 3. Si no, pregunto a mi padre. Sin padre, error
if (this.padre) {
return this.padre.obtener<T>(token);
}
throw new Error(`No hay proveedor para ${String(token)}`);
}
}
// ---- Uso ----
class Registro {
id = Math.floor(Math.random() * 1000);
}
class ApiEmpleados {
readonly registro: Registro;
constructor(registro: Registro) {
this.registro = registro;
}
}
const raiz = new MiniInyector();
raiz.registrar('Registro', () => new Registro());
raiz.registrar('ApiEmpleados', (i) => new ApiEmpleados(i.obtener<Registro>('Registro')));
const hijo = new MiniInyector(raiz);
hijo.registrar('Registro', () => new Registro()); // el hijo tiene el suyo
const a = raiz.obtener<ApiEmpleados>('ApiEmpleados');
const b = raiz.obtener<ApiEmpleados>('ApiEmpleados');
console.log('Misma ApiEmpleados:', a === b);
console.log('Mismo Registro desde el hijo:', hijo.obtener('Registro') === raiz.obtener('Registro'));
console.log('ApiEmpleados desde el hijo:', hijo.obtener('ApiEmpleados') === a);
try {
hijo.obtener('Nominas');
} catch (error) {
console.log((error as Error).message);
}
Misma ApiEmpleados: true
Mismo Registro desde el hijo: false
ApiEmpleados desde el hijo: true
No hay proveedor para Nominas
Estas cuatro líneas resumen casi todo el módulo:
- La segunda petición devuelve el mismo objeto: cada inyector crea una sola instancia por token.
- El hijo tiene su propio
Registro, que tapa al del padre. Es la base de la jerarquía de inyectores (apartado 6). - Lo que el hijo no conoce se lo pide al padre.
- Si nadie en la cadena conoce el token, se produce un error.
El inyector real de Angular añade mucho más, pero sigue este mismo algoritmo.
2. Tu primer servicio
Un servicio es una clase que la aplicación comparte mediante el inyector. No es un concepto técnico especial: es una clase normal con un decorador que le dice a Angular cómo proveerla.
Vamos a crear la cesta de un pedido de EMPRESA. El catálogo añade productos y el resumen los muestra. Son componentes independientes, sin relación de padre e hijo, así que input() y output() no sirven para conectarlos (módulo 06). Un servicio sí.
// producto.ts
export interface Producto {
productoId: number;
nombre: string;
precio: number;
}
export const PRODUCTOS: Producto[] = [
{ productoId: 1, nombre: 'Portátil UltraBook 14', precio: 899 },
{ productoId: 7, nombre: 'Teclado mecánico', precio: 79.9 },
{ productoId: 9, nombre: 'Monitor 24 FHD', precio: 139 },
];
// cesta.ts
import { computed, Service, signal } from '@angular/core';
import { Producto } from './producto';
export interface LineaCesta {
producto: Producto;
unidades: number;
}
@Service()
export class Cesta {
// La señal escribible es privada: solo la Cesta puede cambiar su estado
private readonly _lineas = signal<LineaCesta[]>([]);
readonly lineas = this._lineas.asReadonly();
readonly unidades = computed(() =>
this._lineas().reduce((suma, linea) => suma + linea.unidades, 0),
);
readonly total = computed(() =>
this._lineas().reduce((suma, linea) => suma + linea.unidades * linea.producto.precio, 0),
);
anadir(producto: Producto): void {
this._lineas.update((lineas) => {
const existe = lineas.some((l) => l.producto.productoId === producto.productoId);
return existe
? lineas.map((l) =>
l.producto.productoId === producto.productoId
? { ...l, unidades: l.unidades + 1 }
: l,
)
: [...lineas, { producto, unidades: 1 }];
});
}
vaciar(): void {
this._lineas.set([]);
}
}
Fíjate en el patrón, que usarás en casi todos los servicios con estado:
- La señal escribible
_lineases privada. Nadie de fuera puede hacercesta.lineas.set([]). - Hacia fuera se expone una versión de solo lectura con
asReadonly()y valores derivados concomputed(módulo 05). - Cambiar el estado solo es posible mediante métodos con nombre, como
anadiryvaciar. Así las reglas del negocio viven en un único sitio.
Los componentes consiguen la cesta con la función inject():
// catalogo.ts
import { Component, inject } from '@angular/core';
import { Cesta } from './cesta';
import { PRODUCTOS } from './producto';
@Component({
selector: 'app-catalogo',
template: `
<h3>Catálogo</h3>
<ul>
@for (producto of productos; track producto.productoId) {
<li>
{{ producto.nombre }} ({{ producto.precio }} €)
<button (click)="cesta.anadir(producto)">Añadir</button>
</li>
}
</ul>
`,
})
export class Catalogo {
protected readonly cesta = inject(Cesta);
protected readonly productos = PRODUCTOS;
}
// resumen-cesta.ts
import { Component, inject } from '@angular/core';
import { Cesta } from './cesta';
@Component({
selector: 'app-resumen-cesta',
template: `
<h3>Tu cesta ({{ cesta.unidades() }})</h3>
<ul>
@for (linea of cesta.lineas(); track linea.producto.productoId) {
<li>{{ linea.unidades }} × {{ linea.producto.nombre }}</li>
} @empty {
<li>Está vacía</li>
}
</ul>
<p><strong>Total: {{ cesta.total() }} €</strong></p>
<button (click)="cesta.vaciar()">Vaciar</button>
`,
})
export class ResumenCesta {
protected readonly cesta = inject(Cesta);
}
// pagina-cesta.ts
import { Component } from '@angular/core';
import { Catalogo } from './catalogo';
import { ResumenCesta } from './resumen-cesta';
@Component({
selector: 'app-pagina-cesta',
imports: [Catalogo, ResumenCesta],
template: `
<app-catalogo />
<app-resumen-cesta />
`,
})
export class PaginaCesta {}
La propiedad es protected porque la plantilla debe acceder a ella, pero el resto de la aplicación no.

Figura 3. Captura real: dos componentes sin relación directa comparten la misma Cesta.
Catalogo no sabe que ResumenCesta existe y sin embargo el resumen se actualiza: los dos reciben el mismo objeto del inyector, y las señales avisan de cada cambio.
Generar un servicio con la CLI 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21)
ng generate servicecrea una clase con@Injectable({ providedIn: 'root' }). Si usas la LTS: no tienes que hacer nada: el mismo comando te da la versión con@Injectable, que funciona igual.
ng generate service datos/empleados
CREATE src/app/datos/empleados.spec.ts (350 bytes)
CREATE src/app/datos/empleados.ts (85 bytes)
import { Service } from '@angular/core';
@Service()
export class Empleados {}
Como en el resto del proyecto, ni el fichero ni la clase llevan sufijo: empleados.ts y Empleados, no empleados.service.ts y EmpleadosService. Tienes dos opciones útiles:
| Opción | Resultado |
|---|---|
--injectable | Genera @Injectable({ providedIn: 'root' }) en lugar de @Service() |
--type=service | Genera empleados.service.ts con la clase EmpleadosService |
--skip-tests | No genera el fichero .spec.ts |
¿Cuándo merece la pena un servicio?
| Necesitas… | Solución |
|---|---|
| Estado que comparten varios componentes | Servicio con señales |
Acceso a una API, localStorage u otro recurso externo | Servicio, para poder sustituirlo en las pruebas |
| Lógica de negocio que no depende de la vista | Servicio |
| Algo que se pueda cambiar según la configuración | Servicio o token |
| Una función pura, como formatear un NIF | Una función exportada: no hace falta inyectar nada |
| Una constante que nunca cambia | Una constante exportada |
Estado compartido: siempre en señales 🔄 v22.0
🔄 Cambia en Angular 22.0. En la LTS (Angular 21) el valor por defecto es
Eager, y este ejemplo muestraValor: 3. Si usas la LTS: guarda igualmente el estado en señales: funciona en las dos versiones y tu código no se romperá al actualizar.
Veamos qué pasa si el estado del servicio es una propiedad normal:
// Estado en una propiedad normal, sin señal
@Service()
export class ContadorPlano {
valor = 0;
sumar(): void {
this.valor++;
}
}
@Component({
selector: 'app-boton-plano',
template: `<button (click)="contador.sumar()">Sumar</button>`,
})
export class BotonPlano {
protected readonly contador = inject(ContadorPlano);
}
@Component({
selector: 'app-marcador-plano',
template: `<p>Valor: {{ contador.valor }}</p>`,
})
export class MarcadorPlano {
protected readonly contador = inject(ContadorPlano);
}
Resultado real en Chromium después de pulsar tres veces el botón:
Angular 22.2: Valor: 0
Angular 21.2: Valor: 3
En ambos casos contador.valor vale 3. La diferencia está en la vista. En Angular 22 los componentes usan OnPush por defecto, y MarcadorPlano no tiene ningún motivo para revisarse: no ha recibido un evento, ni un input, ni ha cambiado una señal que lea. El módulo 09 lo explica a fondo. La regla práctica es simple:
⚠️ Importante: Todo dato de un servicio que se muestre en una plantilla debe estar en una señal. Una propiedad normal puede cambiar sin que la pantalla se entere, y no se produce ningún error que te avise.
3. Servicios con @Service 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: usa
@Injectable({ providedIn: 'root' })en lugar de@Service(),@Injectable()en lugar de@Service({ autoProvided: false })y@Injectable({ providedIn: 'root', useFactory: ... })en lugar de@Service({ factory: ... }).
@Service es el decorador moderno para crear servicios. Sus tres formas cubren los casos habituales:
| Forma | Efecto |
|---|---|
@Service() | Una instancia para toda la aplicación, en el inyector raíz. No hay que registrarla en ningún sitio |
@Service({ autoProvided: false }) | No está disponible hasta que la añadas a una lista providers |
@Service({ factory: () => ... }) | Disponible en la raíz, pero el valor lo crea tu función |
Sin inyección por constructor
@Service solo admite inject(). Si declaras dependencias como parámetros del constructor, la compilación falla:
@Service()
export class ConParametro {
constructor(private reloj: Reloj) {}
}
error NG2028: @Service class cannot use constructor dependency injection. Use the `inject` function instead.
El error salta con cualquier parámetro, incluso con uno que tenga valor por defecto. Lo que sí puedes hacer es tener un constructor sin parámetros y usar inject() dentro, y heredar de otro servicio:
@Service()
export class ConCuerpo {
private reloj: Reloj;
constructor() {
this.reloj = inject(Reloj);
}
}
La opción factory
Con factory, el servicio decide en el momento de crearse qué objeto entrega. La función se ejecuta en un contexto de inyección, así que puede usar inject(). Un caso típico es una clase abstracta con varias implementaciones:
import { inject, InjectionToken, Service } from '@angular/core';
export const MODO_DEMO = new InjectionToken<boolean>('MODO_DEMO', {
factory: () => false,
});
@Service({
factory: () => (inject(MODO_DEMO) ? new AnaliticaSilenciosa() : new AnaliticaConsola()),
})
export abstract class Analitica {
abstract registrar(evento: string): string;
}
class AnaliticaConsola extends Analitica {
registrar(evento: string): string {
console.log(`Analítica: ${evento}`);
return `enviado: ${evento}`;
}
}
class AnaliticaSilenciosa extends Analitica {
registrar(evento: string): string {
return `ignorado: ${evento}`;
}
}
Quien consume no se entera de nada: hace inject(Analitica) y llama a registrar. Las pruebas confirman que registrar('login') devuelve enviado: login en modo normal e ignorado: login cuando se provee MODO_DEMO con true.
⚠️ Importante:
factorysolo actúa cuando el servicio se resuelve de forma automática en la raíz. Si además añades esa clase aproviders: [ConFabrica]en un componente, Angular crea un objeto de la propia clase e ignora la fábrica. Lo comprobamos con un servicio cuya fábrica devolvía{ tipo: 'fabrica' }y cuya clase teníatipo = 'clase': desdeprovidersse obtuvotipo: 'clase'.
La opción autoProvided
autoProvided: false sirve para servicios que no tienen sentido como instancia global, por ejemplo uno que representa el estado de un formulario abierto. Si olvidas proveerlo, Angular te lo dice con un error en lugar de crear en silencio una instancia global:
NG0201: No provider found for `_Auditoria`. Source: Environment Injector. Find more at https://v22.angular.dev/errors/NG0201
El guion bajo de _Auditoria lo añade el empaquetador al compilar; la clase se llama Auditoria.
@Service no acepta ninguna otra opción. @Service({ providedIn: 'root' }) y @Service({ useClass: Otra }) dan error de tipos (TS2769), y tampoco se pueden combinar autoProvided: false y factory.
4. @Injectable: la forma clásica
@Injectable existe desde las primeras versiones de Angular y sigue siendo plenamente válido. Lo encontrarás en casi todo el código existente, y sigue haciendo falta en algunos casos.
Inyección por constructor
Antes de inject(), las dependencias se declaraban como parámetros del constructor. Angular leía sus tipos durante la compilación:
import { computed, Inject, Injectable, signal } from '@angular/core';
import { API_URL } from '../empresa/api-url';
import { Departamento } from '../empresa/departamento';
import { RepositorioDepartamentos } from '../empresa/repositorio-departamentos';
@Injectable({ providedIn: 'root' })
export class InformeDepartamentos {
readonly departamentos = signal<Departamento[]>([]);
readonly numero = computed(() => this.departamentos().length);
// Inyección por constructor: las dependencias son parámetros
constructor(
private readonly repositorio: RepositorioDepartamentos,
@Inject(API_URL) readonly apiUrl: string, // los tokens que no son clases necesitan @Inject
) {}
async cargar(): Promise<void> {
this.departamentos.set(await this.repositorio.listar());
}
}
Un token que no es una clase no se puede deducir del tipo: apiUrl: string no dice qué cadena quieres. Por eso hace falta @Inject(API_URL). Sin él, la compilación falla con NG2003: No suitable injection token for parameter.
inject() tiene además una ventaja práctica. Con el constructor, un inicializador de campo no puede leer inmediatamente la dependencia:
@Injectable({ providedIn: 'root' })
export class InmediatoConstructor {
readonly inicial = this.datos.valor();
constructor(private readonly datos: Datos) {}
}
error TS2729: Property 'datos' is used before its initialization.
Con private readonly datos = inject(Datos); en la línea anterior, el campo inicial funciona, porque los campos se inicializan en orden. (Dentro de un computed no hay problema en ningún caso, ya que su función se ejecuta más tarde.) Para convertir código antiguo tienes una migración que existe tanto en Angular 21 como en 22:
ng generate @angular/core:inject
Valores de providedIn
| Valor | Efecto |
|---|---|
'root' | Una instancia en el inyector raíz, equivalente a @Service() |
'platform' | Una instancia compartida por todas las aplicaciones Angular de la página |
Sin providedIn | Hay que añadirlo a providers, equivalente a @Service({ autoProvided: false }) |
'any' o un NgModule | Obsoletos: no los uses en código nuevo |
Elegir entre @Service e @Injectable
| Necesidad | @Service | @Injectable |
|---|---|---|
| Servicio global | @Service() | @Injectable({ providedIn: 'root' }) |
| Provisto a mano | autoProvided: false | @Injectable() |
| Valor creado por una función | factory | useFactory |
| Inyección por constructor | No | Sí |
providedIn: 'platform' | No | Sí |
useClass, useValue o useExisting en el decorador | No | Sí |
| Disponible en Angular 21 | No | Sí |
En código nuevo con Angular 22, usa @Service salvo que necesites una de las filas que solo admite @Injectable.
¿Hace falta algún decorador?
No siempre. Una clase sin decorador y sin parámetros en el constructor se puede añadir a providers, e incluso usar inject() en sus campos. Lo comprobamos con una clase SinDecorador: desde un componente con providers: [SinDecorador] funcionó, y sin esa línea dio NG0201. El decorador aporta la provisión automática y documenta la intención, así que úsalo siempre.
De @Injectable a @Service 🆕 v22.1
🆕 Desde Angular 22.1. No existe en la LTS (Angular 21). Si usas la LTS: no la necesitas: tu código con
@Injectableseguirá funcionando igual cuando actualices a Angular 22.
ng generate @angular/core:service-migration --path src/app
La migración es prudente. Sobre un fichero de prueba hizo esto:
| Antes | Después |
|---|---|
@Injectable({ providedIn: 'root' }) sin constructor | @Service() |
@Injectable() con inject() | @Service({ autoProvided: false }) |
@Injectable({ providedIn: 'root' }) con parámetros en el constructor | Sin cambios |
@Injectable({ providedIn: 'root', useFactory: ... }) | Sin cambios |
@Injectable({ providedIn: 'platform' }) | Sin cambios |
El módulo 28 trata las migraciones con detalle.
5. El contexto de inyección
inject() no recibe ningún inyector como parámetro. ¿Cómo sabe a cuál preguntar? Angular mantiene una variable interna con el inyector activo mientras crea un objeto. Ese intervalo se llama contexto de inyección. Fuera de él, inject() no tiene a quién preguntar.
| Dónde | ¿Hay contexto? |
|---|---|
| Inicializadores de campo de clases creadas por Angular | Sí |
| Constructores de esas clases | Sí |
factory de @Service y de InjectionToken, useFactory | Sí |
Dentro de runInInjectionContext | Sí |
| Guards e interceptores funcionales (módulos 11 y 12) | Sí |
| Métodos y manejadores de eventos | No |
ngOnInit y demás ganchos del ciclo de vida | No |
Después de un await o dentro de setTimeout | No |
La función de un computed | No |
Hemos probado cada caso negativo de la tabla, salvo setTimeout, y todos dan el mismo error:
NG0203: The `_Reloj` token injection failed. `inject()` function must be called from an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`. Find more at https://v22.angular.dev/errors/NG0203
Casi siempre la solución es mover el inject() a un campo y usar luego ese campo:
export class Ficha {
private readonly cesta = inject(Cesta); // correcto: inicializador de campo
async guardar(): Promise<void> {
await this.preparar();
this.cesta.vaciar(); // correcto: solo usa el campo
}
}
Funciones inject... propias
Como inject() funciona en cualquier función llamada dentro del contexto, puedes encapsular lógica reutilizable. Esta función entrega el ancho de la ventana como señal y limpia su propio escuchador cuando se destruye quien la usa:
import {
assertInInjectionContext,
DestroyRef,
DOCUMENT,
inject,
Signal,
signal,
} from '@angular/core';
export function injectAnchoVentana(): Signal<number> {
// Si alguien la llama desde un método, el error nombra a esta función
assertInInjectionContext(injectAnchoVentana);
const ventana = inject(DOCUMENT).defaultView!;
const destroyRef = inject(DestroyRef);
const ancho = signal(ventana.innerWidth);
const alCambiar = () => ancho.set(ventana.innerWidth);
ventana.addEventListener('resize', alCambiar);
destroyRef.onDestroy(() => ventana.removeEventListener('resize', alCambiar));
return ancho.asReadonly();
}
assertInInjectionContext no es obligatorio, pero mejora el mensaje de error: nombra tu función en lugar de una dependencia interna.
NG0203: injectAnchoVentana() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Prestar un contexto con runInInjectionContext
A veces necesitas llamar a una de estas funciones más tarde. Guarda el inyector en un campo y préstalo cuando haga falta:
import { Component, inject, Injector, runInInjectionContext, Signal, signal } from '@angular/core';
import { injectAnchoVentana } from './ancho-ventana';
@Component({
selector: 'app-pagina-contexto',
template: `
<p>Ancho desde el principio: {{ ancho() }} px</p>
<button id="activar" (click)="activarMedidor()">Activar medidor</button>
@if (medidor(); as anchoTardio) {
<p id="tardio">Medidor activado después: {{ anchoTardio() }} px</p>
}
<button id="mal" (click)="activarMal()">Activar mal</button>
`,
})
export class PaginaContexto {
private readonly injector = inject(Injector);
// Inicializador de campo: aquí hay contexto de inyección
protected readonly ancho = injectAnchoVentana();
protected readonly medidor = signal<Signal<number> | null>(null);
// Un manejador de eventos no tiene contexto: hay que prestárselo
protected activarMedidor(): void {
runInInjectionContext(this.injector, () => {
this.medidor.set(injectAnchoVentana());
});
}
protected activarMal(): void {
this.medidor.set(injectAnchoVentana());
}
}
En Chromium, activarMedidor funciona y el medidor sigue el tamaño de la ventana: al pasar de 800 a 700 px, las dos líneas mostraron 700 px. activarMal produce el error anterior. Como el inyector es el del componente, DestroyRef también lo es y el escuchador se elimina al destruirse el componente.
💡 Consejo: Si solo quieres obtener un servicio más tarde, no hace falta
runInInjectionContext: basta conthis.injector.get(Servicio). Resérvalo para ejecutar código que llama internamente ainject().
6. Jerarquía de inyectores
Una aplicación Angular no tiene un inyector, sino muchos, organizados en dos árboles.
Figura 4. Los dos árboles de inyectores.
- Los inyectores de entorno (
EnvironmentInjector) existen con independencia de la vista. Se configuran con@Service(), conapp.config.tsy con losprovidersde una ruta (módulo 10). - Los inyectores de elemento forman un árbol paralelo al del DOM. Se configuran con
providersyviewProvidersen@Componenty@Directive.
La búsqueda sigue siempre el mismo orden: primero sube por el árbol de elementos y, si no encuentra nada, continúa por el de entorno. El primer proveedor que encuentra gana.
Figura 5. La búsqueda se detiene en el primer inyector que conoce el token.
Un servicio por componente
Supón que un comercial atiende a dos clientes a la vez, cada uno con su pedido. Una cesta global no sirve. Basta con declarar Cesta en providers para que cada instancia del componente tenga la suya:
// panel-cliente.ts
import { Component, input } from '@angular/core';
import { Catalogo } from './catalogo';
import { Cesta } from './cesta';
import { ResumenCesta } from './resumen-cesta';
@Component({
selector: 'app-panel-cliente',
imports: [Catalogo, ResumenCesta],
providers: [Cesta], // cada panel crea su propia Cesta
template: `
<h2>{{ cliente() }}</h2>
<app-catalogo />
<app-resumen-cesta />
`,
})
export class PanelCliente {
readonly cliente = input.required<string>();
}
// pagina-clientes.ts
import { Component } from '@angular/core';
import { PanelCliente } from './panel-cliente';
import { ResumenCesta } from './resumen-cesta';
@Component({
selector: 'app-pagina-clientes',
imports: [PanelCliente, ResumenCesta],
template: `
<app-panel-cliente cliente="Talleres Martín S.L." />
<app-panel-cliente cliente="Construcciones Levante S.A." />
<section class="global">
<h2>Fuera de los paneles</h2>
<app-resumen-cesta />
</section>
`,
})
export class PaginaClientes {}
Catalogo y ResumenCesta son los mismos de antes, sin ningún cambio: siguen haciendo inject(Cesta). Lo que cambia es dónde encuentran la respuesta.

Figura 6. Captura real: cada panel tiene su Cesta, y el resumen exterior usa la de la raíz, que sigue vacía.
Es la misma clase con tres instancias: una por panel y otra en la raíz. Como @Service() provee en la raíz, el resumen exterior no falla. Si quieres que usar Cesta fuera de un panel sea un error, decláralo con @Service({ autoProvided: false }).
Dónde declarar cada proveedor
| Lugar | Instancias | Vive hasta | Uso típico |
|---|---|---|---|
@Service() | Una | Se destruye la aplicación | Estado global, acceso a datos |
providers de app.config.ts | Una | Se destruye la aplicación | Configuración, sustituir implementaciones |
providers de una ruta | Una para esa ruta y sus hijas | Por defecto, igual que la aplicación (apartado 12) | Estado de una sección |
providers de un componente | Una por cada instancia del componente | Se destruye el componente | Estado de un editor o panel |
⚠️ Importante:
provideRouter,provideHttpClienty las demás funciones que devuelvenEnvironmentProviderssolo valen en inyectores de entorno. En losprovidersde un componente no compilan:TS2322: Type 'EnvironmentProviders' is not assignable to type 'Provider'.
7. providers frente a viewProviders
Un componente tiene dos zonas: su vista, escrita en su plantilla, y el contenido proyectado, que recibe de fuera mediante <ng-content> (módulo 06). providers es visible para las dos. viewProviders solo para la vista.
import { Component, inject, InjectionToken, input } from '@angular/core';
// Si nadie lo sobrescribe, su valor sale de esta fábrica, en el inyector raíz
export const ORIGEN = new InjectionToken<string>('ORIGEN', {
factory: () => 'inyector raíz',
});
@Component({
selector: 'app-muestra',
template: `<p>{{ rotulo() }} → <strong>{{ origen }}</strong></p>`,
})
export class Muestra {
readonly rotulo = input.required<string>();
protected readonly origen = inject(ORIGEN);
}
@Component({
selector: 'app-con-providers',
imports: [Muestra],
providers: [{ provide: ORIGEN, useValue: 'providers del padre' }],
template: `
<app-muestra rotulo="Hijo de la vista" />
<ng-content />
`,
})
export class ConProviders {}
@Component({
selector: 'app-con-view-providers',
imports: [Muestra],
viewProviders: [{ provide: ORIGEN, useValue: 'viewProviders del padre' }],
template: `
<app-muestra rotulo="Hijo de la vista" />
<ng-content />
`,
})
export class ConViewProviders {}
@Component({
selector: 'app-pagina-proveedores',
imports: [ConProviders, ConViewProviders, Muestra],
template: `
<section>
<h3>Padre con <code>providers</code></h3>
<app-con-providers>
<app-muestra rotulo="Contenido proyectado" />
</app-con-providers>
</section>
<section>
<h3>Padre con <code>viewProviders</code></h3>
<app-con-view-providers>
<app-muestra rotulo="Contenido proyectado" />
</app-con-view-providers>
</section>
`,
})
export class PaginaProveedores {}

Figura 7. Captura real: el contenido proyectado no ve los viewProviders del padre.
| Hijos de la vista | Contenido proyectado | El propio componente | |
|---|---|---|---|
providers | Lo ven | Lo ven | Lo ve |
viewProviders | Lo ven | No lo ven | Lo ve |
viewProviders sirve para que los detalles internos de un componente no se filtren a quien le proyecta contenido. Si tu componente debe ofrecer un servicio a lo proyectado, como hace el acordeón del ejercicio 5, necesitas providers.
8. Modificadores de inject()
El segundo parámetro de inject() cambia las reglas de la búsqueda.
| Opción | Efecto |
|---|---|
optional: true | Si no lo encuentra, devuelve null en vez de lanzar NG0201 |
self: true | Solo mira en el inyector del propio elemento |
skipSelf: true | Empieza a buscar en el padre |
host: true | No sube más allá del componente que contiene esta plantilla |
export const NIVEL = new InjectionToken<string>('NIVEL', { factory: () => 'raíz' });
// Tiene su propio proveedor de NIVEL
@Component({
selector: 'app-con-proveedor',
imports: [JsonPipe],
providers: [{ provide: NIVEL, useValue: 'propio' }],
template: `<pre>{{ informe | json }}</pre>`,
})
export class ConProveedor {
protected readonly informe = {
normal: inject(NIVEL),
self: inject(NIVEL, { self: true }),
skipSelf: inject(NIVEL, { skipSelf: true }),
host: inject(NIVEL, { host: true, optional: true }),
skipSelfYHost: inject(NIVEL, { skipSelf: true, host: true, optional: true }),
};
}
El componente SinProveedor hace las mismas llamadas, sin proveedor propio y con optional: true en self. Los dos se colocan en la plantilla de dos padres: uno declara NIVEL en providers y el otro en viewProviders.
Salida real, idéntica en Angular 22.2 y 21.2:
Padre con providers: hijo con proveedor
{ "normal": "propio", "self": "propio", "skipSelf": "providers del padre",
"host": "propio", "skipSelfYHost": null }
Padre con providers: hijo sin proveedor
{ "normal": "providers del padre", "self": null,
"skipSelf": "providers del padre", "host": null }
Padre con viewProviders: hijo con proveedor
{ "normal": "propio", "self": "propio", "skipSelf": "viewProviders del padre",
"host": "propio", "skipSelfYHost": "viewProviders del padre" }
Padre con viewProviders: hijo sin proveedor
{ "normal": "viewProviders del padre", "self": null,
"skipSelf": "viewProviders del padre", "host": "viewProviders del padre" }
(Hemos compactado el JSON para que ocupe menos.) Lo más sorprendente es host: la búsqueda llega hasta el componente anfitrión, pero de él solo ve los viewProviders, no sus providers. Por eso da null con el primer padre y encuentra el valor con el segundo. Tiene lógica: los viewProviders pertenecen a la vista donde está escrito el hijo.
💡 Consejo: Sin
optional,selfyhostlanzan NG0201 si no encuentran nada. Según la documentación,selfno debe combinarse conskipSelfni conhost, porque se contradicen. Angular no te avisa: conoptional: trueambas combinaciones devolvieronnullsin error, aunque el token tenía valor en la raíz.
El uso más común de estos modificadores es optional, para dependencias que pueden no estar:
private readonly destinos = inject(DESTINOS_REGISTRO, { optional: true }) ?? [];
9. InjectionToken
Hasta ahora el token ha sido casi siempre una clase. Pero muchas cosas que querrías inyectar no son clases, como la URL de la API o un objeto de configuración. Tampoco sirve una interfaz, porque TypeScript la borra al compilar y en ejecución no existe nada que usar como clave:
error TS2693: 'Config' only refers to a type, but is being used as a value here.
Una cadena tampoco vale con inject('cadena'): no compila. La solución es InjectionToken, un objeto cuya identidad sirve de clave y que lleva el tipo como parámetro genérico:
import { InjectionToken } from '@angular/core';
export const API_URL = new InjectionToken<string>('API_URL');
export class Ejemplo {
private readonly apiUrl = inject(API_URL); // TypeScript sabe que es string
}
El texto del constructor solo sirve para los mensajes de error. Lo que identifica al token es el objeto: dos tokens con el mismo texto son distintos. Un token sin proveedor produce el error habitual: NG0201: No provider found for `InjectionToken API_URL`.
Tokens con valor por defecto
Con una función factory, el token funciona sin registrar nada, igual que @Service(), y se puede sobrescribir donde haga falta. La fábrica tiene contexto de inyección:
import { inject, InjectionToken, isDevMode } from '@angular/core';
export interface ConfigEmpresa {
nombre: string;
tamanoPagina: number;
}
// Una interfaz no existe en ejecución, así que el token la representa
export const CONFIG_EMPRESA = new InjectionToken<ConfigEmpresa>('CONFIG_EMPRESA', {
factory: () => ({ nombre: 'EMPRESA', tamanoPagina: isDevMode() ? 5 : 20 }),
});
// Un token puede depender de otros tokens
export const TITULO_VENTANA = new InjectionToken<string>('TITULO_VENTANA', {
factory: () => `Gestión de ${inject(CONFIG_EMPRESA).nombre}`,
});
Una prueba confirma que inject(TITULO_VENTANA) da Gestión de EMPRESA sin registrar nada, y Gestión de ACME si se provee CONFIG_EMPRESA con otro objeto.
Una clase abstracta como token
Una clase abstracta existe en ejecución, así que sirve a la vez de contrato de tipos y de token, sin necesitar un InjectionToken:
export abstract class RepositorioDepartamentos {
abstract listar(): Promise<Departamento[]>;
}
| Quieres inyectar… | Token recomendado |
|---|---|
| Un servicio | La propia clase |
| Varias implementaciones de un contrato | Clase abstracta |
| Un valor primitivo, un objeto o una función | InjectionToken |
| Una lista que aportan varios módulos | InjectionToken con multi |
Tokens que ya trae Angular
Todos estos se inyectaron en una misma prueba, en Angular 22.2 y 21.2:
| Token | Entrega | Importado de |
|---|---|---|
DOCUMENT | El documento; mejor que usar document directamente, porque funciona en SSR | @angular/core |
LOCALE_ID | El idioma activo; en-US por defecto | @angular/core |
PLATFORM_ID | browser o server | @angular/core |
Injector | El inyector del lugar actual (en un componente, de elemento) | @angular/core |
EnvironmentInjector | El inyector de entorno más cercano | @angular/core |
DestroyRef | Permite registrar limpieza | @angular/core |
ElementRef | El elemento anfitrión; solo en componentes y directivas | @angular/core |
ChangeDetectorRef | Control de la detección de cambios (módulo 09) | @angular/core |
ApplicationRef | La aplicación en ejecución | @angular/core |
HOST_TAG_NAME | El nombre de la etiqueta anfitriona, como app-sonda | @angular/core |
new HostAttributeToken('tipo') | El valor estático de un atributo del anfitrión | @angular/core |
10. Proveedores: useClass, useValue, useFactory, useExisting y multi
Un proveedor responde a la pregunta «¿qué entrego cuando me pidan este token?». Hay cuatro respuestas posibles.
Figura 8. Las cuatro recetas de un proveedor.
Todos los fragmentos de este apartado se han ejecutado como pruebas con Vitest.
useClass
Crea un objeto de la clase indicada, resolviendo sus dependencias:
providers: [{ provide: RepositorioDepartamentos, useClass: RepositorioMemoria }];
providers: [Cesta], que has usado antes, es la forma abreviada de { provide: Cesta, useClass: Cesta }.
useValue
Entrega un valor ya existente. Angular no lo crea ni lo destruye:
providers: [{ provide: API_URL, useValue: 'http://localhost:3000/api' }];
useFactory
Llama a una función, una sola vez, y entrega el resultado. La fábrica tiene contexto de inyección:
providers: [
{ provide: API_URL, useValue: 'memoria' },
RepositorioApi,
{
provide: RepositorioDepartamentos,
useFactory: () =>
inject(API_URL) === 'memoria' ? new RepositorioMemoria() : inject(RepositorioApi),
},
];
En código antiguo verás las dependencias declaradas en deps. Una dependencia opcional se indica con new Optional():
{
provide: URL_LISTADO,
useFactory: (url: string, config: ConfigEmpresa | null) =>
`${url}/empleados?tamano=${config?.tamanoPagina ?? 10}`,
deps: [API_URL, [new Optional(), CONFIG_EMPRESA]],
}
useExisting
Crea un alias: dos tokens, un solo objeto. La prueba muestra la diferencia con useClass:
// Con useExisting, los dos tokens entregan el mismo objeto
providers: [
RepositorioMemoria,
{ provide: RepositorioDepartamentos, useExisting: RepositorioMemoria },
];
// TestBed.inject(RepositorioDepartamentos) === TestBed.inject(RepositorioMemoria) -> true
// Con useClass, hay dos objetos distintos
providers: [
RepositorioMemoria,
{ provide: RepositorioDepartamentos, useClass: RepositorioMemoria },
];
// TestBed.inject(RepositorioDepartamentos) === TestBed.inject(RepositorioMemoria) -> false
⚠️ Importante: Confundirlos es un error difícil de detectar. Con
useClasstendrías dos objetos con estado independiente, y no se produce ningún error.
multi
Normalmente, si registras dos veces el mismo token gana el último. Con multi: true, los valores se acumulan en un array, lo que permite que varias partes de la aplicación aporten a una misma lista sin conocerse:
import { InjectionToken } from '@angular/core';
export interface EnlaceMenu {
texto: string;
ruta: string;
}
export const ENLACES_MENU = new InjectionToken<EnlaceMenu[]>('ENLACES_MENU');
providers: [
{ provide: ENLACES_MENU, useValue: { texto: 'Empleados', ruta: '/empleados' }, multi: true },
{ provide: ENLACES_MENU, useValue: { texto: 'Pedidos', ruta: '/pedidos' }, multi: true },
{ provide: API_URL, useValue: 'primera' },
{ provide: API_URL, useValue: 'segunda' },
];
// inject(ENLACES_MENU).map((e) => e.texto) -> ['Empleados', 'Pedidos']
// inject(API_URL) -> 'segunda'
Mezclar proveedores con y sin multi para un mismo token produce el error Cannot mix multi providers and regular providers.
11. Funciones provide...: configurar una funcionalidad entera
Una funcionalidad suele necesitar varios proveedores. En lugar de obligar a copiarlos en cada aplicación, agrúpalos en una función, igual que hacen provideRouter y provideHttpClient:
import { EnvironmentProviders, makeEnvironmentProviders } from '@angular/core';
import { API_URL } from './api-url';
import {
RepositorioApi,
RepositorioDepartamentos,
RepositorioMemoria,
} from './repositorio-departamentos';
export interface OpcionesEmpresa {
apiUrl: string;
datosDePrueba?: boolean;
}
export function provideEmpresa(opciones: OpcionesEmpresa): EnvironmentProviders {
return makeEnvironmentProviders([
{ provide: API_URL, useValue: opciones.apiUrl },
{
provide: RepositorioDepartamentos,
useClass: opciones.datosDePrueba ? RepositorioMemoria : RepositorioApi,
},
]);
}
// app.config.ts
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideEmpresa } from './empresa/provide-empresa';
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideRouter(routes),
provideEmpresa({ apiUrl: 'http://localhost:3000/api' }),
],
};
makeEnvironmentProviders envuelve la lista en un EnvironmentProviders. Ese tipo es una protección: garantiza que nadie usará provideEmpresa en los providers de un componente.
Ejecutar código antes de arrancar
provideAppInitializer registra una función que se ejecuta al arrancar. Si devuelve una promesa, Angular espera antes de crear el componente raíz. Tiene contexto de inyección:
provideAppInitializer(async () => {
console.log('Inicializador: empieza');
await new Promise((resolver) => setTimeout(resolver, 500));
console.log('Inicializador: termina');
}),
Con un console.log en el constructor de App, el orden real fue:
Inicializador: empieza
Inicializador: termina
Componente App creado
Úsalo con moderación: mientras espera, el usuario no ve nada de la aplicación.
12. Ciclo de vida de un servicio
Un servicio se crea la primera vez que alguien lo pide, no al arrancar, y se destruye con el inyector que lo creó. Para limpiar sus recursos tienes ngOnDestroy y DestroyRef:
import { Component, DestroyRef, inject, OnDestroy, Service, signal } from '@angular/core';
@Service({ autoProvided: false })
export class Cronometro implements OnDestroy {
readonly segundos = signal(0);
private readonly intervalo = setInterval(() => this.segundos.update((s) => s + 1), 1000);
constructor() {
console.log('Cronometro creado');
inject(DestroyRef).onDestroy(() => console.log('DestroyRef: se destruye el inyector'));
}
ngOnDestroy(): void {
clearInterval(this.intervalo);
console.log('ngOnDestroy: intervalo limpiado');
}
}
@Component({
selector: 'app-sesion',
providers: [Cronometro], // nace y muere con este componente
template: `<p>Sesión abierta: {{ cronometro.segundos() }} s</p>`,
})
export class Sesion {
protected readonly cronometro = inject(Cronometro);
}
@Component({
selector: 'app-pagina-sesion',
imports: [Sesion],
template: `
<button (click)="abierta.set(!abierta())">{{ abierta() ? 'Cerrar' : 'Abrir' }}</button>
@if (abierta()) {
<app-sesion />
}
`,
})
export class PaginaSesion {
protected readonly abierta = signal(false);
}
Registro real en Chromium al abrir, esperar dos segundos, cerrar y volver a abrir:
Cronometro creado
ngOnDestroy: intervalo limpiado
DestroyRef: se destruye el inyector
Cronometro creado
Al reabrir, la sesión mostró 0 s: es una instancia nueva. Sin clearInterval, cada apertura dejaría un temporizador activo para siempre, una fuga de memoria típica.
Inyectores creados a mano
createEnvironmentInjector crea un ámbito con la vida que tú decidas:
const padre = inject(EnvironmentInjector);
const inyector = createEnvironmentInjector([Cronometro], padre, 'sesion');
const cronometro = inyector.get(Cronometro); // se crea aquí
inyector.destroy(); // llama a ngOnDestroy
Debes llamar a destroy() tú mismo. Usar el inyector después da NG0205: Injector has already been destroyed.
Inyectores de ruta
Una ruta puede declarar providers. Su inyector se crea la primera vez que se visita y, por defecto, no se destruye al salir de ella. Lo comprobamos con un servicio ContextoVentas en una ruta: al entrar, salir y volver, solo se registró una creación y ninguna destrucción.
ContextoVentas creado 82
Liberar los inyectores de ruta 🆕 v22.2
🆕 Desde Angular 22.2. No existe en la LTS (Angular 21). Si usas la LTS: usa
withExperimentalAutoCleanupInjectors(), experimental desde 21.1, que en nuestra prueba se comportó igual.
provideRouter(routes, withAutoCleanupInjectors()),
Con esta opción, el mismo recorrido destruye el servicio al salir y crea otro al volver:
ContextoVentas creado 263
ContextoVentas destruido 263
ContextoVentas creado 733
El módulo 11 explica esta función con más detalle.
13. Caso completo: departamentos de EMPRESA
Vamos a juntar las piezas en una pantalla real contra la API de ejemplo. Para arrancarla, descarga empresa-api.zip, descomprímelo y ejecuta node server.mjs. La API queda en http://localhost:3000/api.
El diseño separa tres responsabilidades:
| Capa | Clase | Responsabilidad |
|---|---|---|
| Datos | RepositorioDepartamentos y sus implementaciones | Cómo se obtienen los datos |
| Estado | EstadoDepartamentos | Qué datos hay y en qué situación está la carga |
| Vista | PaginaDepartamentos | Cómo se muestran |
En este módulo usamos fetch directamente para no adelantar materia. En el módulo 12 lo sustituirás por HttpClient, y solo cambiará RepositorioApi.
// departamento.ts
export interface Departamento {
deptId: number;
nombre: string;
localidad: string;
presupuesto: number | null; // INVESTIGACIÓN no tiene presupuesto asignado
}
El tipo number | null no es capricho. La primera versión de este ejemplo decía presupuesto: number, y la API devuelve null en INVESTIGACIÓN. TypeScript no comprueba lo que llega de la red: la tabla mostró una celda vacía, sin ningún error.
// repositorio-departamentos.ts
import { inject, Service } from '@angular/core';
import { API_URL } from './api-url';
import { Departamento } from './departamento';
// El contrato: se usa a la vez como tipo y como token
export abstract class RepositorioDepartamentos {
abstract listar(): Promise<Departamento[]>;
}
// Implementación real: habla con la API
@Service({ autoProvided: false })
export class RepositorioApi extends RepositorioDepartamentos {
private readonly apiUrl = inject(API_URL);
async listar(): Promise<Departamento[]> {
const respuesta = await fetch(`${this.apiUrl}/departamentos?orden=nombre`);
if (!respuesta.ok) {
throw new Error(`La API respondió ${respuesta.status}`);
}
const pagina: { contenido: Departamento[] } = await respuesta.json();
return pagina.contenido;
}
}
// Implementación para pruebas y prototipos: no necesita servidor
@Service({ autoProvided: false })
export class RepositorioMemoria extends RepositorioDepartamentos {
async listar(): Promise<Departamento[]> {
return [
{ deptId: 1, nombre: 'PRUEBAS', localidad: 'Memoria', presupuesto: 1000 },
{ deptId: 2, nombre: 'DEMOSTRACIONES', localidad: 'Memoria', presupuesto: 2000 },
];
}
}
// estado-departamentos.ts
import { computed, inject, Service, signal } from '@angular/core';
import { Departamento } from './departamento';
import { RepositorioDepartamentos } from './repositorio-departamentos';
@Service()
export class EstadoDepartamentos {
private readonly repositorio = inject(RepositorioDepartamentos);
readonly departamentos = signal<Departamento[]>([]);
readonly cargando = signal(false);
readonly error = signal<string | null>(null);
readonly presupuestoTotal = computed(() =>
this.departamentos().reduce((suma, d) => suma + (d.presupuesto ?? 0), 0),
);
async cargar(): Promise<void> {
this.cargando.set(true);
this.error.set(null);
try {
this.departamentos.set(await this.repositorio.listar());
} catch (e) {
this.error.set(e instanceof Error ? e.message : 'Error desconocido');
} finally {
this.cargando.set(false);
}
}
}
// pagina-departamentos.ts
import { Component, inject } from '@angular/core';
import { EstadoDepartamentos } from './estado-departamentos';
@Component({
selector: 'app-pagina-departamentos',
template: `
<h2>Departamentos</h2>
@if (estado.cargando()) {
<p>Cargando...</p>
} @else if (estado.error(); as error) {
<p class="error">{{ error }}</p>
<button (click)="estado.cargar()">Reintentar</button>
} @else {
<table>
<thead>
<tr><th>Id</th><th>Nombre</th><th>Localidad</th><th>Presupuesto (€)</th></tr>
</thead>
<tbody>
@for (d of estado.departamentos(); track d.deptId) {
<tr>
<td>{{ d.deptId }}</td>
<td>{{ d.nombre }}</td>
<td>{{ d.localidad }}</td>
<td>{{ d.presupuesto ?? 'Sin asignar' }}</td>
</tr>
}
</tbody>
</table>
<p><strong>Presupuesto total: {{ estado.presupuestoTotal() }} €</strong></p>
}
`,
})
export class PaginaDepartamentos {
protected readonly estado = inject(EstadoDepartamentos);
constructor() {
this.estado.cargar();
}
}

Figura 9. Captura real con los datos de la API de ejemplo.
EstadoDepartamentos depende de la clase abstracta, no de RepositorioApi. Para trabajar sin servidor, solo hay que cambiar una línea de app.config.ts:
provideEmpresa({ apiUrl: 'http://localhost:3000/api', datosDePrueba: true }),
Ningún componente ni servicio se entera. Esa es la recompensa de la inyección de dependencias.
💡 Consejo: El estado en un servicio global sobrevive a los cambios de pantalla, pero también significa que puedes mostrar datos antiguos. El módulo 13 presenta
httpResource, y el módulo 19 compara las opciones de gestión del estado.
14. Carga diferida con injectAsync 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: haz
const { Servicio } = await import('./servicio')y despuésinjector.get(Servicio), coninjector = inject(Injector). Para precargar, llama aimport()dentro derequestIdleCallback.
Hay servicios caros que casi nadie usa: un generador de PDF, una exportación a Excel, un visor de gráficos. Con inject() su código entra en la descarga inicial aunque el usuario nunca pulse el botón. injectAsync lo separa en un fichero aparte, un chunk, que se descarga la primera vez que hace falta.
// exportador-csv.ts
import { DOCUMENT, inject, Service } from '@angular/core';
@Service()
export class ExportadorCsv {
private readonly documento = inject(DOCUMENT);
constructor() {
console.log('ExportadorCsv creado');
}
aCsv(filas: Record<string, string | number>[]): string {
const columnas = Object.keys(filas[0] ?? {});
const cuerpo = filas.map((fila) => columnas.map((c) => this.celda(fila[c])).join(';'));
return [columnas.join(';'), ...cuerpo].join('\n');
}
descargar(nombre: string, contenido: string): void {
const url = URL.createObjectURL(new Blob([contenido], { type: 'text/csv' }));
const enlace = this.documento.createElement('a');
enlace.href = url;
enlace.download = nombre;
enlace.click();
URL.revokeObjectURL(url);
}
private celda(valor: string | number): string {
return `"${String(valor).replaceAll('"', '""')}"`;
}
}
// boton-exportar.ts
import { Component, injectAsync, input, signal } from '@angular/core';
@Component({
selector: 'app-boton-exportar',
template: `
<button (click)="exportar()" [disabled]="exportando()">
{{ exportando() ? 'Preparando...' : 'Exportar a CSV' }}
</button>
`,
})
export class BotonExportar {
readonly filas = input.required<Record<string, string | number>[]>();
protected readonly exportando = signal(false);
// Aquí todavía no se descarga nada: solo se guarda cómo cargarlo
private readonly exportador = injectAsync(() =>
import('./exportador-csv').then((m) => m.ExportadorCsv),
);
protected async exportar(): Promise<void> {
this.exportando.set(true);
try {
// La primera llamada descarga el chunk; las siguientes reutilizan la promesa
const exportador = await this.exportador();
exportador.descargar('departamentos.csv', exportador.aCsv(this.filas()));
} finally {
this.exportando.set(false);
}
}
}
injectAsync no devuelve el servicio, sino una función que devuelve una promesa del servicio. La línea import('./exportador-csv') dentro de una función es lo que indica al empaquetador que debe separarlo. Si el servicio es export default, basta con injectAsync(() => import('./exportador-csv')).
ng build muestra el resultado:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-ZHEFZ2CP.js | - | 119.31 kB | 35.89 kB
main-YHDYTEYQ.js | main | 101.97 kB | 25.57 kB
styles-FUUDFDHJ.css | styles | 1.39 kB | 437 bytes
| Initial total | 222.67 kB | 61.90 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-WWEYNK33.js | exportador-csv | 599 bytes | 599 bytes
Este servicio pesa poco porque es un ejemplo; la técnica compensa cuando arrastra una biblioteca grande. Peticiones .js reales en Chromium, con el build de producción:
199 ms chunk-PXWMJOSL.js
199 ms main-656BJMGU.js
--- primer clic ---
2608 ms chunk-F2CCQ767.js
--- segundo clic: ninguna petición ---
La función guarda la promesa, así que el chunk se pide una sola vez. El objeto se resuelve con el inyector normal: es el mismo que daría inject(), puede tener sus propias dependencias y es único en la aplicación.
⚠️ Importante: El servicio debe proveerse automáticamente, con
@Service()o@Injectable({ providedIn: 'root' }). Con@Service({ autoProvided: false })o@Injectable()no hay proveedor, y la promesa se rechaza con NG0201.
⚠️ Importante: Si cualquier otro fichero hace un
importnormal del servicio y usa la clase como valor, la separación se pierde sin avisar: el código vuelve al bundle inicial. En nuestra prueba, el chunk pasó de 599 a 69 bytes yng buildno dio ninguna advertencia. Para usar solo el tipo escribeimport type { ExportadorCsv } from './exportador-csv', que se borra al compilar: el chunk volvió a sus 599 bytes.
Alternativa en la LTS
Este componente compila en Angular 21.2 y crea el chunk exportador-csv:
import { afterNextRender, Component, inject, Injector, input, signal } from '@angular/core';
// ...
export class BotonExportar {
readonly filas = input.required<Record<string, string | number>[]>();
protected readonly exportando = signal(false);
private readonly injector = inject(Injector);
constructor() {
// Equivale a prefetch: onIdle
afterNextRender(() => {
const precargar = () => import('./exportador-csv');
if ('requestIdleCallback' in window) {
requestIdleCallback(precargar);
} else {
setTimeout(precargar, 1000);
}
});
}
protected async exportar(): Promise<void> {
this.exportando.set(true);
try {
const { ExportadorCsv } = await import('./exportador-csv');
const exportador = this.injector.get(ExportadorCsv);
exportador.descargar('departamentos.csv', exportador.aCsv(this.filas()));
} finally {
this.exportando.set(false);
}
}
}
Precarga con onIdle 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: llama a
import('./servicio')dentro derequestIdleCallback, desdeafterNextRender, como en el ejemplo anterior.
Esperar al primer clic significa que ese clic tarda. La opción prefetch adelanta la descarga. onIdle espera a que el navegador esté libre, y timeout fija cuánto se puede esperar como máximo:
private readonly exportador = injectAsync(
() => import('./exportador-csv').then((m) => m.ExportadorCsv),
{ prefetch: () => onIdle({ timeout: 2000 }) },
);
Si no necesitas timeout, escribe { prefetch: onIdle }. Resultado real, sin tocar la página:
41 ms chunk-PXWMJOSL.js
41 ms main-656BJMGU.js
202 ms chunk-F2CCQ767.js
El mensaje ExportadorCsv creado no apareció hasta pulsar el botón: la precarga descarga el código, pero no crea el servicio.
prefetch acepta cualquier función que devuelva una promesa. Esta descarga el chunk cuando el puntero entra en el componente:
private readonly host: HTMLElement = inject(ElementRef).nativeElement;
private readonly exportador = injectAsync(
() => import('./exportador-csv').then((m) => m.ExportadorCsv),
{
prefetch: () =>
new Promise((resolver) => {
this.host.addEventListener('pointerenter', () => resolver(), { once: true });
}),
},
);
Figura 10. La precarga adelanta la descarga, no la creación del servicio.
Tanto injectAsync como onIdle deben llamarse en un contexto de inyección. Fuera de él dan NG0203 con su propio nombre en el mensaje.
Qué pasa si falla la descarga
La red falla, y esta parte no la cuenta la documentación. Bloqueamos el chunk en Chromium, pulsamos el botón, restauramos la red y volvimos a pulsar:
Primer clic: ERROR TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:5208/chunk-F2CCQ767.js
Segundo clic: ERROR TypeError: Failed to fetch dynamically imported module: http://127.0.0.1:5208/chunk-F2CCQ767.js
(ninguna petición nueva)
injectAsync guarda la promesa aunque haya fallado, de modo que ese componente no vuelve a intentarlo hasta que se cree de nuevo. Con prefetch es peor: el fallo de la precarga se descarta en silencio, y cuando el usuario pulsa el botón recibe ese mismo error sin que se haya intentado de nuevo. El navegador no es el culpable: un import() repetido sí volvió a pedir el fichero y funcionó. La solución es reintentar dentro de la función de carga, como verás en el ejercicio 6.
Cambiar qué significa «reposo» con provideIdleServiceWith 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: no hay alternativa: Angular 21 no ofrece ninguna API pública para cambiar este comportamiento, así que escribe tu propia lógica de espera antes de llamar a
import().
Por defecto, onIdle usa requestIdleCallback y, si el navegador no lo tiene, setTimeout. Para cambiarlo, implementa la interfaz IdleService:
import { IdleService, Service } from '@angular/core';
@Service()
export class ReposoConRetardo implements IdleService {
requestOnIdle(callback: (deadline?: IdleDeadline) => void): number {
return window.setTimeout(() => callback(), 200);
}
cancelOnIdle(id: number): void {
clearTimeout(id);
}
}
// app.config.ts
provideIdleServiceWith(ReposoConRetardo),
Una prueba con temporizadores simulados confirmó que la carga no ocurría a los 199 ms y sí a los 200 ms. Según su documentación, este servicio también controla el disparador on idle de @defer (módulo 23).
15. Dónde acaba el código de cada servicio
Se dice a menudo que los servicios root son tree-shakable: si nadie los usa, desaparecen de la compilación. Conviene saber con exactitud qué significa. Hicimos este experimento con ng build en producción, buscando en los ficheros generados una cadena marcada en cada servicio:
| Caso | Dónde acabó |
|---|---|
Servicio usado solo por una ruta con loadComponent | En el chunk diferido de esa ruta |
El mismo servicio, añadido además a providers de app.config.ts | En la carga inicial |
| Servicio sin usar, en el mismo fichero que otro usado | En main: no se elimina |
Servicio sin usar, en su fichero, exportado por un index.ts que se importa | En main: no se elimina |
Clase de una biblioteca sin usar: LiveAnnouncer de @angular/cdk/a11y, importando solo FocusMonitor | No aparece |
Los resultados con @Injectable({ providedIn: 'root' }) fueron los mismos, también en Angular 21.2, en los casos que comprobamos: fichero compartido y ruta diferida. Las conclusiones prácticas son estas:
- La gran ventaja de
@Service()frente aapp.config.tses que el código va con quien lo usa: un servicio de una ruta diferida no engorda la carga inicial. - Un servicio por fichero no es solo una cuestión de estilo.
- Evita los ficheros
index.tsque reexportan todo: en tu propio código, arrastran lo que exportan. - Las bibliotecas sí se depuran bien, porque se publican preparadas para ello.
16. Sustituir dependencias en las pruebas
Esta es la mayor ventaja práctica de la inyección. TestBed crea un inyector para cada prueba, y tú decides qué recetas contiene. El módulo 26 trata las pruebas a fondo; aquí basta con la idea.
import { TestBed } from '@angular/core/testing';
import { EstadoDepartamentos } from './estado-departamentos';
import { provideEmpresa } from './provide-empresa';
describe('EstadoDepartamentos', () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideEmpresa({ apiUrl: 'sin-usar', datosDePrueba: true })],
});
});
it('carga los departamentos y calcula el presupuesto total', async () => {
const estado = TestBed.inject(EstadoDepartamentos);
await estado.cargar();
expect(estado.departamentos().length).toBe(2);
expect(estado.presupuestoTotal()).toBe(3000);
expect(estado.error()).toBeNull();
});
});
La prueba no necesita servidor, porque provideEmpresa selecciona RepositorioMemoria. Para simular situaciones difíciles de provocar, como un servidor caído, se sustituye la dependencia por una versión hecha a medida, como verás en el ejercicio 7.
Para comprobar que cada panel tiene su instancia, el injector del debugElement te da acceso al inyector del elemento:
const cestaUno = uno.debugElement.injector.get(Cesta);
const cestaDos = dos.debugElement.injector.get(Cesta);
expect(cestaUno).not.toBe(cestaDos);
expect(cestaUno).not.toBe(TestBed.inject(Cesta));
Si tu código llama a inject() y quieres probarlo directamente, usa TestBed.runInInjectionContext(() => ...).
17. Depurar y errores frecuentes
Estos son los mensajes reales, capturados en Angular 22.2.
| Error | Causa | Solución |
|---|---|---|
NG0201: No provider found for `_Auditoria` | Nadie provee el token | Añádelo a providers o usa @Service() |
NG0203: The `_Reloj` token injection failed | inject() fuera de contexto | Muévelo a un campo o usa runInInjectionContext |
NG0200: Circular dependency detected for `_Pedidos`. ... Path: _Pedidos -> _Clientes -> _Pedidos | Dos servicios se necesitan mutuamente | Extrae lo común a un tercero |
NG0205: Injector has already been destroyed | Uso de un inyector ya destruido | Revisa quién conserva la referencia |
NG2028: @Service class cannot use constructor dependency injection | Parámetros en el constructor de @Service | Usa inject() o @Injectable |
NG2003: No suitable injection token for parameter | Parámetro sin token válido | Usa @Inject(TOKEN) o inject() |
TS2693: 'Config' only refers to a type | Una interfaz usada como token | Usa InjectionToken |
TS2322: Type 'EnvironmentProviders' is not assignable to type 'Provider' | provide... en un componente | Pásalo a app.config.ts o a una ruta |
Cannot mix multi providers and regular providers | Un token registrado con y sin multi | Pon multi: true en todos |
En producción los mensajes se reducen al código: ERROR C: NG0201, donde C es el nombre minimizado de la clase del error. Busca ese código en https://angular.dev/errors.
Dependencias circulares
Pedidos -> necesita Clientes
Clientes -> necesita Pedidos
Lo correcto es repensar el diseño: casi siempre hay una tercera responsabilidad escondida que ambos necesitan. forwardRef no resuelve esta situación: sirve para referirse a una clase que todavía no está definida al evaluar el decorador. Con dos servicios, nuestro inject(forwardRef(() => B)) siguió dando NG0200. Su uso legítimo lo verás en el ejercicio 5.
Inspeccionar los inyectores
- Angular DevTools tiene una pestaña Injector Tree que muestra los árboles de entorno y de elementos. Al seleccionar un inyector, resalta el camino de resolución y lista sus proveedores. El módulo 01 explica cómo instalar la extensión.
- En desarrollo, la consola del navegador ofrece
ng.getInjector(elemento). Por ejemplo,ng.getInjector($0).get(...)trabaja con el elemento seleccionado en el inspector. Las funciones dengque empiezan porɵson internas: no las uses en tu código.
18. Buenas prácticas
| Práctica | Motivo |
|---|---|
Usa inject() en campos | Funciona con @Service y evita los problemas de orden del constructor |
| Guarda en señales lo que se muestra | Con OnPush por defecto, una propiedad normal no actualiza la vista |
Expón el estado con asReadonly() y métodos con nombre | Las reglas del negocio quedan en un único lugar |
| Un servicio por fichero | Las clases sin usar de tu código no se eliminan |
Empieza por @Service() y limita el ámbito si hace falta | El código acaba junto a quien lo usa |
Usa autoProvided: false para servicios con ámbito | Olvidar el proveedor da un error, no una instancia global incorrecta |
| Depende de abstracciones en las fronteras | Clases abstractas para API y almacenamiento facilitan probar y sustituir |
No uses document ni localStorage directamente | Con un token funcionan en SSR y se sustituyen en las pruebas |
| Limpia temporizadores y escuchadores | Con ngOnDestroy o DestroyRef, para evitar fugas |
Agrupa los proveedores en funciones provide... | La configuración de una funcionalidad queda en un sitio |
| No conviertas todo en servicio | Una función pura no necesita inyector |
Reintenta dentro del cargador de injectAsync | Un fallo de red queda guardado en su promesa |
19. Ejercicios
Las soluciones se han compilado y sus pruebas se han ejecutado en Angular 22.2 con ng test --watch=false.
Ejercicio 1: Avisos para toda la aplicación
Crea un servicio Avisos con la lista de mensajes en una señal. mostrar(texto, tipo, duracionMs) añade un aviso que desaparece solo, y cerrar(id) lo quita antes. Dos componentes hermanos, BotonGuardar y BotonBorrar, deben lanzar avisos que aparezcan en un tercero, PanelAvisos.
Ver solución
import { Component, computed, inject, Service, signal } from '@angular/core';
export type TipoAviso = 'exito' | 'error';
export interface Aviso {
id: number;
texto: string;
tipo: TipoAviso;
}
@Service()
export class Avisos {
private readonly _lista = signal<Aviso[]>([]);
private siguienteId = 1;
readonly lista = this._lista.asReadonly();
readonly hayErrores = computed(() => this._lista().some((a) => a.tipo === 'error'));
mostrar(texto: string, tipo: TipoAviso = 'exito', duracionMs = 3000): void {
const id = this.siguienteId++;
this._lista.update((lista) => [...lista, { id, texto, tipo }]);
setTimeout(() => this.cerrar(id), duracionMs);
}
cerrar(id: number): void {
this._lista.update((lista) => lista.filter((a) => a.id !== id));
}
}
@Component({
selector: 'app-panel-avisos',
template: `
@for (aviso of avisos.lista(); track aviso.id) {
<div class="aviso" [class.error]="aviso.tipo === 'error'">
{{ aviso.texto }}
<button (click)="avisos.cerrar(aviso.id)">×</button>
</div>
}
`,
})
export class PanelAvisos {
protected readonly avisos = inject(Avisos);
}

Figura 11. Captura real de la solución.
BotonGuardar y BotonBorrar solo inyectan Avisos y llaman a mostrar. Los botones solo conocen el servicio, no el panel. Puedes colocar PanelAvisos una sola vez en el componente raíz, y cualquier pantalla podrá mostrar avisos.
Ejercicio 2: Borrador por editor
Crea un componente EditorSalario que reciba un empleado y permita modificar su salario sin tocar el original, con un botón Descartar que solo aparezca si hay cambios. El estado debe estar en un servicio BorradorEmpleado, con una instancia por editor. Usar el servicio fuera de un editor debe ser un error.
Ver solución
import { Component, computed, inject, input, OnInit, Service, signal } from '@angular/core';
export interface Empleado {
empId: number;
nombre: string;
salario: number;
}
@Service({ autoProvided: false })
export class BorradorEmpleado {
private readonly original = signal<Empleado | null>(null);
readonly salario = signal(0);
readonly cambiado = computed(() => this.original()?.salario !== this.salario());
iniciar(empleado: Empleado): void {
this.original.set(empleado);
this.salario.set(empleado.salario);
}
descartar(): void {
this.salario.set(this.original()?.salario ?? 0);
}
}
@Component({
selector: 'app-editor-salario',
providers: [BorradorEmpleado],
template: `
<label>
{{ empleado().nombre }}
<input
type="number"
[value]="borrador.salario()"
(input)="borrador.salario.set($any($event.target).valueAsNumber)"
/>
</label>
@if (borrador.cambiado()) {
<button (click)="borrador.descartar()">Descartar</button>
}
`,
})
export class EditorSalario implements OnInit {
readonly empleado = input.required<Empleado>();
protected readonly borrador = inject(BorradorEmpleado);
ngOnInit(): void {
this.borrador.iniciar(this.empleado());
}
}
autoProvided: false hace que TestBed.inject(BorradorEmpleado) falle con NG0201, y providers: [BorradorEmpleado] crea un borrador por editor: modificar el de Ana no afecta al de Luis. El servicio muere con el editor, sin limpieza manual.
Ejercicio 3: Preferencias con almacén sustituible
Crea un servicio Preferencias que guarde el tema (claro u oscuro) en localStorage y lo recupere al iniciarse. No debe usar localStorage directamente: defínelo con un token ALMACEN, para que las pruebas usen un almacén falso.
Ver solución
import { DOCUMENT, effect, inject, InjectionToken, Service, signal } from '@angular/core';
export type Almacen = Pick<Storage, 'getItem' | 'setItem'>;
export const ALMACEN = new InjectionToken<Almacen>('ALMACEN', {
factory: () => inject(DOCUMENT).defaultView!.localStorage,
});
export type Tema = 'claro' | 'oscuro';
@Service()
export class Preferencias {
private readonly almacen = inject(ALMACEN);
readonly tema = signal<Tema>(this.almacen.getItem('tema') === 'oscuro' ? 'oscuro' : 'claro');
constructor() {
effect(() => this.almacen.setItem('tema', this.tema()));
}
alternarTema(): void {
this.tema.update((tema) => (tema === 'claro' ? 'oscuro' : 'claro'));
}
}
Pick<Storage, ...> limita el token a lo que realmente se usa, de modo que el doble de prueba solo implementa dos métodos. El efecto se crea en el constructor, donde hay contexto de inyección, y en la prueba TestBed.tick() lo ejecuta.
Ejercicio 4: Registro con varios destinos
Crea un servicio Registro que envíe cada mensaje a todos los destinos configurados, usando un token multi. Crea DestinoConsola y DestinoMemoria, y una función provideRegistro(...destinos). Las pruebas deben poder leer los mensajes de DestinoMemoria. Sin destinos, el registro no debe fallar.
Ver solución
import {
EnvironmentProviders,
inject,
InjectionToken,
makeEnvironmentProviders,
Service,
signal,
Type,
} from '@angular/core';
export interface DestinoRegistro {
escribir(mensaje: string): void;
}
export const DESTINOS_REGISTRO = new InjectionToken<DestinoRegistro[]>('DESTINOS_REGISTRO');
@Service({ autoProvided: false })
export class DestinoConsola implements DestinoRegistro {
escribir(mensaje: string): void {
console.log(mensaje);
}
}
@Service({ autoProvided: false })
export class DestinoMemoria implements DestinoRegistro {
readonly mensajes = signal<string[]>([]);
escribir(mensaje: string): void {
this.mensajes.update((mensajes) => [...mensajes, mensaje]);
}
}
export function provideRegistro(...destinos: Type<DestinoRegistro>[]): EnvironmentProviders {
return makeEnvironmentProviders(
destinos.flatMap((destino) => [
destino,
{ provide: DESTINOS_REGISTRO, useExisting: destino, multi: true },
]),
);
}
@Service()
export class Registro {
private readonly destinos = inject(DESTINOS_REGISTRO, { optional: true }) ?? [];
info(mensaje: string): void {
for (const destino of this.destinos) {
destino.escribir(`[INFO] ${mensaje}`);
}
}
}
La clave está en flatMap: cada destino se registra como clase y además como alias en la lista multi. Con useClass habría dos objetos y la prueba leería un DestinoMemoria vacío. Observa que el ?? [] cubre el caso sin destinos.
Ejercicio 5: Acordeón coordinado por inyección
Crea Acordeon y PanelAcordeon, de modo que solo pueda haber un panel abierto. Los paneles deben proyectarse dentro del acordeón y encontrarlo por inyección, mediante un token. Un panel fuera de un acordeón debe producir un error.
Ver solución
import { Component, forwardRef, inject, InjectionToken, input, signal } from '@angular/core';
export const GRUPO_ACORDEON = new InjectionToken<Acordeon>('GRUPO_ACORDEON');
@Component({
selector: 'app-acordeon',
providers: [{ provide: GRUPO_ACORDEON, useExisting: forwardRef(() => Acordeon) }],
template: `<ng-content />`,
})
export class Acordeon {
readonly abierto = signal<string | null>(null);
alternar(titulo: string): void {
this.abierto.update((actual) => (actual === titulo ? null : titulo));
}
}
@Component({
selector: 'app-panel-acordeon',
template: `
<button (click)="grupo.alternar(titulo())">{{ titulo() }}</button>
@if (grupo.abierto() === titulo()) {
<div class="contenido"><ng-content /></div>
}
`,
})
export class PanelAcordeon {
readonly titulo = input.required<string>();
protected readonly grupo = inject(GRUPO_ACORDEON);
}
@Component({
selector: 'app-pagina-acordeon',
imports: [Acordeon, PanelAcordeon],
template: `
<app-acordeon>
<app-panel-acordeon titulo="VENTAS">Madrid: 350000 €</app-panel-acordeon>
<app-panel-acordeon titulo="INFORMÁTICA">Valencia: 420000 €</app-panel-acordeon>
<app-panel-acordeon titulo="MARKETING">Barcelona: 200000 €</app-panel-acordeon>
</app-acordeon>
`,
})
export class PaginaAcordeon {}

Figura 12. Captura real de la solución.
Aquí conviven varias ideas del módulo:
- El padre debe usar
providers: conviewProviders, los paneles proyectados no lo verían. useExistinghace que el token entregue el propio componente, no una copia.forwardRefes necesario porque el decorador se evalúa antes de que exista la claseAcordeon.- El token evita que el panel dependa de la clase concreta, y permite separarlos en ficheros sin una importación circular.
Ejercicio 6: Carga diferida con reintentos 🆕 v22.0
🆕 Desde Angular 22.0. No existe en la LTS (Angular 21). Si usas la LTS: aplica
conReintentosdirectamente aimport():await conReintentos(() => import('./exportador-csv'))()y luegoinjector.get(...).
Como injectAsync guarda la promesa fallida, crea una función conReintentos(cargar, intentos, esperaMs) que envuelva al cargador y repita el intento, esperando cada vez más, antes de darse por vencida.
Ver solución
export function conReintentos<T>(
cargar: () => Promise<T>,
intentos = 3,
esperaMs = 300,
): () => Promise<T> {
return async () => {
for (let intento = 1; ; intento++) {
try {
return await cargar();
} catch (error) {
if (intento === intentos) {
throw error;
}
await new Promise((resolver) => setTimeout(resolver, esperaMs * intento));
}
}
};
}
Uso:
private readonly exportador = injectAsync(
conReintentos(() => import('./exportador-csv').then((m) => m.ExportadorCsv)),
);
También lo comprobamos en Chromium con un chunk real, bloqueando sus primeras peticiones:
2 fallos: 3 peticiones, exportación correcta
Red siempre caída: 3 peticiones y "fallo definitivo: Failed to fetch dynamically imported module"
injectAsync solo recibe una promesa cuando conReintentos ha terminado, por lo que los fallos intermedios no quedan guardados. El fallo definitivo sí queda guardado: para un nuevo intento, hay que crear otra vez el componente.
Ejercicio 7: Probar el caso de error
Escribe pruebas para EstadoDepartamentos cuando falla el repositorio: debe guardar el mensaje, dejar de cargar y permitir reintentar. No uses RepositorioMemoria ni el servidor.
Ver solución
import { TestBed } from '@angular/core/testing';
import { EstadoDepartamentos } from './estado-departamentos';
import { RepositorioDepartamentos } from './repositorio-departamentos';
class RepositorioCaido extends RepositorioDepartamentos {
llamadas = 0;
async listar(): Promise<never> {
this.llamadas++;
throw new Error('Servidor caído');
}
}
describe('EstadoDepartamentos cuando falla el repositorio', () => {
let repositorio: RepositorioCaido;
let estado: EstadoDepartamentos;
beforeEach(() => {
repositorio = new RepositorioCaido();
TestBed.configureTestingModule({
providers: [{ provide: RepositorioDepartamentos, useValue: repositorio }],
});
estado = TestBed.inject(EstadoDepartamentos);
});
it('guarda el mensaje de error y deja de cargar', async () => {
await estado.cargar();
expect(estado.error()).toBe('Servidor caído');
expect(estado.cargando()).toBe(false);
expect(estado.departamentos()).toEqual([]);
});
it('un reintento vuelve a llamar al repositorio', async () => {
await estado.cargar();
await estado.cargar();
expect(repositorio.llamadas).toBe(2);
});
});
useValue entrega el objeto creado por la prueba, y por eso esta puede consultar repositorio.llamadas. Con useClass, el objeto lo crearía Angular y necesitarías pedírselo al inyector.
Ejercicio 8 (propuesto): Reloj inyectable
Crea un token RELOJ de tipo () => Date, con una fábrica que use new Date(). Haz un servicio Saludo que devuelva «Buenos días» o «Buenas tardes» según la hora, y pruébalo con horas fijas mediante useValue.
Ejercicio 9 (propuesto): injectTeclaEscape
Escribe una función injectTeclaEscape(accion) que llame a accion al pulsar Escape, valide el contexto con assertInInjectionContext y elimine el escuchador con DestroyRef. Compruébalo en un componente que se muestra con @if.
Ejercicio 10 (propuesto): provideEmpresa ampliado
Amplía provideEmpresa con un repositorio de empleados, una opción tamanoPagina y un provideAppInitializer que cargue /api/estadisticas antes de arrancar. Si la API no responde, la aplicación debe arrancar igualmente y mostrar un aviso.
Ejercicio 11 (propuesto): Medir la división en chunks
Añade a una aplicación una ruta diferida con un servicio propio. Compila con ng build y localiza el servicio en el resultado. Después, añádelo a app.config.ts y mide cuánto crece la carga inicial. Repite el experimento con un index.ts que reexporte varios servicios.
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Inyección de dependencias | Token, proveedor, inyector | La clase pide lo que usa; otro lo crea |
| Obtener una dependencia | inject(Token) | Solo en contexto de inyección |
| Servicio moderno | @Service() 🆕 v22.0 | Global y sin constructor con parámetros |
| Servicio con ámbito | @Service({ autoProvided: false }) | Debe declararse en providers |
| Servicio con fábrica | @Service({ factory }) | La fábrica se ignora desde providers |
| Servicio clásico | @Injectable({ providedIn: 'root' }) | Admite constructor y useFactory |
| Contexto | runInInjectionContext, assertInInjectionContext | NG0203 fuera de él |
| Jerarquía | Elementos, ruta, raíz, plataforma | Gana el primero que se encuentra |
| Por componente | providers: [Servicio] | Una instancia por componente |
| Solo para la vista | viewProviders | Oculto al contenido proyectado |
| Modificadores | optional, self, skipSelf, host | host solo ve viewProviders del anfitrión |
| Tokens | InjectionToken<T> | Para lo que no es una clase |
| Recetas | useClass, useValue, useFactory, useExisting | useExisting es un alias, sin copia |
| Listas | multi: true | Acumula en lugar de sustituir |
| Configuración agrupada | makeEnvironmentProviders | Solo válida en entorno |
| Arranque | provideAppInitializer | Bloquea hasta terminar |
| Limpieza | ngOnDestroy, DestroyRef | Mueren con su inyector |
| Ámbito manual | createEnvironmentInjector | Tú llamas a destroy() |
| Carga diferida | injectAsync 🆕 v22.0 | Devuelve una función; guarda también los fallos |
| Precarga | onIdle 🆕 v22.0 | Descarga, no crea el servicio |
| Reposo propio | provideIdleServiceWith 🆕 v22.0 | Implementa IdleService |