Skip to solution
hardLow-Level Design

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

443 views
01

Understand 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 patternsdependency injectiontestingmodularity
02

Attempt it yourself

Sketch your approach before reading the solution — that's what interviews test.

Nudge consolestandby

Stuck? Beam a request up — the console returns a conceptual nudge that guides your logic without spoiling the implementation.

03

Study the solution

Target Audience: Engineers preparing for Node.js interviews — assumes basic class/constructor familiarity, no prior DI-framework experience required. Difficulty: Hard

How to read this doc: Concepts are explained in plain language first, then tagged with 📌 Interview term:. The class-swapping

Solution ready — 2 min read

Classified // press E to declassify

04

Read the code

The 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
05

Join the discussion

Discussion (0)

Sign in to join the discussion.

No responses yet. Be the first to share what you think.

Transmission complete // awaiting log

KEEP THE
STREAK ALIVE.

Dossier 138 of 152 decoded in the Node.js track. One more won't hurt.

Back to track