Skip to content
Node.js nd patterns 5 min read

Dependency Injection

Dependency injection (DI) is the practice of giving a component the things it needs from the outside instead of letting it construct them itself. Rather than a UserService reaching into the module system to grab a database client, the client is handed to it — usually through its constructor or a factory. This small shift in who-creates-what is the heart of inversion of control, and it pays off in code that is easier to swap, configure, and test in isolation.

Inversion of control, briefly

In a tightly coupled design, a high-level module decides exactly which concrete implementations it depends on. Inversion of control (IoC) flips that responsibility: the high-level module declares what it needs, and some outer layer decides which implementation to supply. Dependency injection is the most common way to achieve IoC — you “inject” collaborators rather than hard-wiring them. The benefit is that the same business logic can run against a real Postgres pool in production and an in-memory fake in a unit test, with no change to the logic itself.

The problem DI solves

Here a service constructs its own dependency, which makes it impossible to test without a live database.

// tightly-coupled.js — hard to test
import { db } from "./db.js";

export class UserService {
  async findById(id) {
    return db.query("SELECT * FROM users WHERE id = $1", [id]);
  }
}

Because db is imported at module load, every test of UserService drags in a real connection. There is no seam to substitute a stub.

Constructor injection (manual DI)

The simplest and most idiomatic approach in Node needs no library at all: accept dependencies as constructor arguments. The service now depends on an abstraction (anything with a query method) rather than a specific module.

// user-service.js
export class UserService {
  #db;

  constructor({ db }) {
    this.#db = db;
  }

  async findById(id) {
    const rows = await this.#db.query(
      "SELECT * FROM users WHERE id = $1",
      [id]
    );
    return rows[0] ?? null;
  }
}

You wire everything together once, at the application’s entry point — often called the composition root. This is the only place that knows about concrete implementations.

// main.js — the composition root
import pg from "pg";
import { UserService } from "./user-service.js";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const db = { query: (text, params) => pool.query(text, params).then((r) => r.rows) };

const users = new UserService({ db });
console.log(await users.findById(42));

Tip: keep your composition root thin and push it to the edge of the app. If business modules start importing each other’s concrete classes, the wiring leaks and the testing benefit evaporates.

DI containers

Manual wiring is fine for dozens of services, but as the graph grows you end up threading the same dependencies through many constructors. A DI container automates this: you register how to build each dependency, then resolve a root service and let the container construct the whole tree.

awilix

awilix is a popular, framework-agnostic container that works with plain JavaScript and ES modules. You register factories or classes, choose a lifetime, and resolve by name.

// container.js
import { createContainer, asClass, asFunction, asValue } from "awilix";
import pg from "pg";
import { UserService } from "./user-service.js";

const container = createContainer();

container.register({
  pool: asValue(new pg.Pool({ connectionString: process.env.DATABASE_URL })),
  db: asFunction(({ pool }) => ({
    query: (text, params) => pool.query(text, params).then((r) => r.rows),
  })).singleton(),
  userService: asClass(UserService).scoped(),
});

export default container;

awilix injects dependencies by matching the destructured argument names ({ db }) to registered names — a style called proxy injection.

// app.js
import container from "./container.js";

const users = container.resolve("userService");
console.log(await users.findById(42));

tsyringe

tsyringe is a lightweight container from Microsoft built for TypeScript, using decorators and reflect-metadata. You mark classes @injectable() and annotate constructor parameters with @inject().

// user-service.ts
import { injectable, inject } from "tsyringe";
import type { Db } from "./types.js";

@injectable()
export class UserService {
  constructor(@inject("Db") private db: Db) {}

  async findById(id: number) {
    const rows = await this.db.query("SELECT * FROM users WHERE id = $1", [id]);
    return rows[0] ?? null;
  }
}
// main.ts
import "reflect-metadata";
import { container } from "tsyringe";
import { UserService } from "./user-service.js";

container.register("Db", { useValue: realDb });
const users = container.resolve(UserService);

Choosing an approach

ApproachBest forLifetimesTypeScript
Manual constructor DISmall/medium apps, librariesYou manage themFirst-class
awilixLarger JS or TS apps, Express/Fastifysingleton, scoped, transientSupported
tsyringeDecorator-driven TS codebasessingleton, transient, containerRequired

How DI improves testability

With dependencies injected, a unit test supplies fakes directly — no module mocking, no real I/O. This keeps tests fast and deterministic.

// user-service.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { UserService } from "./user-service.js";

test("findById returns the first matching row", async () => {
  const db = {
    query: async (sql, params) => {
      assert.equal(params[0], 42);
      return [{ id: 42, name: "Ada" }];
    },
  };

  const users = new UserService({ db });
  const result = await users.findById(42);
  assert.deepEqual(result, { id: 42, name: "Ada" });
});

Output:

✔ findById returns the first matching row (1.8ms)
# tests 1
# pass 1
# fail 0

The stub db records that the query was called with the right parameter and returns canned rows — the service is exercised with zero database access.

Best practices

  • Depend on abstractions (an interface or a shape like { query }), not on concrete modules, so implementations stay swappable.
  • Inject through the constructor; avoid service-locator patterns where code reaches into the container at runtime, as they hide the real dependency graph.
  • Keep a single, thin composition root at the application edge and let it own all concrete wiring.
  • Reach for a container only when manual wiring becomes repetitive — small apps rarely need one.
  • Choose explicit lifetimes deliberately: singleton for shared resources like pools, scoped for per-request state, transient for stateless helpers.
  • Prefer plain fakes over heavy mocking frameworks in tests; injected dependencies make this trivial.
  • Never inject a dependency a module does not actually use, and avoid circular dependencies — they signal a missing layer.
Last updated June 14, 2026
Was this helpful?