Dependency Injection in Express with awilix

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
Note: Awilix uses CAMEL_INJECTION by default โ€” it reads the constructor parameter names and matches them to registered component names. A class with 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.
Tip: Register infrastructure components (database connection, logger, config) as singletons and business components (services, repositories) as scoped or transient. Use scoped registration for anything that should be created fresh per request โ€” this prevents state leaking between requests. With awilix’s scopePerRequest middleware, a new container scope is created automatically for every incoming HTTP request.
Warning: Awilix resolves dependencies by parameter name at runtime. If you minify or obfuscate your JavaScript code (common in some build pipelines), parameter names are mangled and awilix cannot resolve dependencies. Either configure your build tool to preserve class and function parameter names (using Babel’s @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))

🧠 Test Yourself

A TaskService class has constructor({ taskRepository, emailService }). In a unit test, you want to test the service without making real database calls. What is the correct approach with awilix?