Dependency Injection in JavaScript: A Practical Guide for Node.js

Online Python Trainer for Beginners

Learn Python easily without overwhelming theory. Solve practical tasks with automatic checking, get hints in Russian, and write code directly in your browser — no installation required.

Start Course

Introduction: The Problem of Tightly Coupled Code



Every Node.js developer sooner or later faces a situation where a change in one module triggers a chain of edits in a dozen others. This is especially acute when writing unit tests: to test one service, you have to spin up a database, external APIs, and a cache. The root of the problem is tight coupling.



Dependency Injection (DI) is a design pattern that solves this problem. Instead of a class or function creating its dependencies on its own, it receives them from the outside. This makes the code flexible, testable, and compliant with the Dependency Inversion Principle from SOLID.



In this article, we will break down DI in practice: from simple manual injection to using professional containers in Node.js.



1. Manual Dependency Injection: The First Step



Bad Approach: Tight Coupling

Consider a typical example of an email sending service:



// userService.jsconst emailService = require('./emailService');

class UserService { async registerUser(email) { // Tight dependency on a specific implementation await emailService.sendWelcomeEmail(email); console.log('User registered'); }}

module.exports = UserService;


Problem: when testing UserService, it is impossible to replace emailService with a mock. You would have to use complex tools like proxyquire or jest.mock.



Good Approach: Constructor Injection

Let's rewrite the service by passing the dependency through the constructor:



// userService.jsclass UserService {  constructor(emailService) {    this.emailService = emailService;  }

async registerUser(email) { await this.emailService.sendWelcomeEmail(email); console.log('User registered'); }}

module.exports = UserService;


Now we can easily test the service:



// userService.test.jsconst UserService = require('./userService');

const mockEmailService = { sendWelcomeEmail: jest.fn().mockResolvedValue(true)};

test('should send a welcome email', async () => { const service = new UserService(mockEmailService); await service.registerUser('test@test.com'); expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@test.com');});


2. Types of Dependency Injection in JavaScript



In JavaScript/Node.js, there are three main ways to inject dependencies:



2.1. Constructor Injection

The most common and predictable method. Dependencies are passed when creating an object:



class DatabaseService {  constructor(connection) {    this.connection = connection;  }

async query(sql) { return this.connection.execute(sql); }}

// Usageconst db = new DatabaseService(new MySQLConnection());


2.2. Setter Injection

Suitable when dependencies can be changed during the object's lifecycle:



class LoggerService {  setLogger(logger) {    this.logger = logger;  }

log(message) { this.logger.write(`[${new Date().toISOString()}] ${message}`); }}

const logger = new LoggerService();logger.setLogger(new ConsoleLogger());logger.log('Application started');


2.3. Function Parameter Injection

Ideal for a functional approach and utilities:



function sendNotification(user, emailService, smsService) {  emailService.send(user.email, 'Notification');  smsService.send(user.phone, 'Notification');}

// Testingconst mockEmail = { send: jest.fn() };const mockSms = { send: jest.fn() };sendNotification({ email: 'a@b.com', phone: '123' }, mockEmail, mockSms);


3.

Blogs

Book Recommendations