Dependency Injection and IoC Containers in Node.js
Dependency injection decouples components by providing dependencies from outside.
Manual Dependency Injection
// Define interfaces (abstractions)
interface Logger {
log(message: string): void;
error(message: string, error?: Error): void;
}
interface UserRepository {
findById(id: string): Promise<User | null>;
save(user: User): Promise<void>;
}
// Concrete implementations
class ConsoleLogger implements Logger {
log(message: string) { console.log(`[INFO] ${message}`); }
error(message: string, error?: Error) { console.error(`[ERROR] ${message}`, error); }
}
class PostgresUserRepository implements UserRepository {
constructor(private db: Database) {}
async findById(id: string) { return this.db.users.findOne(id); }
async save(user: User) { await this.db.users.upsert(user); }
}
// Service depends on abstractions
class UserService {
constructor(
private userRepo: UserRepository,
private logger: Logger
) {}
async getUser(id: string): Promise<User> {
this.logger.log(`Getting user ${id}`);
const user = await this.userRepo.findById(id);
if (!user) throw new UserNotFoundError(id);
return user;
}
}
// Composition root - wire everything together
const logger = new ConsoleLogger();
const db = new Database(config.databaseUrl);
const userRepo = new PostgresUserRepository(db);
const userService = new UserService(userRepo, logger);
tsyringe (Lightweight Container)
import 'reflect-metadata';
import { container, injectable, inject, singleton } from 'tsyringe';
@singleton()
class ConsoleLogger implements Logger {
log(message: string) { console.log(message); }
error(message: string) { console.error(message); }
}
@injectable()
class UserRepository {
constructor(@inject('Database') private db: Database) {}
async findById(id: string) { return this.db.users.findOne(id); }
}
@injectable()
class UserService {
constructor(
private userRepo: UserRepository,
private logger: ConsoleLogger
) {}
async getUser(id: string) {
this.logger.log(`Getting user ${id}`);
return this.userRepo.findById(id);
}
}
// Register and resolve
container.register('Database', { useValue: new Database(config.url) });
const service = container.resolve(UserService);
NestJS Built-in IoC
import { Injectable, Module } from '@nestjs/common';
@Injectable()
class UserRepository {
async findById(id: string): Promise<User> { /* ... */ }
}
@Injectable()
class UserService {
constructor(private userRepository: UserRepository) {}
async getUser(id: string) {
return this.userRepository.findById(id);
}
}
@Module({
providers: [UserService, UserRepository],
exports: [UserService],
})
class UserModule {}
Custom Decorators for DI
// Simple decorator-based DI
const METADATA_KEY = Symbol('injectable');
const container = new Map<string, any>();
function Injectable(token: string) {
return (target: any) => {
Reflect.defineMetadata(METADATA_KEY, token, target);
};
}
function register(token: string, implementation: any) {
container.set(token, new implementation());
}
function resolve<T>(token: string): T {
const instance = container.get(token);
if (!instance) throw new Error(`No binding for ${token}`);
return instance;
}
Testing with DI
// Easy to swap implementations in tests
describe('UserService', () => {
let service: UserService;
let mockRepo: jest.Mocked<UserRepository>;
beforeEach(() => {
mockRepo = {
findById: jest.fn(),
save: jest.fn(),
};
service = new UserService(mockRepo, new ConsoleLogger());
});
test('throws when user not found', async () => {
mockRepo.findById.mockResolvedValue(null);
await expect(service.getUser('unknown')).rejects.toThrow(UserNotFoundError);
});
});
DI makes code testable, modular, and flexible—replacing components without changing consumers.