Siddhant Deval
Siddhant Deval
backend5 min read

Dependency Injection & IoC Containers: Wiring Clean Architecture in Node.js

Manually wiring all dependencies in a Clean Architecture Node.js application produces fragile, verbose composition roots. Dependency Injection containers (InversifyJS or TSyringe) automate the wiring while preserving interface-based decoupling — binding concrete adapters to port interfaces without touching any domain or application source file.

Dependency Injection & IoC Containers: Wiring Clean Architecture in Node.js

Domain objects are autonomous state machines with enforced invariants — they are not passive bags of data passed between controller functions. But by Part 7, the main.ts composition root that wires together PrismaOrderRepository, StripePaymentProcessor, PlaceOrderUseCase, OrderController, and every event handler is a fragile cascade of new X(new Y(new Z())). Adding one new dependency to a use case requires tracing the entire construction chain and updating the wiring manually. An IoC container automates this wiring with a binding registry — and makes swapping implementations (test doubles for production adapters) a single-line change.

This article implements the full DI container for the billing engine using InversifyJS: binding interfaces to implementations, managing singleton vs. transient scopes, conditional bindings for test vs. production environments, and replacing main.ts manual wiring with a declarative container module.

Architectural Note

Prerequisites: Part 7 (Ports & Adapters) — the DI container is the mechanism that connects Driving Adapters to Use Cases and Use Cases to Driven Adapters; it is the runtime realization of the hexagonal dependency graph. Part 9 assumes all Ports and Adapters from Parts 4–8 are defined.


1. The Manual Wiring Problem

This is main.ts from Part 7, before an IoC container:

TYPESCRIPT
// ❌ The manual wiring problem — grows quadratically as the system scales

// Infrastructure
const prisma = new PrismaClient();
const stripeClient = new Stripe(process.env.STRIPE_KEY!);
const kafka = new Kafka({ clientId: 'billing-engine', brokers: ['localhost:9092'] });
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

// Repositories — order matters and is non-obvious
const rawOrderRepo = new PrismaOrderRepository(prisma);
const cachedOrderRepo = new CachingOrderRepository(rawOrderRepo, redisClient);
const customerRepo = new PrismaCustomerRepository(prisma);
const inventoryRepo = new PrismaInventoryRepository(prisma);

// Infrastructure adapters
const paymentProcessor = new StripePaymentProcessor(stripeClient);
const emailPort = new SMTPEmailAdapter(process.env.SMTP_HOST!, process.env.SMTP_PORT!);

// Event bus
const eventBus = new InMemoryEventBus();

// Event handler registration — order matters, easy to forget a handler
eventBus.subscribe('order.placed', new InventoryReservationHandler(inventoryRepo));
eventBus.subscribe('order.placed', new OrderConfirmationEmailHandler(customerRepo, emailPort));
eventBus.subscribe('order.cancelled', new InventoryCancellationHandler(inventoryRepo));

// Use cases — depends on all of the above
const placeOrderUseCase = new PlaceOrderUseCase(cachedOrderRepo, customerRepo, paymentProcessor, eventBus);
const cancelOrderUseCase = new CancelOrderUseCase(cachedOrderRepo, eventBus);
const getOrderSummaryUseCase = new GetOrderSummaryUseCase(cachedOrderRepo, customerRepo);

// Controllers — depends on use cases
const orderController = new OrderController(placeOrderUseCase, cancelOrderUseCase, getOrderSummaryUseCase);

// Express
const app = express();
app.post('/orders', (req, res) => orderController.create(req, res));
// ...

Three problems that compound as the system grows:

  1. Fragile ordering: cachedOrderRepo must be constructed after rawOrderRepo and redisClient. If the order is wrong, a runtime undefined error occurs. TypeScript cannot detect this.
  2. Scope management is manual: PrismaClient is a singleton (one per process), but every new PrismaClient() creates a separate connection pool. If any adapter accidentally creates its own PrismaClient, the connection count doubles silently.
  3. Swapping for tests requires a different main.ts: running integration tests with InMemoryOrderRepository requires either duplicating this entire wiring block or introducing runtime flags (if (process.env.NODE_ENV === 'test') { ... } scattered throughout).

An IoC container solves all three by making bindings declarative and scope-managed.


2. InversifyJS: Symbols, Bindings, and Containers

2.1 Symbols as Type Tokens

InversifyJS uses Symbol identifiers to map interfaces (which are erased at runtime by TypeScript) to their implementations. We define all tokens in one place:

TYPESCRIPT
// src/infrastructure/container/TYPES.ts
export const TYPES = {
  // Infrastructure
  PrismaClient:             Symbol.for('PrismaClient'),
  RedisClient:              Symbol.for('RedisClient'),
  StripeClient:             Symbol.for('StripeClient'),

  // Repositories (Driven Ports)
  IOrderRepository:         Symbol.for('IOrderRepository'),
  ICustomerRepository:      Symbol.for('ICustomerRepository'),
  IInventoryRepository:     Symbol.for('IInventoryRepository'),

  // Infrastructure Adapters (Driven Ports)
  IPaymentProcessor:        Symbol.for('IPaymentProcessor'),
  IEmailPort:               Symbol.for('IEmailPort'),
  IEventBus:                Symbol.for('IEventBus'),

  // Application Use Cases
  PlaceOrderUseCase:        Symbol.for('PlaceOrderUseCase'),
  CancelOrderUseCase:       Symbol.for('CancelOrderUseCase'),
  GetOrderSummaryUseCase:   Symbol.for('GetOrderSummaryUseCase'),

  // Interface Controllers
  OrderController:          Symbol.for('OrderController'),
} as const;

2.2 Decorating Classes for Injection

InversifyJS requires @injectable() on every class managed by the container, and @inject(TYPES.X) on every constructor parameter:

TYPESCRIPT
// src/infrastructure/persistence/PrismaOrderRepository.ts
import { injectable, inject } from 'inversify';
import { TYPES } from '../container/TYPES';

@injectable()
export class PrismaOrderRepository implements IOrderRepository {
  constructor(
    @inject(TYPES.PrismaClient) private readonly prisma: PrismaClient
  ) {}

  async findById(id: OrderId): Promise<Order | null> {
    const raw = await this.prisma.order.findUnique({
      where: { id },
      include: { items: true },
    });
    return raw ? this.toDomain(raw) : null;
  }

  async save(order: Order): Promise<void> {
    await this.prisma.$transaction([
      this.prisma.order.upsert({
        where: { id: order.id },
        create: { id: order.id, customerId: order.customerId, status: order.status },
        update: { status: order.status },
      }),
      this.prisma.orderItem.deleteMany({ where: { orderId: order.id } }),
      this.prisma.orderItem.createMany({
        data: order.items.map(item => ({
          id: item.id,
          orderId: order.id,
          productId: item.productId,
          quantity: item.quantity,
          unitPriceCents: item.unitPrice.amountCents,
          currency: item.unitPrice.currency,
        })),
      }),
    ]);
  }

  nextId(): OrderId { return generateOrderId(); }

  private toDomain(raw: PrismaOrderWithItems): Order { /* ... see Part 13 */ }
}
TYPESCRIPT
// src/application/order/PlaceOrderUseCase.ts
import { injectable, inject } from 'inversify';
import { TYPES } from '../../infrastructure/container/TYPES';

@injectable()
export class PlaceOrderUseCase {
  constructor(
    @inject(TYPES.IOrderRepository)   private readonly orders: IOrderRepository,
    @inject(TYPES.ICustomerRepository) private readonly customers: ICustomerRepository,
    @inject(TYPES.IPaymentProcessor)   private readonly payment: IPaymentProcessor,
    @inject(TYPES.IEventBus)           private readonly eventBus: IEventBus,
  ) {}

  async execute(command: PlaceOrderCommand): Promise<OrderId> { /* ... same as Part 6 */ }
}

2.3 The Production Container Module

TYPESCRIPT
// src/infrastructure/container/productionContainer.ts
import { Container } from 'inversify';
import { PrismaClient } from '@prisma/client';
import Stripe from 'stripe';
import { createClient } from 'redis';
import { TYPES } from './TYPES';

export async function buildProductionContainer(): Promise<Container> {
  const container = new Container({ defaultScope: 'Singleton' });

  // ── Singletons: one instance per process ──

  // Infrastructure clients
  container.bind<PrismaClient>(TYPES.PrismaClient)
    .toConstantValue(new PrismaClient());

  const redis = createClient({ url: process.env.REDIS_URL });
  await redis.connect();
  container.bind(TYPES.RedisClient).toConstantValue(redis);

  container.bind(TYPES.StripeClient)
    .toConstantValue(new Stripe(process.env.STRIPE_KEY!));

  // Repositories — Singleton: one Prisma repo per process
  container.bind<IOrderRepository>(TYPES.IOrderRepository)
    .toDynamicValue((ctx) => {
      const inner = new PrismaOrderRepository(ctx.container.get(TYPES.PrismaClient));
      const redis = ctx.container.get<ReturnType<typeof createClient>>(TYPES.RedisClient);
      return new CachingOrderRepository(inner, redis); // Decorator applied here
    })
    .inSingletonScope();

  container.bind<ICustomerRepository>(TYPES.ICustomerRepository)
    .to(PrismaCustomerRepository)
    .inSingletonScope();

  container.bind<IInventoryRepository>(TYPES.IInventoryRepository)
    .to(PrismaInventoryRepository)
    .inSingletonScope();

  // Infrastructure adapters
  container.bind<IPaymentProcessor>(TYPES.IPaymentProcessor)
    .to(StripePaymentProcessor)
    .inSingletonScope();

  container.bind<IEmailPort>(TYPES.IEmailPort)
    .to(SMTPEmailAdapter)
    .inSingletonScope();

  // Event bus — register handlers after binding dependencies
  const eventBus = new InMemoryEventBus();
  const inventory = container.get<IInventoryRepository>(TYPES.IInventoryRepository);
  const customers = container.get<ICustomerRepository>(TYPES.ICustomerRepository);
  const email = container.get<IEmailPort>(TYPES.IEmailPort);

  eventBus.subscribe('order.placed', new InventoryReservationHandler(inventory));
  eventBus.subscribe('order.placed', new OrderConfirmationEmailHandler(customers, email));
  eventBus.subscribe('order.cancelled', new InventoryCancellationHandler(inventory));

  container.bind<IEventBus>(TYPES.IEventBus).toConstantValue(eventBus);

  // ── Transient: new instance per resolution (Use Cases, Controllers) ──

  container.bind<PlaceOrderUseCase>(TYPES.PlaceOrderUseCase)
    .to(PlaceOrderUseCase)
    .inTransientScope();

  container.bind<CancelOrderUseCase>(TYPES.CancelOrderUseCase)
    .to(CancelOrderUseCase)
    .inTransientScope();

  container.bind<GetOrderSummaryUseCase>(TYPES.GetOrderSummaryUseCase)
    .to(GetOrderSummaryUseCase)
    .inTransientScope();

  container.bind<OrderController>(TYPES.OrderController)
    .to(OrderController)
    .inTransientScope();

  return container;
}

3. Singleton vs. Transient Scope: What Goes Where

Scope Use For Reason
Singleton PrismaClient, RedisClient, repositories, payment adapters, event bus One database connection pool per process; shared state (cache, event subscriptions) must be one instance
Transient Use Cases, Controllers Each HTTP request should get a fresh use case instance — prevents request-state bleed between concurrent requests
Request (Optional) Use Cases with per-request context (e.g., authenticated user) One instance per HTTP request lifecycle — requires middleware to set a request-scoped container
Performance / Safety Warning

The most common IoC scoping bug: binding PrismaClient as inTransientScope(). Every container.get(TYPES.PrismaClient) creates a new PrismaClient with a new connection pool — the default pool is 10 connections. In a service that resolves 50 dependencies per request, this exhausts the Postgres connection limit in seconds. Always bind PrismaClient as toConstantValue() or inSingletonScope().


4. The Test Container: One Binding Change, All Tests Updated

TYPESCRIPT
// src/infrastructure/container/testContainer.ts
import { Container } from 'inversify';
import { TYPES } from './TYPES';

export function buildTestContainer(): Container {
  const container = new Container();

  // ── In-memory doubles replace all infrastructure ──
  const orderRepo = new InMemoryOrderRepository();
  const customerRepo = new InMemoryCustomerRepository();
  const inventoryRepo = new InMemoryInventoryRepository();
  const paymentProcessor = new MockPaymentProcessor();
  const emailPort = new SpyEmailAdapter();
  const eventBus = new SpyEventBus();

  container.bind<IOrderRepository>(TYPES.IOrderRepository).toConstantValue(orderRepo);
  container.bind<ICustomerRepository>(TYPES.ICustomerRepository).toConstantValue(customerRepo);
  container.bind<IInventoryRepository>(TYPES.IInventoryRepository).toConstantValue(inventoryRepo);
  container.bind<IPaymentProcessor>(TYPES.IPaymentProcessor).toConstantValue(paymentProcessor);
  container.bind<IEmailPort>(TYPES.IEmailPort).toConstantValue(emailPort);
  container.bind<IEventBus>(TYPES.IEventBus).toConstantValue(eventBus);

  // ── Use Cases wired identically to production ──
  container.bind<PlaceOrderUseCase>(TYPES.PlaceOrderUseCase).to(PlaceOrderUseCase);
  container.bind<CancelOrderUseCase>(TYPES.CancelOrderUseCase).to(CancelOrderUseCase);
  container.bind<GetOrderSummaryUseCase>(TYPES.GetOrderSummaryUseCase).to(GetOrderSummaryUseCase);
  container.bind<OrderController>(TYPES.OrderController).to(OrderController);

  return container;
}

A test using this container:

TYPESCRIPT
// tests/unit/application/container.PlaceOrder.test.ts
describe('PlaceOrderUseCase (via test container)', () => {
  let container: Container;

  beforeEach(() => {
    container = buildTestContainer();
  });

  it('should place an order end-to-end through the container', async () => {
    // Seed the customer repository
    const customerRepo = container.get<InMemoryCustomerRepository>(TYPES.ICustomerRepository);
    customerRepo.seed(Customer.create('cust_001' as CustomerId, 'Alice', EmailAddress.create('alice@test.com')));

    const useCase = container.get<PlaceOrderUseCase>(TYPES.PlaceOrderUseCase);
    const orderId = await useCase.execute({
      customerId: 'cust_001' as CustomerId,
      items: [{ productId: 'prod_A' as ProductId, quantity: 1, unitPrice: Money.of('USD', 50.00) }],
      shippingAddress: testAddress(),
    });

    expect(orderId).toBeDefined();

    // Assert via the spy event bus
    const eventBus = container.get<SpyEventBus>(TYPES.IEventBus);
    expect(eventBus.wasPublished('order.placed')).toBe(true);
  });
});

Adding a new dependency to PlaceOrderUseCase — say, IFraudScorePort — requires:

  1. Add @inject(TYPES.IFraudScorePort) to the constructor
  2. Add container.bind<IFraudScorePort>(TYPES.IFraudScorePort).to(...) in both production and test containers

Zero other tests need updating. The container resolves all dependencies automatically.


5. Module Pattern: Splitting the Container into Modules

As the billing engine grows, a single productionContainer.ts becomes unwieldy. InversifyJS supports ContainerModule for organizing bindings by domain concern:

TYPESCRIPT
// src/infrastructure/container/modules/persistenceModule.ts
import { ContainerModule, interfaces } from 'inversify';
import { TYPES } from '../TYPES';

export const persistenceModule = new ContainerModule((bind: interfaces.Bind) => {
  bind<PrismaClient>(TYPES.PrismaClient).toConstantValue(new PrismaClient());
  bind<IOrderRepository>(TYPES.IOrderRepository).to(PrismaOrderRepository).inSingletonScope();
  bind<ICustomerRepository>(TYPES.ICustomerRepository).to(PrismaCustomerRepository).inSingletonScope();
});
TYPESCRIPT
// src/infrastructure/container/modules/applicationModule.ts
import { ContainerModule, interfaces } from 'inversify';
import { TYPES } from '../TYPES';

export const applicationModule = new ContainerModule((bind: interfaces.Bind) => {
  bind(TYPES.PlaceOrderUseCase).to(PlaceOrderUseCase).inTransientScope();
  bind(TYPES.CancelOrderUseCase).to(CancelOrderUseCase).inTransientScope();
  bind(TYPES.GetOrderSummaryUseCase).to(GetOrderSummaryUseCase).inTransientScope();
});
TYPESCRIPT
// src/infrastructure/container/productionContainer.ts
import { Container } from 'inversify';
import { persistenceModule } from './modules/persistenceModule';
import { paymentModule } from './modules/paymentModule';
import { messagingModule } from './modules/messagingModule';
import { applicationModule } from './modules/applicationModule';
import { interfaceModule } from './modules/interfaceModule';

export async function buildProductionContainer(): Promise<Container> {
  const container = new Container();
  container.load(persistenceModule);
  container.load(paymentModule);
  container.load(messagingModule);
  container.load(applicationModule);
  container.load(interfaceModule);
  return container;
}

Adding a new bounded context (e.g., Subscription management) means adding subscriptionModule.ts and loading it — zero changes to existing modules.


6. The Clean main.ts After DI

TYPESCRIPT
// src/main.ts — reduced to environment setup and server start
import 'reflect-metadata'; // Required by InversifyJS — must be first import
import express from 'express';
import { buildProductionContainer } from './infrastructure/container/productionContainer';
import { TYPES } from './infrastructure/container/TYPES';

async function bootstrap(): Promise<void> {
  const container = await buildProductionContainer();
  const app = express();
  app.use(express.json());

  const orderController = container.get<OrderController>(TYPES.OrderController);

  app.post('/orders',     authenticate, validateBody(placeOrderSchema), (req, res) => orderController.create(req, res));
  app.delete('/orders/:id', authenticate, (req, res) => orderController.cancel(req, res));
  app.get('/orders/:id',  authenticate, (req, res) => orderController.summary(req, res));

  const port = process.env.PORT ?? 3000;
  app.listen(port, () => console.log(`Billing engine on :${port}`));
}

bootstrap().catch(console.error);

main.ts has no new PrismaClient(), no new Stripe(), no new PlaceOrderUseCase(). It asks the container for an OrderController. The container resolves the entire dependency graph in the correct order, with the correct scopes.


Summary

Concept Domain Rule
IoC Container Manages object construction and scope; replaces manual new X(new Y(new Z())) wiring
TYPES symbols Map TypeScript interfaces (erased at runtime) to runtime binding tokens
@injectable() / @inject() Decorators that make a class participatable in container resolution
Singleton scope One instance per process — use for stateful infrastructure (PrismaClient, event bus)
Transient scope New instance per container.get() — use for Use Cases and Controllers
Test container Same TYPES symbols, different implementations — all in-memory doubles
ContainerModule Groups bindings by domain concern; loaded into the main container in main.ts

What's Next

In Part 10, we tackle the Domain Service and Specification Pattern — building the DiscountEngine and eligibility specifications that enforce complex, composable business rules without placing logic in use cases or making the domain model a God Object. Part 10: Domain Services & Specification Pattern →

Research & Synthesis Note

This article was developed with AI-assisted deep search, specification cross-referencing, and technical research synthesis.

#Dependency Injection#InversifyJS#IoC Container#TypeScript#Node.js#Clean Architecture#OOP
Siddhant Deval

Written by Siddhant Deval

Senior Full-Stack Engineer building high-scale architectures, browser performance engineering systems, and SaaS platforms.