Dependency Injection In Node.js: One Container, Three Patterns, No Pain
Most Node.js codebases either hardwire every dependency or import a heavyweight DI framework and regret it. This post shows three practical DI patterns with working code: a manual wiring function, a lightweight factory-based container, and function-scoped injection. Pick the pattern that fits your project size.
You have a UserService that calls UserRepository that needs a DatabaseConnection. Somewhere in your codebase there is a line like this:
const userService = new UserService(new UserRepository(new DatabaseConnection(config)));
And you know this is a DI problem. But the two paths you have seen in practice both hurt:
- The everything-is-tightly-coupled path: every class constructs its own dependencies with
new. Testing requires monkey-patching or mocking global modules. Changing a constructor signature breaks ten files. - The over-engineered DI path: somebody installed
tsyringe,inversify, orawilixand now the codebase has@injectable()decorators everywhere, circular dependencies that fail at runtime, and acontainer.tsfile that nobody understands.
There is a middle ground. Three of them, actually. Pick the one that matches your project size and complexity.
Problem: why manual wiring is not the answer (but neither is inversify)
The default approach in most Node.js codebases is constructor-scraping: classes construct what they need inside the constructor or at the module level.
// src/services/orderService.ts
import { db } from '../db';
import { EmailService } from './emailService';
import { InventoryClient } from '../clients/inventory';
export class OrderService {
async createOrder(userId: string, items: CartItem[]) {
const dbResult = await db.query('INSERT INTO orders ...');
await new EmailService().sendConfirmation(userId, dbResult.id);
await new InventoryClient().reserve(items);
return dbResult;
}
}
This looks innocent. Then you write a test:
import { OrderService } from './orderService';
// How do I prevent OrderService from hitting the real database?
// How do I stop it from sending real emails?
// I cannot. It constructs everything internally.
You end up with jest.mock('../../db') calls at the top of every test file, which is fragile and couples your tests to implementation details.
The opposite extreme:
import { container } from 'tsyringe';
@injectable()
class OrderService {
constructor(
@inject(Database) private db: Database,
@inject(EmailService) private email: EmailService,
@inject(InventoryClient) private inventory: InventoryClient
) {}
}
Now you need decorators, a decorator transform in your build tool, and a runtime that resolves strings to types. The mental overhead is real, and when a dependency resolution fails at runtime, the error message is cryptic.
What follows are three patterns that avoid both extremes.
Pattern 1: The manual wiring function (for smaller projects)
If your project has fewer than 20 services, you do not need a DI container. You need a single file that constructs your dependency graph in the right order.
// src/di.ts
import { Config } from './config';
import { DatabaseConnection } from './db/connection';
import { UserRepository } from './repositories/userRepository';
import { OrderRepository } from './repositories/orderRepository';
import { EmailService } from './services/emailService';
import { InventoryClient } from './clients/inventoryClient';
import { OrderService } from './services/orderService';
import { UserService } from './services/userService';
// One function. Builds the entire graph. No magic.
export function createContainer(config: Config) {
const db = new DatabaseConnection(config.db);
const userRepo = new UserRepository(db);
const orderRepo = new OrderRepository(db);
const emailService = new EmailService(config.smtp);
const inventoryClient = new InventoryClient(config.inventoryApi);
const userService = new UserService(userRepo, emailService);
const orderService = new OrderService(orderRepo, userRepo, emailService, inventoryClient);
return { userService, orderService, db };
}
Your application entry point becomes:
// src/index.ts
import { createContainer } from './di';
import { loadConfig } from './config';
const config = loadConfig();
const container = createContainer(config);
const server = new Server(container.orderService, container.userService);
server.listen(config.port);
And your tests:
// test/orderService.test.ts
import { OrderService } from '../src/services/orderService';
test('create order validates stock', async () => {
const orderRepo = new FakeOrderRepository();
const userRepo = new FakeUserRepository();
const emailService = new FakeEmailService();
const inventoryClient = new FakeInventoryClient();
const orderService = new OrderService(orderRepo, userRepo, emailService, inventoryClient);
await expect(orderService.createOrder('user-1', [overstockedItem]))
.rejects.toThrow('insufficient stock');
});
No decorators. No decorator transforms. No jest.mock calls. The test constructs exactly the dependencies it needs with test doubles.
When to use this
- Projects under 20 classes
- Teams that value explicitness over convenience
- You want zero build-time transforms
The drawback
As the project grows past 30 or 40 classes, the createContainer function becomes a bottleneck. You merge branches and the DI file conflicts. Ordering matters and is fragile. That is when you reach for Pattern 2.
Pattern 2: The factory-based container (for medium projects)
Register factories, not classes. The container is a Map<string, () => unknown>. Dependencies are resolved lazily, so order does not matter as long as there are no circular references.
// src/container.ts
type Factory<T> = (c: Container) => T;
class Container {
private factories = new Map<string, Factory<unknown>>();
private instances = new Map<string, unknown>();
register<T>(name: string, factory: Factory<T>): void {
this.factories.set(name, factory);
this.instances.delete(name); // bust cached instance on re-register
}
resolve<T>(name: string): T {
if (!this.instances.has(name)) {
const factory = this.factories.get(name);
if (!factory) throw new Error(`No factory registered for "${name}"`);
this.instances.set(name, factory(this));
}
return this.instances.get(name) as T;
}
}
The Container class has no external dependencies. It is 20 lines. You can vendor it or inline it.
Register everything in a single place:
// src/bootstrap.ts
import { Container } from './container';
import { Config } from './config';
export function bootstrap(config: Config): Container {
const c = new Container();
// Infrastructure
c.register('config', () => config);
c.register('db', (c) => new DatabaseConnection(c.resolve<Config>('config').db));
// Repositories
c.register('userRepo', (c) => new UserRepository(c.resolve('db')));
c.register('orderRepo', (c) => new OrderRepository(c.resolve('db')));
// Clients
c.register('inventoryClient', (c) =>
new InventoryClient(c.resolve<Config>('config').inventoryApi)
);
// Services
c.register('emailService', (c) =>
new EmailService(c.resolve<Config>('config').smtp)
);
c.register('orderService', (c) =>
new OrderService(
c.resolve('orderRepo'),
c.resolve('userRepo'),
c.resolve('emailService'),
c.resolve('inventoryClient')
)
);
return c;
}
Services never touch the container directly. They accept their dependencies through the constructor, same as Pattern 1:
export class OrderService {
constructor(
private orderRepo: OrderRepository,
private userRepo: UserRepository,
private email: EmailService,
private inventory: InventoryClient
) {}
async createOrder(userId: string, items: CartItem[]) { /* ... */ }
}
The container handles lifecycle. By default every registration is a singleton (resolved once, cached). If you need a transient (new instance every time), add a second parameter:
c.register('requestLogger', () => new RequestLogger(), { transient: true });
Testing with the container
// test/orderService.test.ts
import { Container } from '../src/container';
function testContainer(): Container {
const c = new Container();
c.register('orderRepo', () => new FakeOrderRepository());
c.register('userRepo', () => new FakeUserRepository());
c.register('emailService', () => new FakeEmailService());
c.register('inventoryClient', () => new FakeInventoryClient());
// Register minimal real dependencies
c.register('config', () => ({ smtp: { host: 'fake' } }));
c.register('db', () => ({ query: async () => [] }));
const orderService = c.resolve<OrderService>('orderService');
return { orderService, c };
}
No decorators. No decorator transform. No third-party DI library.
When to use this
- 20 to 100 services/repositories/clients
- You want lazy resolution so registration order does not matter
- You want singleton lifecycle management for database connections and HTTP clients
- You want to avoid pulling in an external DI library
The drawback
The bootstrap.ts file gets long. You still string-reference service names ('orderRepo'), which means runtime errors if you typo a name. TypeScript can only catch this if you wrap resolve with a branded type or a registry object.
Pattern 3: Function-scoped injection (for services with no state)
Not every service needs to be a class. If your service does not hold mutable state, write it as a function and inject dependencies as the first argument (or use currying).
// src/services/createOrder.ts
import { OrderRepository } from '../repositories/orderRepository';
import { EmailService } from './emailService';
import { InventoryClient } from '../clients/inventoryClient';
type Dependencies = {
orderRepo: OrderRepository;
emailService: EmailService;
inventoryClient: InventoryClient;
};
export function createOrderService(deps: Dependencies) {
return {
async createOrder(userId: string, items: CartItem[]) {
const orderId = await deps.orderRepo.create(userId, items);
await deps.emailService.sendConfirmation(userId, orderId);
await deps.inventoryClient.reserve(items);
return orderId;
},
async cancelOrder(orderId: string) {
await deps.orderRepo.cancel(orderId);
await deps.inventoryClient.releaseItems(orderId);
},
};
}
Usage:
const orderService = createOrderService({
orderRepo: new OrderRepository(db),
emailService: new EmailService(config.smtp),
inventoryClient: new InventoryClient(config.inventoryApi),
});
await orderService.createOrder('user-1', cartItems);
Testing with this pattern is the cleanest of all three:
const orderService = createOrderService({
orderRepo: { create: async () => 'order-1', cancel: async () => {} },
emailService: { sendConfirmation: async () => {} },
inventoryClient: { reserve: async () => {}, releaseItems: async () => {} },
});
const result = await orderService.createOrder('user-1', [item]);
expect(result).toBe('order-1');
No classes. No new. No jest.mock. The entire dependency graph for a test is visible in the function call.
When to use this
- Services that are pure orchestration (no state, no lifecycle hooks)
- Teams that prefer functional composition
- You want the simplest possible test setup
The drawback
Functions cannot hold open handles or manage lifecycle. If your service needs dispose() or close(), a class with a constructor-injected dependency is a better fit.
Comparison by real metrics
| Pattern | Lines of DI code (20 services) | Test setup boilerplate | Build transforms needed | Runtime error safety |
|---|---|---|---|---|
| Manual wiring | 40-60 | None | No | Full (TypeScript checks arg order) |
| Factory container | 80-120 | None | No | Runtime (string-key typos) |
| Function-scoped | 20-30 per module | None | No | Full (TypeScript checks the deps object) |
| tsyringe/inversify | 30-50 + decorators | Minimal | Yes (experimentalDecorators or SWC plugin) | Runtime (decorator resolution) |
The three patterns above all score better than a heavyweight DI framework on build transforms (zero), test setup (zero mocking infrastructure needed), and cognitive overhead (you read the code and see exactly what gets passed where).
When you should actually reach for a DI library
There are three scenarios where a library like tsyringe or awilix earns its keep:
1. You need automatic lifetime management. tsyringe can register { lifecycle: 'singleton', useContext: true } and resolve scoped instances per HTTP request. Doing that with Pattern 2 requires you to manually manage a request-scoped cache. It is doable but the library handles edge cases (nested scopes, disposal) that your 20-line container will not.
2. Circular dependencies that you cannot refactor. Sometimes two modules legitimately need each other. A DI library with proxy-based resolution can break the cycle without restructuring. Pattern 2 will throw a stack overflow on circular resolution.
3. Plugins or extensibility. If you are building a framework that third parties extend by registering their own services (like Hapi.js or NestJS), you need dynamic discovery and ordered registration. The container must be configurable at runtime by external code.
If you are not in one of those three scenarios, start with Pattern 1. Grow into Pattern 2. Consider Pattern 3 for stateless orchestration. Skip the decorators.
Putting it together: the pragmatic default
For a new Node.js project that expects to grow, here is the recommendation:
-
Day 1-30: Pattern 1 (manual wiring). The
createContainerfunction is one file. It documents the entire dependency graph in one place. New team members read it and understand the architecture immediately. -
Month 2-6: Refactor to Pattern 2 (factory container) when the
createContainerfunction hits 50 lines and merge conflicts become annoying. The container class is 20 lines. Add it as a local utility, not a package dependency. -
For every new service: decide whether it holds state. If no, use Pattern 3 (function-scoped). If yes, use Pattern 2’s container resolution.
-
Never import
tsyringe,inversify, orawilixunless you hit one of the three scenarios above and can justify the transform pipeline change.
The result is a codebase where every test constructs its dependencies explicitly, no test uses jest.mock at the module level, and changing a constructor signature breaks exactly one file (the container or the test).
That is worth more than any decorator syntax ever delivered.
A note from Yojji
Building services with clear, testable dependency boundaries is one of those practices that separates teams who ship reliably from teams who dread refactoring. It requires discipline on every pull request to keep constructors clean and containers lean. That discipline is exactly what Yojji’s engineering teams bring to every engagement.
Yojji is an international custom software development company that has been helping businesses design, build, and scale digital products since 2016. Their senior engineers work across the full Node.js ecosystem (TypeScript, React, cloud-native architectures) and operate out of offices in Europe, the US, and the UK. If your team needs help establishing better backend practices or scaling an existing codebase, they are worth a conversation.