NodeJSDependency Injection

Dependency Injection

Dependency Injection (DI) is the practice of supplying a module's dependencies from the outside rather than having it create or import them internally. Instead of a service hard-coding import db from '../db', you pass the database as an argument — so tests can pass a fake, and different callers can provide different implementations. DI is the most practical technique for making Node code testable without mocks of the whole world, and it keeps modules genuinely decoupled. You don't need a DI framework; constructor injection and factory functions cover most Node use cases cleanly.

The problem — hard-coded dependencies

TS
// ❌ Service creates/imports its dependency — impossible to test without the real DB:
import { db } from '../db'                    // hard-coded import

export class UsersService {
  async findById(id: string) {
    return db.query('SELECT * FROM users WHERE id = $1', [id])
  }
}
// Test: you MUST have a real database running, or mock the 'db' module globally.
A module that imports its dependencies directly is glued to them — tests must bring the real dependency or globally mock the module
When a service **imports** its database connection directly, it's tightly coupled to that exact implementation. Testing means either spinning up a real database (slow, stateful, hard to parallelize) or monkeypatching the module system to replace the import — a fragile approach that depends on module-loader internals. The module also can't be reused with a different database, a different connection pool, or a test double without code changes. This coupling is the problem DI solves: instead of the service *reaching out* to grab its dependency, the dependency is *handed in* from the outside.
Constructor injection — the clean solution

TS
// ✅ Dependency is PASSED IN — the service is decoupled from the implementation:
export class UsersService {
  constructor(private readonly db: Db) {}   // interface, not a concrete import

  async findById(id: string) {
    return this.db.query('SELECT * FROM users WHERE id = $1', [id])
  }
}

// Production — wire it together in your composition root:
const usersService = new UsersService(realDb)

// Test — pass a fake, no module patching:
const fakeDb = { query: vi.fn().mockResolvedValue({ rows: [{ id: '1', name: 'Ada' }] }) }
const service = new UsersService(fakeDb)
const user = await service.findById('1')
expect(user.rows[0].name).toBe('Ada')
Pass dependencies as constructor arguments — tests inject fakes, production injects the real implementation, the service never knows
**Constructor injection** is the most common DI form in Node: declare a dependency as a constructor parameter (typed against an interface, not a concrete class) and pass the real or fake implementation at instantiation. The service becomes **implementation-agnostic**: it works identically with the real Postgres pool, an in-memory fake, or a Vitest mock — whatever is passed in. Tests no longer need a running database and run in milliseconds. The concrete class never changes when the implementation changes. And the dependency graph is **explicit and readable**: you can trace from `new UsersService(db)` to `new OrdersService(db, usersService)` and see exactly how the application is wired together.
Functional injection with factory functions

TS
// Functional DI — factory that closes over its deps; returns a service object:
export function createUsersService(db: Db, mailer: Mailer) {
  return {
    async findById(id: string) {
      return db.query('SELECT * FROM users WHERE id = $1', [id])
    },
    async create(dto: CreateUserDto) {
      const user = await db.query('INSERT INTO users ...')
      await mailer.send(user.email, 'Welcome!')
      return user
    },
  }
}

// Usage — same composition pattern, no class syntax needed:
const usersService = createUsersService(db, mailer)
Factory functions are idiomatic functional DI — no class syntax, same testability, and they feel natural in Node's module system
Classes aren't required for DI. The **factory function** pattern closes over injected dependencies (captured in the closure) and returns a plain object with methods. The result is identical in terms of coupling and testability — you pass `fakeDb` and `fakeMail` to the factory in tests — but uses functional style that many Node developers prefer. It avoids the verbosity of `constructor(private readonly db: Db)` and the occasional TypeScript friction with class generics. Both approaches work; choose the one that matches your codebase's conventions. Either way, the key rule is the same: **never import a side-effectful dependency directly inside a module** — accept it as a parameter.
Manual wiring vs DI containers

Approach

When to use

Trade-off

Manual wiring (constructor/factory)

Most Node apps — explicit, readable, no magic

Verbose composition root for large graphs

DI containers (InversifyJS, tsyringe)

Large apps with deep dependency graphs

Setup complexity, decorators, "magic" resolution

Frameworks with built-in DI (NestJS)

Teams wanting a full opinionated framework

Significant NestJS-specific learning curve

Manual wiring is the right default for most Node apps — reach for a DI container only when the composition root becomes unmanageable
Many developers reaching for DI in Node immediately look for an Angular-style IoC container. For most projects, **manual wiring** — a `bootstrap.ts` or similar composition root that instantiates all services and wires them together — is clearer and less magical than a container. You can read it top to bottom and see the whole dependency graph. DI containers (InversifyJS, tsyringe) add automatic dependency resolution via decorators and tokens, which helps when the graph is very deep or highly dynamic, but adds learning overhead, metadata reflection requirements, and a build step. **NestJS** takes this further with a full framework built around DI. Reach for a container when the manual composition root is genuinely unwieldy (dozens of services, complex scoping) — not as a default for a five-service app.
Next
Patterns that solve recurring design problems: [Common Design Patterns](/nodejs/design-patterns).