Express apps don’t just magically connect their pieces; they often become tangled messes of global variables and direct imports that are impossible to test or maintain. Dependency injection is the antidote, allowing you to explicitly define how your app’s components get the things they need to run.
Imagine you have a userService that needs a userRepository and an emailService. Without DI, you’d likely write it like this:
// userService.js
const userRepository = require('./userRepository');
const emailService = require('./emailService');
class UserService {
constructor() {
this.userRepository = userRepository;
this.emailService = emailService;
}
async getUser(id) {
const user = await this.userRepository.findById(id);
if (user) {
await this.emailService.sendWelcomeEmail(user.email);
}
return user;
}
}
module.exports = new UserService();
This is brittle. What if userRepository fails to load? What if you want to swap emailService for a mock during testing? It’s a mess.
With dependency injection, you inject these dependencies from the outside.
Let’s set up a simple DI container. We’ll use a basic object for this example, but in real apps, you’d use libraries like awilix, tsyringe, or inversify.
// container.js
const UserRepository = require('./userRepository');
const EmailService = require('./emailService');
const UserService = require('./userService');
const container = {
userRepository: new UserRepository(),
emailService: new EmailService(),
};
// Now, UserService will receive its dependencies
container.userService = new UserService(container.userRepository, container.emailService);
module.exports = container;
And userService.js changes to:
// userService.js
class UserService {
constructor(userRepository, emailService) {
this.userRepository = userRepository;
this.emailService = emailService;
}
async getUser(id) {
const user = await this.userRepository.findById(id);
if (user) {
await this.emailService.sendWelcomeEmail(user.email);
}
return user;
}
}
module.exports = UserService; // Export the class, not an instance
Now, your container.js is the single source of truth for how services are wired up.
In your Express app’s entry point (app.js or server.js):
// app.js
const express = require('express');
const container = require('./container'); // Import our DI container
const app = express();
const port = 3000;
app.get('/users/:id', async (req, res) => {
try {
const userId = req.params.id;
const user = await container.userService.getUser(userId); // Use the injected service
if (user) {
res.json(user);
} else {
res.status(404).send('User not found');
}
} catch (error) {
console.error('Error fetching user:', error);
res.status(500).send('Internal server error');
}
});
app.listen(port, () => {
console.log(`App listening on port ${port}`);
});
This setup allows you to easily swap implementations. For testing, you can create a mock userRepository and emailService and pass them into the container before testing your UserService.
// userService.test.js
const UserService = require('./userService');
describe('UserService', () => {
let mockUserRepository;
let mockEmailService;
let userService;
beforeEach(() => {
mockUserRepository = {
findById: jest.fn(),
};
mockEmailService = {
sendWelcomeEmail: jest.fn(),
};
// Inject mocks for testing
userService = new UserService(mockUserRepository, mockEmailService);
});
test('should get user and send welcome email', async () => {
const mockUser = { id: '123', email: 'test@example.com' };
mockUserRepository.findById.mockResolvedValue(mockUser);
const user = await userService.getUser('123');
expect(user).toEqual(mockUser);
expect(mockUserRepository.findById).toHaveBeenCalledWith('123');
expect(mockEmailService.sendWelcomeEmail).toHaveBeenCalledWith('test@example.com');
});
test('should return null if user not found', async () => {
mockUserRepository.findById.mockResolvedValue(null);
const user = await userService.getUser('456');
expect(user).toBeNull();
expect(mockUserRepository.findById).toHaveBeenCalledWith('456');
expect(mockEmailService.sendWelcomeEmail).not.toHaveBeenCalled();
});
});
The true power of DI isn’t just about passing objects around; it’s about managing the lifecycle of those objects. A DI container can manage whether a service is a singleton (one instance for the entire application), a transient (a new instance every time it’s requested), or a scoped instance (one instance per request, common in web apps). This is crucial for managing state and resources efficiently. For instance, a database connection pool might be a singleton, ensuring only one pool is created, while a request-scoped logger might be created anew for each incoming HTTP request.
When you move from manual injection to a dedicated DI container library, you often define your services and their dependencies in a central configuration file. The container then inspects these definitions and automatically resolves and injects dependencies, managing their lifecycles according to your configuration. This abstraction significantly cleans up your application’s bootstrapping code and makes complex dependency graphs manageable.