Building a Basic Dependency Injection Container in Express Without NestJS
Wenhao Wang
Dev Intern · Leapcell

Introduction
In the world of web development, maintaining clean, testable, and scalable codebases is paramount. As applications grow in complexity, managing dependencies can become a significant challenge. Tightly coupled components lead to brittle code, making refactoring a nightmare and unit testing an arduous task. Dependency Injection (DI) emerges as a powerful design pattern to address these issues, promoting loose coupling and enhancing modularity. While frameworks like NestJS offer robust, opinionated DI containers out of the box, many existing or smaller Express.js projects might not justify a complete framework overhaul. This article explores how to manually implement a simple, yet effective, dependency injection container directly within an Express application, leveraging JavaScript's dynamic nature to improve code organization and testability without the overhead of a full-fledged framework.
Understanding Dependency Injection
Before diving into the implementation, let's establish a clear understanding of the core concepts involved:
- Dependency Injection (DI): A design pattern where components receive their dependencies from an external source rather than creating them themselves. This "inversion of control" makes components more independent and reusable.
 - IoC Container (Inversion of Control Container): A framework or a mechanism responsible for managing the lifecycle and dependencies of components. It instantiates objects and injects their required dependencies. In our case, we'll build a very basic version of this.
 - Service: A class or function that performs a specific task and often depends on other services. Services are the primary candidates for dependency injection.
 - Provider: A mechanism that tells the IoC container how to create an instance of a particular dependency. This could be a constructor function, a factory function, or an existing instance.
 
The core principle behind DI is that a module should not instantiate its dependencies but rather declare them. An external entity (the container) then "injects" these dependencies at runtime. This leads to several benefits: increased testability (dependencies can be easily mocked), reduced coupling, and improved maintainability.
Building a Simple DI Container
Our simple DI container will focus on a few key capabilities: registering services, resolving services and their dependencies, and optionally, managing singletons.
The Container Class
First, let's create a Container class that will hold our registered services and provide methods to register and resolve them.
// container.js class Container { constructor() { this.services = new Map(); } /** * Registers a service with the container. * @param {string} name - The unique name of the service. * @param {class | Function} ServiceProvider - The class constructor or factory function for the service. * @param {Array<string>} dependencies - An array of names of services this service depends on. * @param {boolean} isSingleton - Whether this service should be a singleton. */ register(name, ServiceProvider, dependencies = [], isSingleton = false) { if (this.services.has(name)) { console.warn(`Service "${name}" is being re-registered.`); } this.services.set(name, { provider: ServiceProvider, dependencies: dependencies, isSingleton: isSingleton, instance: null // To store singleton instance }); } /** * Resolves and returns an instance of the requested service. * @param {string} name - The name of the service to resolve. * @returns {any} An instance of the service. * @throws {Error} If the service or its dependencies cannot be resolved. */ resolve(name) { const serviceEntry = this.services.get(name); if (!serviceEntry) { throw new Error(`Service "${name}" not found.`); } if (serviceEntry.isSingleton && serviceEntry.instance) { return serviceEntry.instance; // Return existing singleton instance } const resolvedDependencies = serviceEntry.dependencies.map(depName => { // Recursively resolve dependencies return this.resolve(depName); }); let serviceInstance; if (typeof serviceEntry.provider === 'function') { // Check if it's a class constructor or a regular function try { // Attempt to instantiate as a class serviceInstance = new serviceEntry.provider(...resolvedDependencies); } catch (e) { // If it's a regular function or class instantiation failed (e.g., trying to new a factory function) // Treat it as a factory function serviceInstance = serviceEntry.provider(...resolvedDependencies); } } else { // If provider is not a function, assume it's a pre-instantiated object (direct value) serviceInstance = serviceEntry.provider; } if (serviceEntry.isSingleton) { serviceEntry.instance = serviceInstance; // Store singleton instance } return serviceInstance; } } const container = new Container(); module.exports = container;
Example Services
Let's imagine we have a LoggerService and a UserService that depends on the LoggerService and an EmailService.
// services/LoggerService.js class LoggerService { log(message) { console.log(`[LOG] ${message}`); } error(message) { console.error(`[ERROR] ${message}`); } } module.exports = LoggerService; // services/EmailService.js class EmailService { send(to, subject, body) { console.log(`Sending email to ${to} with subject "${subject}" and body: ${body}`); // In a real app, this would integrate with an email sending API return true; } } module.exports = EmailService; // services/UserService.js class UserService { constructor(loggerService, emailService) { this.logger = loggerService; this.emailService = emailService; } createUser(name, email) { this.logger.log(`Attempting to create user: ${name}`); // ... logic to save user to DB ... const user = { id: Date.now(), name, email }; this.emailService.send(email, 'Welcome!', `Hello ${name}, welcome to our service!`); this.logger.log(`User ${name} created successfully.`); return user; } getUser(id) { this.logger.log(`Fetching user with ID: ${id}`); // ... logic to fetch user from DB ... return { id, name: "John Doe", email: "john.doe@example.com" }; } } module.exports = UserService;
Registering Services
Now, we need to register these services with our container. This is typically done at the application's bootstrap phase.
// app.js (or an initializer file) const container = require('./container'); const LoggerService = require('./services/LoggerService'); const EmailService = require('./services/EmailService'); const UserService = require('./services/UserService'); // Register LoggerService as a singleton container.register('LoggerService', LoggerService, [], true); // Register EmailService as a singleton container.register('EmailService', EmailService, [], true); // Register UserService, specifying its dependencies container.register('UserService', UserService, ['LoggerService', 'EmailService']); // You can also register a pre-instantiated object or a factory function container.register('Config', { database: 'mongodb://localhost/mydb', port: 3000 }, [], true); container.register('DatabaseConnection', (config) => { console.log(`Connecting to database: ${config.database}`); return { query: (sql) => console.log(`Executing SQL: ${sql} on ${config.database}`) }; }, ['Config'], true);
Integrating with Express.js
The key challenge in Express.js is how to leverage this container within route handlers or middleware, as they are typically invoked by Express directly, not our container. A common pattern is to retrieve the necessary services from the container within the route handler.
// server.js const express = require('express'); const app = express(); const container = require('./container'); // Our DI container initialized // Initializing services (this would typically be in app.js or an index file) require('./app'); // This file registers all our services with the container app.use(express.json()); // Example route using injected services app.post('/users', (req, res) => { try { const userService = container.resolve('UserService'); const { name, email } = req.body; if (!name || !email) { return res.status(400).send('Name and email are required.'); } const newUser = userService.createUser(name, email); res.status(201).json(newUser); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error creating user: ${error.message}`); res.status(500).send('Internal server error.'); } }); app.get('/users/:id', (req, res) => { try { const userService = container.resolve('UserService'); const logger = container.resolve('LoggerService'); const userId = req.params.id; logger.log(`Received request to get user by ID: ${userId}`); const user = userService.getUser(userId); if (!user) { return res.status(404).send('User not found.'); } res.json(user); } catch (error) { const logger = container.resolve('LoggerService'); logger.error(`Error fetching user: ${error.message}`); res.status(500).send('Internal server error.'); } }); const PORT = process.env.PORT || 3000; app.listen(PORT, () => { const logger = container.resolve('LoggerService'); logger.log(`Server running on port ${PORT}`); });
Application Scenarios
This manual DI container is particularly useful in several scenarios:
- Existing Express projects: When you want to introduce better dependency management without a full framework migration.
 - Smaller microservices: Where the overhead of a large framework might be disproportionate to the service's complexity.
 - Learning and understanding: A great way to grasp the fundamentals of dependency injection before exploring more advanced frameworks.
 - Testability: In unit tests, you can easily mock or swap dependencies by registering different providers for the test environment.
 
Conclusion
Manually implementing a simple dependency injection container in an Express.js application, without relying on frameworks like NestJS, is a practical approach to improving code structure, testability, and maintainability. By understanding the core principles of DI and creating a basic Container class, developers can effectively manage service dependencies, leading to cleaner and more modular Express applications. This empowers developers to selectively apply DI principles where they bring the most value, fostering scalable and robust backend systems with greater control over the architectural choices.