Backend Development 10 min read

Mastering Dependency Injection in Node.js: From Manual to Automatic

This article explains the concept of dependency injection, its benefits such as decoupling, easier maintenance, testing and reusability, and demonstrates both manual and automatic implementations in Node.js, including usage of third‑party libraries like InversifyJS.

Code Mala Tang
Code Mala Tang
Code Mala Tang
Mastering Dependency Injection in Node.js: From Manual to Automatic

What Is Dependency Injection

Dependency injection is a technique used to achieve inversion of control (IoC) during development. In IoC, control of the program flow is inverted: components do not create and manage their dependencies; instead, dependencies are provided from an external source.

In traditional programming, a component may directly create and manage the other components it depends on, leading to high coupling and making maintenance and testing difficult.

Inversion of control is a design principle that changes the control relationship between components. In IoC, components no longer create and manage their dependencies themselves; they delegate that control to an external source. Specifically, dependency injection is an implementation of IoC that provides required dependencies via an external source such as a container or framework.

The benefits are:

Decoupling: components depend on abstract interfaces rather than concrete implementations, reducing coupling.

Ease of maintenance: because dependencies are controlled externally, changing a dependency does not require modifying the component’s code.

Ease of testing: during unit tests, dependencies can be replaced with mock objects, allowing isolated testing.

Reusability: abstracted dependencies make components easier to reuse in different contexts.

How to Implement

After understanding the definition, let’s look at an example. First, a case without using dependency injection:

Manual Injection

<code>// Dependency.js
class Dependency {
  constructor() {
    this.name = 'Dependency';
  }
}

// Service.js
class Service {
  constructor(dependency) {
    this.dependency = dependency;
  }
  greet() {
    console.log(`Hello, I depend on ${this.dependency.name}`);
  }
}

// App.js
const Dependency = require('./Dependency');
const Service = require('./Service');
const dependency = new Dependency();
const service = new Service(dependency);
service.greet();
</code>

This example shows a simple manual injection where the Service class depends on a Dependency instance passed through its constructor.

Automatic Injection

Manual injection can become cumbersome when many dependencies are involved. Below is an automatic injection approach using a container.

<code>// Dependency.js
export class Dependency {
  constructor() {
    this.name = 'Dependency';
  }
}

// Service.js
export class Service {
  constructor(dependency) {
    this.dependency = dependency;
  }
  greet() {
    console.log(`Hello, I depend on ${this.dependency.name}`);
  }
}

// Container.js
import { Dependency } from './Dependency';
import { Service } from './Service';

export class Container {
  constructor() {
    this.dependencyInstances = new Map();
    this.dependencyConstructors = new Map([
      [Dependency, Dependency],
      [Service, Service],
    ]);
  }

  getDependency(ctor) {
    if (!this.dependencyInstances.has(ctor)) {
      const dependencyConstructor = this.dependencyConstructors.get(ctor);
      if (!dependencyConstructor) {
        throw new Error(`No dependency registered for ${ctor.name}`);
      }
      const instance = new dependencyConstructor(this.getDependency.bind(this));
      this.dependencyInstances.set(ctor, instance);
    }
    return this.dependencyInstances.get(ctor);
  }
}

// App.js
import { Container } from './Container';
import { Service } from './Service';
import { Dependency } from './Dependency';

const container = new Container();
const service = container.getDependency(Service);
service.greet();
</code>

The Container manages instances, creating them on demand and caching them for reuse.

<code>// app/controller/user.js
const Controller = require('egg').Controller;
class UserController extends Controller {
  async info() {
    const { ctx } = this;
    const userId = ctx.params.id;
    const userInfo = await ctx.service.user.find(userId);
    ctx.body = userInfo;
  }
}
module.exports = UserController;

// app/service/user.js
const Service = require('egg').Service;
class UserService extends Service {
  async find(uid) {
    const user = await this.ctx.db.query('select * from user where uid = ?', uid);
    const picture = await this.getPicture(uid);
    return { name: user.user_name, age: user.age, picture };
  }
}
module.exports = UserService;
</code>

Egg’s implementation uses a getter to replace explicit calls to container.getDependency(Service) , loading class instances via local file reads.

<code>// define ctx.service
Object.defineProperty(app.context, property, {
  get() {
    const ctx = this;
    if (!ctx[CLASS_LOADER]) {
      ctx[CLASS_LOADER] = new Map();
    }
    const classLoader = ctx[CLASS_LOADER];
    let instance = classLoader.get(property);
    if (!instance) {
      instance = getInstance(target, ctx);
      classLoader.set(property, instance);
    }
    return instance;
  },
});
</code>

The getInstance function creates an instance from a class or returns a primitive/value, preferring cached instances when available.

<code>function getInstance(values, ctx) {
  const Class = values[EXPORTS] ? values : null;
  let instance;
  if (Class) {
    if (isClass(Class)) {
      instance = new Class(ctx);
    } else {
      instance = Class;
    }
  } else if (isPrimitive(values)) {
    instance = values;
  } else {
    instance = new ClassLoader({ ctx, properties: values });
  }
  return instance;
}
</code>

Third‑Party Libraries

Besides implementing your own container, you can use libraries such as InversifyJS or Awilix, which provide advanced features like automatic resolution and lifecycle management. Below is a basic example using InversifyJS.

First, install the library:

<code>npm install inversify reflect-metadata --save
</code>

Then use it as follows:

<code>const { injectable, inject, Container } = require('inversify');
require('reflect-metadata');

// Define dependencies
@injectable()
class Logger {
  log(message) {
    console.log(message);
  }
}

@injectable()
class EmailService {
  constructor(@inject(Logger) logger) {
    this.logger = logger;
  }
  sendEmail(to, content) {
    this.logger.log(`Sending email to ${to}`);
    // send email logic...
  }
}

const container = new Container();
container.bind(Logger).toSelf();
container.bind(EmailService).toSelf();

const emailService = container.get(EmailService);
emailService.sendEmail('[email protected]', 'Hello, Dependency Injection with InversifyJS!');
</code>

Conclusion

Dependency injection is a powerful pattern that helps build more flexible, maintainable, and testable Node.js applications. Whether implemented manually or via dedicated libraries, it deserves a place in your toolbox, improving code quality and laying a solid foundation for future extensions.

backendJavaScriptIoCNode.jsDependency InjectionInversifyJS
Code Mala Tang
Written by

Code Mala Tang

Read source code together, write articles together, and enjoy spicy hot pot together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.