Dependency Injection
Dependency Injection (DI) is the practice of supplying a module's dependencies from the outside rather than having it create or import them internally. Instead of a service hard-coding import db from '../db', you pass the database as an argument — so tests can pass a fake, and different callers can provide different implementations. DI is the most practical technique for making Node code testable without mocks of the whole world, and it keeps modules genuinely decoupled. You don't need a DI framework; constructor injection and factory functions cover most Node use cases cleanly.
The problem — hard-coded dependencies
// ❌ Service creates/imports its dependency — impossible to test without the real DB:
import { db } from '../db' // hard-coded import
export class UsersService {
async findById(id: string) {
return db.query('SELECT * FROM users WHERE id = $1', [id])
}
}
// Test: you MUST have a real database running, or mock the 'db' module globally.Constructor injection — the clean solution
// ✅ Dependency is PASSED IN — the service is decoupled from the implementation:
export class UsersService {
constructor(private readonly db: Db) {} // interface, not a concrete import
async findById(id: string) {
return this.db.query('SELECT * FROM users WHERE id = $1', [id])
}
}
// Production — wire it together in your composition root:
const usersService = new UsersService(realDb)
// Test — pass a fake, no module patching:
const fakeDb = { query: vi.fn().mockResolvedValue({ rows: [{ id: '1', name: 'Ada' }] }) }
const service = new UsersService(fakeDb)
const user = await service.findById('1')
expect(user.rows[0].name).toBe('Ada')Functional injection with factory functions
// Functional DI — factory that closes over its deps; returns a service object:
export function createUsersService(db: Db, mailer: Mailer) {
return {
async findById(id: string) {
return db.query('SELECT * FROM users WHERE id = $1', [id])
},
async create(dto: CreateUserDto) {
const user = await db.query('INSERT INTO users ...')
await mailer.send(user.email, 'Welcome!')
return user
},
}
}
// Usage — same composition pattern, no class syntax needed:
const usersService = createUsersService(db, mailer)Manual wiring vs DI containers
Approach | When to use | Trade-off |
|---|---|---|
Manual wiring (constructor/factory) | Most Node apps — explicit, readable, no magic | Verbose composition root for large graphs |
DI containers (InversifyJS, tsyringe) | Large apps with deep dependency graphs | Setup complexity, decorators, "magic" resolution |
Frameworks with built-in DI (NestJS) | Teams wanting a full opinionated framework | Significant NestJS-specific learning curve |