Skip to the solution
HardLow-Level Design7 min read

Explain 'dependency injection' and how it can be used in Node.js.

443 views

The problem

Question presented to candidate: "A class you are testing calls new EmailService() directly inside its constructor. Why does that make it hard to unit test, and what pattern fixes it?"

What a strong answer should cover:

  • Dependency injection (DI) means a class receives its collaborators ("dependencies") from outside itself β€” typically via its constructor β€” rather than creating them itself internally. The class depends on an interface/shape, not a specific concrete implementation it constructs.
  • πŸ“Œ The concrete, verifiable benefit: because the dependency is supplied externally, a test double (a fake/mock implementation) can be substituted with zero changes to the class under test β€” verified directly: the identical class, unmodified, worked correctly wired to a real service in one run and a fake, call-recording service in another.
  • Node.js has no built-in, framework-level DI container the way some other ecosystems do (Angular, Spring, .NET) β€” DI in Node is most commonly just plain constructor parameters, sometimes formalized further with a small DI library (awilix, inversify) or a framework's own convention (NestJS's decorator-based DI being the most prominent Node example).
  • The core problem DI solves is tight coupling: a class that instantiates its own dependencies is bound to that specific concrete implementation at every call site, making substitution (for tests, or for swapping a real implementation for a different one in a different environment) require modifying the class itself rather than just the wiring that constructs it.
  • A precise answer distinguishes DI (a pattern β€” receiving dependencies from outside) from a DI container/framework (a tool that automates the wiring) β€” DI itself needs no special library at all, as demonstrated with plain constructor parameters; a container becomes more valuable specifically as the dependency graph grows large and manual wiring becomes tedious.
  • The trade-off worth naming: DI adds a layer of indirection and an explicit "wiring" step (something has to actually construct and pass in the real dependencies at the application's entry point) β€” for a very small application, this can be more ceremony than the tight-coupling problem it solves actually costs.

Clarifying questions expected:

  • "Is the goal specifically testability, or something broader like swapping implementations across environments?" β€” both are real motivations, but the emphasis differs.
  • "Is a DI container/framework already in use (NestJS, awilix), or is plain constructor injection sufficient here?" β€” decides how much extra tooling the answer should reach for.

Code / implementation expected: Yes β€” the same class wired to a real dependency and a test double, unmodified, is the concrete, convincing demonstration of the actual benefit.

  • design patterns
  • dependency injection
  • testing
  • modularity

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

7 min read Β· 9 sections

Read the code

JavaScriptThe same, completely unmodified class wired to a real service and a fake test double via constructor injection
class RealEmailService {
  send(to, msg) { console.log("REAL: sending email to", to); return "sent-for-real"; }
}
class FakeEmailService {
  constructor() { this.sent = []; }
  send(to, msg) { this.sent.push({ to, msg }); return "sent-fake"; }
}

class UserSignup {
  constructor(emailService) { this.emailService = emailService; } // injected, not created internally
  register(email) { return this.emailService.send(email, "Welcome!"); }
}

// Production wiring:
const prodSignup = new UserSignup(new RealEmailService());
console.log(prodSignup.register("a@example.com"));
// REAL: sending email to a@example.com
// prod result: sent-for-real

// Test wiring β€” the SAME UserSignup class, completely unmodified:
const fake = new FakeEmailService();
const testSignup = new UserSignup(fake);
testSignup.register("test@example.com");
console.log(JSON.stringify(fake.sent));
// [{"to":"test@example.com","msg":"Welcome!"}]  <- recorded, never actually sent

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 138 of 152 in Node.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Node.js questions
Up nextHard

Why does a containerized Node.js process need special signal handling as PID 1?

Continue