📘TypeScript

Inyección de dependencias: qué problema resuelve y cómo aplicarla en TypeScript

Felipe••16 min de lectura
Aprende inyección por constructor en TypeScript, su diferencia con la inversión de dependencias y cómo aplicarla con providers y pruebas en NestJS.

Quieres comprobar que un servicio construye correctamente el aviso de un pedido. Sin embargo, al crear ese servicio también se crea el cliente que envía correos. Una prueba del texto termina necesitando configuración, credenciales y acceso a un servidor externo.

El problema no está en que una clase utilice otra. Está en que la clase que decide qué hacer también decide con qué implementación hacerlo y cómo construirla. La inyección de dependencias permite separar esas decisiones: un objeto recibe sus colaboradores desde fuera.

Vamos a ver el antes y el después en TypeScript, sin frameworks. Después resolveremos el mismo caso con providers de NestJS. Necesitas conocer clases, métodos y promesas; no necesitas haber utilizado un contenedor de dependencias.

Una dependencia es algo que tu código necesita para trabajar

Si NotificarPedido llama a un objeto para enviar un mensaje, depende de ese objeto. Un repositorio de datos, un cliente HTTP, un reloj o una función que genera identificadores también pueden ser dependencias. No tienen por qué ser paquetes instalados con pnpm.

Hay dos preguntas distintas: ¿qué necesita este componente? y ¿quién construye o consigue eso que necesita? La primera describe su trabajo. La segunda organiza la aplicación.

El acoplamiento expresa cuánto conoce un componente de otro y cuánto le afectan sus cambios. Hablamos de acoplamiento fuerte cuando el consumidor queda ligado a detalles concretos: constructor, configuración, API particular o estado compartido. Un acoplamiento más débil limita ese conocimiento a un contrato pequeño y estable.

Débil no significa inexistente. Si acordamos que enviar devuelve una promesa que se rechaza al fallar, ambas partes dependen de ese comportamiento. Una implementación que resuelva la promesa aunque no haya enviado nada rompe el contrato, aunque sus tipos coincidan.

Antes: el servicio construye su propio notificador

Nuestro caso de uso prepara un mensaje para un pedido que ya está listo. Para hacerlo reproducible utilizaremos un notificador de consola: no envía correos ni modifica pedidos. El identificador y el destinatario son ficticios. La validación completa del destinatario corresponde a la frontera de entrada de una aplicación real y queda fuera de este ejemplo.

Prepara una carpeta independiente con Node.js 22 o superior y pnpm:

mkdir inyeccion-dependencias-lab
cd inyeccion-dependencias-lab

pnpm init
pnpm add -D -E typescript@5.9.3 @types/node@22.20.5

mkdir src

Añade "type": "module" a package.json. Crea tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

Crea src/antes.ts:

class NotificadorConsola {
  async enviar(destinatario: string, texto: string): Promise<void> {
    console.info(`[demo] ${destinatario}: ${texto}`);
  }
}

class NotificarPedido {
  private readonly notificador = new NotificadorConsola();

  async ejecutar(pedidoId: string, email: string): Promise<void> {
    if (pedidoId.trim() === '') {
      throw new Error('El pedido necesita un identificador');
    }

    await this.notificador.enviar(email, `Tu pedido ${pedidoId} está listo.`);
  }
}

await new NotificarPedido().ejecutar('P-104', 'cliente@example.com');

export {};

Compila y ejecuta:

pnpm exec tsc
node dist/antes.js

La salida es:

[demo] cliente@example.com: Tu pedido P-104 está listo.

NotificarPedido sabe que existe NotificadorConsola y decide crear uno cada vez que se construye el servicio. Si mañana queremos entregar el mensaje a una cola, debemos editar el servicio. Si queremos observar lo enviado en una prueba, tendremos que interceptar la consola o modificar esa construcción.

Con un cliente SMTP, esos mismos puntos de unión podrían involucrar opciones de conexión y credenciales. No significa que todo constructor abra una conexión: el efecto depende de la implementación. El problema de diseño es que el consumidor controla un detalle que queremos poder sustituir.

Tampoco significa que new sea incorrecto. Crear un Map auxiliar o un objeto de datos dentro de un método suele ser razonable. Nos interesa separar colaboradores con efectos externos, configuración o comportamiento que necesitemos controlar, no convertir cada objeto en un servicio.

Después: recibir el colaborador por constructor

Primero expresamos lo que necesita el caso de uso, sin describir un proveedor de correo concreto. Crea src/dominio.ts:

export interface Notificador {
  enviar(destinatario: string, texto: string): Promise<void>;
}

export class NotificarPedido {
  constructor(private readonly notificador: Notificador) {}

  async ejecutar(pedidoId: string, email: string): Promise<void> {
    if (pedidoId.trim() === '') {
      throw new Error('El pedido necesita un identificador');
    }

    await this.notificador.enviar(email, `Tu pedido ${pedidoId} está listo.`);
  }
}

Notificador es una abstracción: un contrato con una operación. No contiene credenciales, conexiones ni una implementación. NotificarPedido recibe un objeto que cumple ese contrato y lo utiliza.

La sintaxis private readonly notificador en el constructor es una propiedad de parámetro de TypeScript: declara y asigna el campo de la instancia. readonly impide reasignarlo desde código TypeScript; no vuelve inmutable al objeto recibido.

Crea src/consola.ts:

import type { Notificador } from './dominio.js';

export class NotificadorConsola implements Notificador {
  async enviar(destinatario: string, texto: string): Promise<void> {
    console.info(`[demo] ${destinatario}: ${texto}`);
  }
}

implements comprueba que la clase cumple la forma declarada. No es herencia de comportamiento. TypeScript usa compatibilidad estructural: también podemos proporcionar un objeto literal con el método adecuado. Si necesitas repasar esa elección, consulta tipos e interfaces en TypeScript.

Por último, crea src/manual.ts:

import { NotificarPedido } from './dominio.js';
import { NotificadorConsola } from './consola.js';

const notificador = new NotificadorConsola();
const notificarPedido = new NotificarPedido(notificador);

await notificarPedido.ejecutar('P-104', 'cliente@example.com');

Este archivo es el punto de composición: el lugar donde elegimos implementaciones y conectamos objetos. Las dependencias siguen construyéndose; movimos esa responsabilidad fuera del caso de uso. En una aplicación mayor conviene mantener esa composición cerca del arranque, sin repartir decisiones de infraestructura por todos los métodos.

pnpm exec tsc
node dist/manual.js

Obtendrás el mismo mensaje. El cambio está en cómo se monta el servicio, no en el texto que produce. Ya estamos usando inyección de dependencias, sin decoradores, framework ni contenedor. Martin Fowler desarrolla esta separación entre configuración y uso en su artículo sobre inyección de dependencias.

Inversión e inyección de dependencias no son sinónimos

La inyección de dependencias, o DI por Dependency Injection, es una técnica para entregar colaboradores desde fuera. La inversión de dependencias, o DIP por Dependency Inversion Principle, es un principio de diseño: las decisiones de alto nivel no deberían quedar atadas a detalles de bajo nivel; las relaciones se organizan alrededor de abstracciones.

En nuestro caso, decidir el contenido del aviso es la política de alto nivel. Escribirlo en consola o enviarlo mediante un proveedor es un detalle. El contrato Notificador expresa lo que necesita el caso de uso; el adaptador lo implementa. La guía de principios arquitectónicos de Microsoft explica esta distinción entre la dirección de las dependencias del código y el flujo de ejecución.

Podrías inyectar directamente un ClienteSmtpConcreto y seguir utilizando sus métodos específicos. Eso sería DI, pero no bastaría para afirmar que aplicaste DIP. También puedes depender de una abstracción y conectar los objetos manualmente, como acabamos de hacer.

ConceptoPregunta que resuelveEn el ejemplo
Inyección de dependencias¿Cómo recibe el objeto sus colaboradores?Mediante el constructor
Inversión de dependencias¿De qué debe depender la lógica?Del contrato Notificador
Contenedor de DI¿Quién monta y administra los objetos?Más adelante, NestJS

Este diagrama representa dependencias de código y composición, no una secuencia temporal:

flowchart TB
    accTitle: Dependencias separadas de su composición
    accDescr: El arranque crea el servicio y el adaptador. Ambos dependen del contrato de notificación.
    A[Arranque de la aplicación] -->|crea y conecta| S[NotificarPedido]
    A -->|crea| N[Adaptador de consola]
    S -->|necesita| C[Contrato Notificador]
    N -->|implementa| C

La inversión no significa que el notificador pase a llamar al servicio. Durante la ejecución, el servicio sigue llamando a enviar. Lo que cambia es el conocimiento necesario para escribirlo y compilarlo. Este es el matiz que conviene conservar al relacionarlo con los principios SOLID.

Pruebas unitarias sin cambiar la implementación real

Una prueba puede suministrar un colaborador pequeño que registre los mensajes. Así comprobamos el comportamiento del caso de uso sin depender de la consola ni de un servidor de correo.

Crea src/dominio.test.ts:

import assert from 'node:assert/strict';
import test from 'node:test';

import { NotificarPedido, type Notificador } from './dominio.js';

test('envía el mensaje del pedido al destinatario', async () => {
  const enviados: Array<{ destinatario: string; texto: string }> = [];

  const notificador: Notificador = {
    async enviar(destinatario, texto) {
      enviados.push({ destinatario, texto });
    },
  };

  const servicio = new NotificarPedido(notificador);

  await servicio.ejecutar('P-104', 'cliente@example.com');

  assert.deepEqual(enviados, [{
    destinatario: 'cliente@example.com',
    texto: 'Tu pedido P-104 está listo.',
  }]);
});

test('rechaza un pedido sin identificador antes de enviar', async () => {
  let envios = 0;

  const notificador: Notificador = {
    async enviar() {
      envios += 1;
    },
  };

  const servicio = new NotificarPedido(notificador);

  await assert.rejects(
    servicio.ejecutar(' ', 'cliente@example.com'),
    /El pedido necesita un identificador/,
  );

  assert.equal(envios, 0);
});

test('propaga el fallo del notificador', async () => {
  const fallo = new Error('Transporte no disponible');

  const notificador: Notificador = {
    async enviar() {
      throw fallo;
    },
  };

  const servicio = new NotificarPedido(notificador);

  await assert.rejects(
    servicio.ejecutar('P-104', 'cliente@example.com'),
    (error: unknown) => error === fallo,
  );
});

Ejecuta las tres pruebas de la versión sin framework:

pnpm exec tsc
node --test dist/dominio.test.js

La primera comprueba el destinatario y el contenido. La segunda comprueba que una entrada rechazada no produzca un envío. La tercera verifica que el fallo del colaborador llegue al llamador. Esa propagación es una decisión de este ejemplo: DI no define si tu aplicación debe reintentar, encolar o traducir el error.

Estos objetos son dobles de prueba: sustitutos controlados del colaborador real. No necesitamos una biblioteca de mocks para este caso. El ejecutor y las aserciones proceden de Node.js.

Las pruebas no demuestran que un proveedor externo entregue correos. Para un adaptador SMTP o HTTP seguirían haciendo falta pruebas de integración. Separar responsabilidades permite probar cada una con un alcance claro; no elimina la necesidad de comprobar cómo funcionan juntas.

El mismo caso con providers de NestJS

Un contenedor de DI mantiene registros de proveedores y resuelve las dependencias necesarias para construir objetos. En NestJS, un provider puede suministrar una instancia o un valor, y los módulos organizan dónde está disponible. Los detalles están en la documentación oficial de providers.

Vamos a conservar el contrato y el notificador de consola. Añade las dependencias al laboratorio, no a otro proyecto:

pnpm add -E @nestjs/common@12.3.2 @nestjs/core@12.3.2 \
  reflect-metadata@0.2.2 rxjs@7.8.2

Las versiones fijan el entorno del ejemplo. Añade estas opciones dentro de compilerOptions en tsconfig.json, conservando las anteriores:

"experimentalDecorators": true,
"emitDecoratorMetadata": true

Es un fragmento para insertar en el objeto, no un archivo JSON completo. Este laboratorio utiliza el modo de decoradores experimental de TypeScript y emisión de metadatos con reflect-metadata, documentado en TSConfig. Compila con tsc; no sustituyas estos comandos por ejecutar los .ts directamente en Node, porque eliminar tipos no equivale a transformar decoradores.

El contrato de TypeScript y el token de Nest tienen trabajos distintos

Una interfaz existe para el comprobador de tipos, pero se elimina al compilar. Por eso Nest necesita un identificador que sí exista en JavaScript: un token de inyección. Puede ser una clase, una cadena o un Symbol.

Usaremos un Symbol exportado y reutilizaremos exactamente ese valor al registrar e inyectar. Crear dos veces Symbol('NOTIFICADOR') produce dos símbolos distintos: compartir la descripción no comparte la identidad. Nest documenta esta separación entre interfaces y tokens de providers.

Crea src/nest.ts con la versión adaptada del servicio:

import { Inject, Injectable, Module } from '@nestjs/common';

import type { Notificador } from './dominio.js';
import { NotificadorConsola } from './consola.js';

export const NOTIFICADOR = Symbol('NOTIFICADOR');

@Injectable()
export class NotificarPedidoNest {
  constructor(
    @Inject(NOTIFICADOR)
    private readonly notificador: Notificador,
  ) {}

  async ejecutar(pedidoId: string, email: string): Promise<void> {
    if (pedidoId.trim() === '') {
      throw new Error('El pedido necesita un identificador');
    }

    await this.notificador.enviar(email, `Tu pedido ${pedidoId} está listo.`);
  }
}

@Module({
  providers: [
    NotificarPedidoNest,
    {
      provide: NOTIFICADOR,
      useClass: NotificadorConsola,
    },
  ],
  exports: [NotificarPedidoNest],
})
export class PedidosModule {}

La regla del servicio permanece igual; repetimos ese método aquí para comparar las dos versiones completas. En una aplicación elegirías una organización, sin mantener dos copias del mismo caso de uso.

El registro asocia NOTIFICADOR con NotificadorConsola mediante useClass. @Inject(NOTIFICADOR) indica qué registro resolver para ese argumento. @Injectable() aporta metadatos al servicio, pero no lo registra por sí solo: también lo incluimos en providers.

NotificadorConsola no tiene dependencias de constructor, por lo que podemos registrarlo así sin decorarlo. Si posteriormente necesita otras dependencias gestionadas por Nest, habrá que configurar también su inyección. Para este ejemplo no hace falta convertir el adaptador en una jerarquía de clases.

exports permite que otros módulos que importen PedidosModule utilicen NotificarPedidoNest. No vuelve todos sus providers globales. Estas reglas de visibilidad pertenecen al sistema de módulos de NestJS.

Ejecutar el contenedor sin montar un servidor HTTP

Crea src/nest-main.ts:

import 'reflect-metadata';
import { NestFactory } from '@nestjs/core';

import { NotificarPedidoNest, PedidosModule } from './nest.js';

const app = await NestFactory.createApplicationContext(PedidosModule, {
  logger: false,
});

try {
  const servicio = app.get(NotificarPedidoNest);

  await servicio.ejecutar('P-104', 'cliente@example.com');
} finally {
  await app.close();
}
pnpm exec tsc
node dist/nest-main.js

Se imprime el mismo mensaje que antes. createApplicationContext inicia una aplicación standalone de Nest, sin abrir un puerto. Recuperamos el servicio en el punto de arranque y cerramos el contexto al terminar. No estamos ejecutando controladores HTTP, pipes ni guards.

La construcción manual y la del contenedor se pueden comparar así:

DecisiónTypeScript sin frameworkNestJS
Elegir el notificadornew NotificadorConsola()Registro con useClass
Entregarlo al servicioArgumento del constructorResolución de @Inject(NOTIFICADOR)
Describir su formaInterfaz NotificadorLa misma interfaz
Sustituirlo en una pruebaPasar otro objetoSobrescribir el provider

Nest no adivina una implementación a partir del nombre de una interfaz. Resuelve los registros que le proporcionamos. Si falta el provider, el token no coincide o el módulo no lo hace visible, la construcción puede fallar antes de ejecutar el caso de uso.

Sustituir el provider en una prueba

Instala las utilidades de pruebas de la misma versión de Nest:

pnpm add -D -E @nestjs/testing@12.3.2

Crea src/nest.test.ts:

import 'reflect-metadata';
import assert from 'node:assert/strict';
import test from 'node:test';
import { Test } from '@nestjs/testing';

import type { Notificador } from './dominio.js';
import { NOTIFICADOR, NotificarPedidoNest, PedidosModule } from './nest.js';

test('resuelve el servicio con un provider de prueba', async () => {
  const mensajes: string[] = [];

  const notificador: Notificador = {
    async enviar(destinatario, texto) {
      mensajes.push(`${destinatario}: ${texto}`);
    },
  };

  const modulo = await Test.createTestingModule({
    imports: [PedidosModule],
  })
    .overrideProvider(NOTIFICADOR)
    .useValue(notificador)
    .compile();

  try {
    const servicio = modulo.get(NotificarPedidoNest);

    await servicio.ejecutar('P-104', 'cliente@example.com');

    assert.deepEqual(mensajes, [
      'cliente@example.com: Tu pedido P-104 está listo.',
    ]);
  } finally {
    await modulo.close();
  }
});
pnpm exec tsc
node --test dist/dominio.test.js dist/nest.test.js

El conjunto contiene cuatro pruebas. overrideProvider sustituye el registro antes de compilar el módulo de pruebas y useValue entrega el objeto ya creado. La documentación de testing de Nest describe estas sustituciones.

Esta última prueba comprueba también la resolución dentro del contenedor; tiene más alcance que instanciar una clase directamente. Si solo quisieras probar el método del servicio adaptado, podrías instanciar NotificarPedidoNest pasando el doble al constructor, sin iniciar Nest. Reserva el módulo de pruebas para lo que necesite comprobar su integración.

Qué conviene vigilar al incorporar el patrón

Una interfaz no verifica datos externos. El contrato ayuda al compilador, pero no valida una respuesta HTTP, una variable de entorno ni un objeto recibido desde JavaScript sin tipos. Esa comprobación sigue siendo validación en tiempo de ejecución. Tampoco garantiza por sí sola entrega, disponibilidad o ausencia de duplicados.

El ciclo de vida importa. Nest utiliza por defecto un alcance singleton: reutiliza la instancia del provider dentro del contexto correspondiente. No guardes datos mutables de un usuario como estado del servicio compartido. Hay alcances por petición y transitorios, pero añaden decisiones de creación y propagación que deben responder a una necesidad. Consulta injection scopes.

No sustituyas dependencias explícitas por búsquedas ocultas. Llamar a un contenedor global desde cualquier método convierte la dependencia en algo difícil de ver y preparar en pruebas. En el ejemplo, app.get está en el arranque. El servicio recibe el colaborador concreto que necesita mediante el constructor, en vez de recibir todo el contenedor para buscarlo.

No confundas el tipo con la calidad de la abstracción. Una interfaz que copie todos los métodos de un SDK sigue arrastrando sus decisiones. Nuestro contrato es pequeño porque el caso de uso solo necesita enviar un mensaje. Si mañana necesita adjuntos, revisa esa necesidad real antes de ampliar el contrato para todos los consumidores.

Un constructor enorme puede señalar otro problema. Si una clase necesita doce colaboradores, DI hará esa lista visible, pero no resolverá automáticamente una mezcla de responsabilidades. Antes de ocultarla detrás de un objeto genérico de servicios, revisa qué trabajo corresponde a esa clase.

Cuándo aporta valor y cuándo basta algo más pequeño

La inyección suele ser útil cuando hay efectos externos —base de datos, red, archivos—, cuando quieres controlar tiempo o aleatoriedad en pruebas, o cuando existen implementaciones alternativas reales. Un doble de prueba puede justificar esa separación aunque en producción solo uses un proveedor.

Un contenedor aporta más cuando la aplicación ya tiene un conjunto de servicios y reglas de composición que merece administrar de forma uniforme. Si trabajas con NestJS, reutilizar sus providers evita crear otro mecanismo paralelo. Si tienes tres objetos en un script, un archivo de arranque con tres llamadas a new puede ser suficiente.

Tampoco necesitas una interfaz para cada clase. A veces basta inyectar una función; otras veces una clase concreta estable es un contrato suficiente. Una función pura que recibe sus datos y devuelve un resultado no necesita un contenedor para ser comprobable. Añadir tokens, factories y módulos sin una dificultad concreta puede hacer más costoso seguir el código.

En nuestro ejemplo, la mejora verificable es pequeña: podemos cambiar cómo se entrega el aviso sin editar la regla que construye el mensaje, y podemos probar esa regla con colaboradores controlados. Empieza por esa frontera. Añade infraestructura de composición cuando el tamaño de la aplicación la justifique.

Referencias

#TypeScript#inyección de dependencias#inversión de dependencias#NestJS#providers#pruebas unitarias#acoplamiento

Artículos relacionados