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.
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.
This is main.ts from Part 7, before an IoC container:
TYPESCRIPT
// ❌ The manual wiring problem — grows quadratically as the system scales// Infrastructureconst prisma = newPrismaClient();
const stripeClient = newStripe(process.env.STRIPE_KEY!);
const kafka = newKafka({ clientId: 'billing-engine', brokers: ['localhost:9092'] });
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
// Repositories — order matters and is non-obviousconst rawOrderRepo = newPrismaOrderRepository(prisma);
const cachedOrderRepo = newCachingOrderRepository(rawOrderRepo, redisClient);
const customerRepo = newPrismaCustomerRepository(prisma);
const inventoryRepo = newPrismaInventoryRepository(prisma);
// Infrastructure adaptersconst paymentProcessor = newStripePaymentProcessor(stripeClient);
const emailPort = newSMTPEmailAdapter(process.env.SMTP_HOST!, process.env.SMTP_PORT!);
// Event busconst eventBus = newInMemoryEventBus();
// Event handler registration — order matters, easy to forget a handler
eventBus.subscribe('order.placed', newInventoryReservationHandler(inventoryRepo));
eventBus.subscribe('order.placed', newOrderConfirmationEmailHandler(customerRepo, emailPort));
eventBus.subscribe('order.cancelled', newInventoryCancellationHandler(inventoryRepo));
// Use cases — depends on all of the aboveconst placeOrderUseCase = newPlaceOrderUseCase(cachedOrderRepo, customerRepo, paymentProcessor, eventBus);
const cancelOrderUseCase = newCancelOrderUseCase(cachedOrderRepo, eventBus);
const getOrderSummaryUseCase = newGetOrderSummaryUseCase(cachedOrderRepo, customerRepo);
// Controllers — depends on use casesconst orderController = newOrderController(placeOrderUseCase, cancelOrderUseCase, getOrderSummaryUseCase);
// Expressconst app = express();
app.post('/orders', (req, res) => orderController.create(req, res));
// ...
Three problems that compound as the system grows:
Fragile ordering: cachedOrderRepo must be constructed after rawOrderRepo and redisClient. If the order is wrong, a runtime undefined error occurs. TypeScript cannot detect this.
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.
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.
InversifyJS uses Symbol identifiers to map interfaces (which are erased at runtime by TypeScript) to their implementations. We define all tokens in one place:
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().
As the billing engine grows, a single productionContainer.ts becomes unwieldy. InversifyJS supports ContainerModule for organizing bindings by domain concern:
// src/main.ts — reduced to environment setup and server startimport'reflect-metadata'; // Required by InversifyJS — must be first importimport express from'express';
import { buildProductionContainer } from'./infrastructure/container/productionContainer';
import { TYPES } from'./infrastructure/container/TYPES';
asyncfunctionbootstrap(): Promise<void> {
const container = awaitbuildProductionContainer();
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.
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.