Dependency injection (DI) is a design technique where a component receives its dependencies from outside rather than creating them itself. Instead of const taskRepo = require('../repositories/task.repository') hardcoded inside a service, the service receives taskRepository as a constructor argument. This makes components loosely coupled โ each one depends on an interface, not a concrete implementation โ and dramatically easier to test, since dependencies can be swapped for mocks. Awilix is the leading DI container for Node.js. It manages the creation, registration, and injection of all application components automatically, eliminating manual wiring while preserving full testability.
Dependency Injection Concepts
| Term | Meaning |
|---|---|
| Dependency | A component that another component needs to function |
| Injection | Providing the dependency from outside rather than creating it internally |
| Container | Object that knows how to create and wire all components |
| Registration | Telling the container how to create a component |
| Resolution | The container creating and returning a fully wired component |
| Lifetime | How long the container keeps a component alive โ singleton, scoped, transient |
Awilix Registration Helpers
| Helper | Wraps | Lifetime Default |
|---|---|---|
asClass(MyClass) |
Class constructor โ awilix calls new |
Transient |
asFunction(myFn) |
Factory function โ awilix calls the function | Transient |
asValue(value) |
Plain value โ stored as-is | Singleton (always) |
Awilix Lifetimes
| Lifetime | Awilix Constant | Created | Use For |
|---|---|---|---|
| Singleton | Lifetime.SINGLETON |
Once, shared forever | DB connections, loggers, caches |
| Scoped | Lifetime.SCOPED |
Once per scope (request) | Per-request services with request context |
| Transient | Lifetime.TRANSIENT |
New instance every time | Stateless components |
constructor({ taskRepository, logger }) receives the registered taskRepository and logger components automatically. This is called “auto-wiring” โ you never have to explicitly connect components. The container figures out the dependency graph from parameter names.scopePerRequest middleware, a new container scope is created automatically for every incoming HTTP request.@babel/plugin-proposal-class-properties), or use explicit registration tokens instead of relying on auto-wiring in production builds.Complete awilix Setup
// npm install awilix
const awilix = require('awilix');
const { createContainer, asClass, asFunction, asValue, Lifetime } = awilix;
// โโ Services with DI constructor injection โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// services/task.service.js โ receives dependencies via constructor
class TaskService {
constructor({ taskRepository, userRepository, logger, config }) {
this.taskRepository = taskRepository;
this.userRepository = userRepository;
this.logger = logger;
this.config = config;
this.MAX_TASKS = config.maxActiveTasks || 100;
}
async createTask(userId, taskData) {
const activeCount = await this.taskRepository.countActive(userId);
if (activeCount >= this.MAX_TASKS) {
throw new ValidationError(`Task limit of ${this.MAX_TASKS} reached`);
}
this.logger.info('Creating task', { userId, title: taskData.title });
return this.taskRepository.create({ ...taskData, user: userId });
}
async getAllTasks(userId, params) {
return this.taskRepository.findAll({ userId, ...params });
}
}
// repositories/task.repository.js โ receives Mongoose models
class TaskRepository {
constructor({ TaskModel }) { // TaskModel injected from container
this.Task = TaskModel;
}
async findAll({ userId, status, priority, page = 1, limit = 10 }) {
const filter = { user: userId };
if (status) filter.status = status;
if (priority) filter.priority = priority;
const [data, total] = await Promise.all([
this.Task.find(filter).sort('-createdAt').skip((page - 1) * limit).limit(limit).lean(),
this.Task.countDocuments(filter),
]);
return { data, total };
}
async findById(id, userId) {
return this.Task.findOne({ _id: id, user: userId }).lean();
}
async create(data) { return this.Task.create(data); }
async countActive(userId) { return this.Task.countDocuments({ user: userId, status: { $ne: 'completed' } }); }
async update(id, userId, data) { return this.Task.findOneAndUpdate({ _id: id, user: userId }, { $set: data }, { new: true }); }
async delete(id, userId) { return this.Task.findOneAndDelete({ _id: id, user: userId }); }
}
// controllers/task.controller.js โ receives service via constructor
class TaskController {
constructor({ taskService }) {
this.taskService = taskService;
}
getAll = async (req, res, next) => {
try {
const result = await this.taskService.getAllTasks(req.user.id, req.query);
res.json({ success: true, ...result });
} catch (err) { next(err); }
};
create = async (req, res, next) => {
try {
const task = await this.taskService.createTask(req.user.id, req.body);
res.status(201).json({ success: true, data: task });
} catch (err) { next(err); }
};
}
// โโ Container configuration โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// config/container.js
const Task = require('../models/task.model');
const logger = require('../utils/logger');
const config = require('./env');
const container = createContainer({ injectionMode: awilix.InjectionMode.PROXY });
container.register({
// Values โ always singleton
TaskModel: asValue(Task),
logger: asValue(logger),
config: asValue(config),
// Repositories โ transient (stateless, no shared state)
taskRepository: asClass(TaskRepository).setLifetime(Lifetime.TRANSIENT),
userRepository: asClass(UserRepository).setLifetime(Lifetime.TRANSIENT),
// Services โ transient
taskService: asClass(TaskService).setLifetime(Lifetime.TRANSIENT),
authService: asClass(AuthService).setLifetime(Lifetime.TRANSIENT),
// Controllers โ transient
taskController: asClass(TaskController).setLifetime(Lifetime.TRANSIENT),
authController: asClass(AuthController).setLifetime(Lifetime.TRANSIENT),
});
module.exports = container;
// โโ Integrating with Express routes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
// routes/task.routes.js
const express = require('express');
const container = require('../config/container');
const auth = require('../middleware/authenticate');
const router = express.Router();
// Resolve controller from container โ dependencies auto-wired
const ctrl = container.resolve('taskController');
router.use(auth);
router.get('/', ctrl.getAll);
router.post('/', ctrl.create);
router.get('/:id', ctrl.getById);
module.exports = router;
How It Works
Step 1 โ The Container Is a Registry of Components
When you call container.register({ taskService: asClass(TaskService) }), awilix stores a recipe for creating TaskService. It does not create the instance yet. When code later calls container.resolve('taskService'), awilix reads TaskService‘s constructor parameters, resolves each dependency recursively, then calls new TaskService({ taskRepository, logger, config }) with the resolved values.
Step 2 โ Auto-Wiring Reads Constructor Parameter Names
In PROXY injection mode, awilix passes a proxy object as the sole constructor argument. The proxy intercepts property access โ when your constructor destructures { taskRepository }, the proxy looks up taskRepository in the container and returns the resolved instance. The convention-based matching means adding a new dependency is as simple as adding its name to the constructor destructuring โ no configuration file to update.
Step 3 โ Lifetimes Control Instance Sharing
A SINGLETON component is created once and reused for all resolutions. A database connection or logger should be a singleton โ creating multiple connections or logger instances wastes resources. A TRANSIENT component gets a new instance every time it is resolved. A SCOPED component is created once per scope โ with scopePerRequest middleware, each HTTP request gets its own scope and its own scoped instances, which is useful for per-request context like tracing IDs.
Step 4 โ Class Arrow Methods Preserve this Context
When Express calls a route handler, this is not the controller instance โ it is undefined in strict mode. Using class field arrow methods (getAll = async (req, res, next) => {}) captures this at class instantiation time and preserves it for every call. This is why DI-injected controllers use arrow methods rather than regular prototype methods โ without this, this.taskService would be undefined inside the handler.
Step 5 โ Testing with Mocked Dependencies
In tests, create a test container that registers mock implementations alongside the real registrations. container.register({ taskRepository: asValue(mockRepository) }) replaces the real repository with a plain object whose methods are jest.fn() stubs. The service resolved from the test container receives the mock, allowing full service testing without a database.
Common Mistakes
Mistake 1 โ Using regular prototype methods instead of arrow methods for handlers
โ Wrong โ this is undefined when Express calls the handler:
class TaskController {
async getAll(req, res, next) { // regular method โ this is lost
const result = await this.taskService.getAll(); // TypeError: Cannot read properties of undefined
}
}
const ctrl = container.resolve('taskController');
router.get('/', ctrl.getAll); // called without controller context
✅ Correct โ use class field arrow methods:
class TaskController {
getAll = async (req, res, next) => { // arrow method โ this is bound
const result = await this.taskService.getAll(); // works correctly
};
}
Mistake 2 โ Registering stateful components as singletons
โ Wrong โ request-specific state leaks between requests:
class RequestLogger {
constructor({ logger }) {
this.requestId = null; // mutable state!
this.logger = logger;
}
setRequestId(id) { this.requestId = id; }
}
container.register({ requestLogger: asClass(RequestLogger) });
// All requests share the same instance โ requestId from request A overwrites request B!
✅ Correct โ use SCOPED lifetime for per-request state:
container.register({ requestLogger: asClass(RequestLogger).setLifetime(Lifetime.SCOPED) });
// Each request scope gets its own RequestLogger instance
Mistake 3 โ Hard-coding require() inside DI-registered classes
โ Wrong โ dependency is hardcoded, DI provides no benefit:
class TaskService {
constructor() {
this.repo = require('../repositories/task.repository'); // bypasses DI!
}
}
✅ Correct โ receive all dependencies through the constructor:
class TaskService {
constructor({ taskRepository }) { // injected by container
this.repo = taskRepository;
}
}
Quick Reference
| Task | Code |
|---|---|
| Create container | createContainer({ injectionMode: InjectionMode.PROXY }) |
| Register class | container.register({ name: asClass(MyClass) }) |
| Register value | container.register({ db: asValue(mongooseConnection) }) |
| Set lifetime | asClass(MyClass).setLifetime(Lifetime.SINGLETON) |
| Resolve component | container.resolve('taskService') |
| Inject in constructor | constructor({ taskRepository, logger }) {} |
| Per-request scope | app.use(awilix.scopePerRequest(container)) |