Saltar al contenido

Patrones de inyección de dependencias en aplicaciones TypeScript

Inyección de dependencias en TypeScript: por constructor, funciones fábrica, contenedores y composición de módulos, sin depender de un framework.

4 min de lectura
Un grafo de dependencias que muestra servicios conectados mediante inyección en lugar de importaciones directas

El problema que resuelve la DI

Cuando un módulo importa e instancia sus dependencias directamente, resulta imposible probarlo de forma aislada, intercambiar implementaciones o reutilizarlo en distintos contextos. La inyección de dependencias invierte ese control: quien llama proporciona las dependencias en lugar de que el módulo las cree.

Inyección por constructor

El patrón más simple y explícito. Las dependencias son parámetros, y el sistema de tipos hace cumplir el contrato.

tstypescript
// ❌ Hard-coded dependency — impossible to test without a real database
class OrderService {
  private db = new PostgresDatabase();
 
  async getOrder(id: string): Promise<Order> {
    return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
  }
}
 
// ✅ Constructor injection — dependency provided from outside
interface Database {
  query<T>(sql: string, params: unknown[]): Promise<T>;
}
 
class OrderService {
  constructor(private db: Database) {}
 
  async getOrder(id: string): Promise<Order> {
    return this.db.query("SELECT * FROM orders WHERE id = $1", [id]);
  }
}
 
// Production
const service = new OrderService(new PostgresDatabase(connectionString));
 
// Test — inject a mock
const mockDb: Database = {
  query: async () => ({ id: "1", total: 100, status: "completed" }) as any,
};
const testService = new OrderService(mockDb);

Funciones fábrica en lugar de clases

No todo necesita ser una clase. Las funciones fábrica logran el mismo desacoplamiento con menos ceremonia.

tstypescript
// ❌ Class with constructor injection for simple logic
class PriceCalculator {
  constructor(
    private taxService: TaxService,
    private discountService: DiscountService
  ) {}
 
  async calculate(items: CartItem[]): Promise<number> {
    const subtotal = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
    const discount = await this.discountService.getDiscount(items);
    const tax = await this.taxService.getTax(subtotal - discount);
    return subtotal - discount + tax;
  }
}
 
// ✅ Factory function — same testability, less boilerplate
interface PricingDeps {
  getTax: (amount: number) => Promise<number>;
  getDiscount: (items: CartItem[]) => Promise<number>;
}
 
function createPriceCalculator(deps: PricingDeps) {
  return {
    async calculate(items: CartItem[]): Promise<number> {
      const subtotal = items.reduce(
        (sum, i) => sum + i.price * i.quantity, 0
      );
      const discount = await deps.getDiscount(items);
      const tax = await deps.getTax(subtotal - discount);
      return subtotal - discount + tax;
    },
  };
}
 
// Usage
const calculator = createPriceCalculator({
  getTax: taxService.getTax.bind(taxService),
  getDiscount: discountService.getDiscount.bind(discountService),
});

Raíz de composición

Conecta todas las dependencias en un único lugar al iniciar la aplicación. Este es el único sitio que conoce las implementaciones concretas.

tstypescript
// composition-root.ts — the only file that imports concrete implementations
import { PostgresDatabase } from "./infra/postgres";
import { StripePaymentGateway } from "./infra/stripe";
import { SendGridEmailService } from "./infra/sendgrid";
import { OrderService } from "./services/order-service";
import { PaymentService } from "./services/payment-service";
import { NotificationService } from "./services/notification-service";
 
export function createAppContext(config: AppConfig) {
  // Infrastructure
  const db = new PostgresDatabase(config.databaseUrl);
  const payments = new StripePaymentGateway(config.stripeKey);
  const email = new SendGridEmailService(config.sendgridKey);
 
  // Services — wired with their dependencies
  const orderService = new OrderService(db);
  const paymentService = new PaymentService(payments, db);
  const notificationService = new NotificationService(email);
 
  return {
    orderService,
    paymentService,
    notificationService,
    // Expose shutdown for graceful cleanup
    async shutdown() {
      await db.close();
    },
  };
}
 
// server.ts
const app = createAppContext(loadConfig());
 
// Route handlers receive services from the composition root
router.post("/orders", async (req, res) => {
  const order = await app.orderService.create(req.body);
  await app.notificationService.sendOrderConfirmation(order);
  res.json(order);
});

Contenedor de DI ligero

Cuando el grafo de dependencias se vuelve complejo, un contenedor simple evita el cableado manual sin necesidad de un framework pesado.

tstypescript
type Factory<T> = (container: Container) => T;
 
class Container {
  private factories = new Map<string, Factory<unknown>>();
  private singletons = new Map<string, unknown>();
 
  register<T>(name: string, factory: Factory<T>): void {
    this.factories.set(name, factory);
  }
 
  singleton<T>(name: string, factory: Factory<T>): void {
    this.factories.set(name, (c) => {
      if (!this.singletons.has(name)) {
        this.singletons.set(name, factory(c));
      }
      return this.singletons.get(name)!;
    });
  }
 
  resolve<T>(name: string): T {
    const factory = this.factories.get(name);
    if (!factory) throw new Error(`No registration for "${name}"`);
    return factory(this) as T;
  }
}
 
// Registration
const container = new Container();
 
container.singleton("database", () =>
  new PostgresDatabase(process.env.DATABASE_URL!)
);
 
container.singleton("orderService", (c) =>
  new OrderService(c.resolve("database"))
);
 
container.singleton("paymentService", (c) =>
  new PaymentService(
    c.resolve("paymentGateway"),
    c.resolve("database")
  )
);
 
// Resolution
const orderService = container.resolve<OrderService>("orderService");

Pruebas con inyección de dependencias

La DI hace que las pruebas sean sencillas. Reemplaza las dependencias reales por dobles de prueba que devuelven resultados predecibles.

tstypescript
import { describe, it, expect } from "vitest";
 
describe("OrderService", () => {
  function createTestOrderService(
    overrides: Partial<OrderServiceDeps> = {}
  ) {
    const defaults: OrderServiceDeps = {
      db: {
        query: async () => [],
        execute: async () => ({ rowCount: 1 }),
      },
      eventBus: {
        publish: async () => {},
      },
      idGenerator: () => "test-id-123",
    };
 
    return new OrderService({ ...defaults, ...overrides });
  }
 
  it("creates an order and publishes event", async () => {
    const publishedEvents: unknown[] = [];
 
    const service = createTestOrderService({
      eventBus: {
        publish: async (event) => {
          publishedEvents.push(event);
        },
      },
    });
 
    const order = await service.create({
      userId: "user-1",
      items: [{ productId: "p1", quantity: 2, price: 10 }],
    });
 
    expect(order.id).toBe("test-id-123");
    expect(publishedEvents).toHaveLength(1);
    expect(publishedEvents[0]).toMatchObject({
      type: "order.created",
      orderId: "test-id-123",
    });
  });
 
  it("throws when no items provided", async () => {
    const service = createTestOrderService();
 
    await expect(
      service.create({ userId: "user-1", items: [] })
    ).rejects.toThrow("Order must have at least one item");
  });
});

Cuándo no usar DI

No todas las funciones necesitan dependencias inyectables. Las funciones puras, utilidades y módulos hoja sin efectos secundarios funcionan bien como importaciones directas. La DI aporta valor en los límites del sistema—acceso a bases de datos, APIs externas, colas de mensajes—donde necesitas testabilidad y capacidad de intercambio.

tstypescript
// No DI needed — pure function with no side effects
function calculateTotal(items: CartItem[]): number {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
 
// DI valuable — external dependency with side effects
function createPaymentProcessor(deps: { gateway: PaymentGateway }) {
  return {
    async charge(amount: number, token: string): Promise<PaymentResult> {
      return deps.gateway.charge(amount, token);
    },
  };
}

Conclusiones clave

La inyección de dependencias consiste en hacer que las dependencias sean explícitas y se proporcionen desde fuera en lugar de crearse internamente. La inyección por constructor es suficiente para la mayoría de los casos—empieza por ahí antes de recurrir a contenedores o decoradores. Las funciones fábrica ofrecen el mismo desacoplamiento que las clases con menos código repetitivo.

Conecta todas las implementaciones concretas en una única raíz de composición para que el resto de tu base de código dependa solo de interfaces. Reserva la DI para los límites con efectos secundarios: bases de datos, APIs, colas de mensajes y sistemas de archivos. Las funciones puras y las utilidades deterministas no necesitan inyección—añadirla ahí es sobreingeniería. La prueba para saber si la DI está justificada es simple: si no puedes escribir una prueba rápida y aislada para un módulo, sus dependencias deben ser inyectables.

Wilfredo Rujel

Wilfredo Rujel

Ingeniero de Software Full Stack

Compartir esta publicaciónX