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 @Service y sus opciones factory y autoProvided, 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 providers de viewProviders y usar los modificadores optional, self, skipSelf y host
  • Crear tokens con InjectionToken y proveedores con useClass, useValue, useFactory, useExisting y multi
  • Agrupar la configuración de una funcionalidad en una función provide...
  • Controlar el ciclo de vida de un servicio con ngOnDestroy, DestroyRef y createEnvironmentInjector
  • Cargar servicios bajo demanda con injectAsync y precargarlos con onIdle
  • 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 FichaEmpleado también hace new ApiEmpleados(), hay dos objetos distintos. Si uno guarda datos en memoria, el otro no los ve.
  • Acoplamiento. ListaEmpleados conoce la clase concreta. Cambiarla por otra obliga a tocar todos los sitios que hacen new.
  • Pruebas difíciles. No hay manera de probar ListaEmpleados sin hablar con el servidor real.
  • Conocimiento en cadena. Si ApiEmpleados necesita 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.

Comparación entre clases que crean sus dependencias con new, duplicando instancias, y clases que las piden a un inyector que comparte una sola instancia

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.

El consumidor entrega un token al inyector, que consulta sus proveedores y sus instancias y devuelve el objeto

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:

  1. La segunda petición devuelve el mismo objeto: cada inyector crea una sola instancia por token.
  2. El hijo tiene su propio Registro, que tapa al del padre. Es la base de la jerarquía de inyectores (apartado 6).
  3. Lo que el hijo no conoce se lo pide al padre.
  4. 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 _lineas es privada. Nadie de fuera puede hacer cesta.lineas.set([]).
  • Hacia fuera se expone una versión de solo lectura con asReadonly() y valores derivados con computed (módulo 05).
  • Cambiar el estado solo es posible mediante métodos con nombre, como anadir y vaciar. 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.

Catálogo con tres productos junto a un resumen de cesta con tres unidades y un total de 1058.8 euros

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 service crea 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 muestra Valor: 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: factory solo actúa cuando el servicio se resuelve de forma automática en la raíz. Si además añades esa clase a providers: [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ía tipo = 'clase': desde providers se obtuvo tipo: '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 @Injectable seguirá 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 con this.injector.get(Servicio). Resérvalo para ejecutar código que llama internamente a inject().


6. Jerarquía de inyectores

Una aplicación Angular no tiene un inyector, sino muchos, organizados en dos árboles.

Árbol de inyectores de entorno con Null, plataforma, raíz, ruta y creado a mano, junto al árbol de inyectores de elemento

Figura 4. Los dos árboles de inyectores.

  • Los inyectores de entorno (EnvironmentInjector) existen con independencia de la vista. Se configuran con @Service(), con app.config.ts y con los providers de una ruta (módulo 10).
  • Los inyectores de elemento forman un árbol paralelo al del DOM. Se configuran con providers y viewProviders en @Component y @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.

Tres casos de resolución: el token se encuentra en un componente padre, en la raíz, o no se encuentra y produce NG0201

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.

Dos paneles de cliente con cestas distintas y un resumen exterior vacío

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, provideHttpClient y las demás funciones que devuelven EnvironmentProviders solo valen en inyectores de entorno. En los providers de 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 {}

Dos recuadros: con providers ambos hijos ven el valor del padre; con viewProviders el contenido proyectado ve el valor de la raíz

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, self y host lanzan NG0201 si no encuentran nada. Según la documentación, self no debe combinarse con skipSelf ni con host, porque se contradicen. Angular no te avisa: con optional: true ambas combinaciones devolvieron null sin 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.

Cuatro recetas para un token: useClass, useValue, useFactory y useExisting, y la opción multi

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 useClass tendrí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();
  }
}

Tabla con los siete departamentos de EMPRESA, ordenados por nombre, y un presupuesto total de 1800000 euros

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és injector.get(Servicio), con injector = inject(Injector). Para precargar, llama a import() dentro de requestIdleCallback.

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 import normal 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 y ng build no dio ninguna advertencia. Para usar solo el tipo escribe import 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 de requestIdleCallback, desde afterNextRender, 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 });
      }),
  },
);

Línea temporal que compara cuándo se descarga el chunk y cuándo se crea el servicio con inject, injectAsync, onIdle y un disparador propio

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 a app.config.ts es 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.ts que 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 de ng que 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);
}

Dos botones sobre dos avisos, uno de éxito y otro de error

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 {}

Acordeón de tres paneles con el de INFORMÁTICA abierto

Figura 12. Captura real de la solución.

Aquí conviven varias ideas del módulo:

  • El padre debe usar providers: con viewProviders, los paneles proyectados no lo verían.
  • useExisting hace que el token entregue el propio componente, no una copia.
  • forwardRef es necesario porque el decorador se evalúa antes de que exista la clase Acordeon.
  • 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 conReintentos directamente a import(): await conReintentos(() => import('./exportador-csv'))() y luego injector.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