JavaScript Interview Prep
Design Patterns

Dependency Injection

Push, Don't Pull

LinkedIn Hook

A class that secretly calls new PostgresDatabase() inside its constructor is a class you cannot unit test.

You cannot swap in a mock. You cannot run your tests offline. You cannot validate business logic without a real database, a real log file, and a real SMTP server. That is not just inconvenient — it is the single biggest reason "legacy" codebases feel untestable.

Dependency Injection flips the direction: instead of a class reaching out to grab what it needs, the dependencies are handed in from outside. Constructor injection for classes, parameter injection for functions, and when the graph grows too big to wire by hand — a DI container that resolves the whole tree automatically.

In this lesson you will build a working DI container (with circular-dependency detection), compare DI against the Service Locator anti-pattern, and see exactly why this one pattern unlocks fast, reliable unit tests.

Read the full lesson -> [link]

#JavaScript #DependencyInjection #DI #DesignPatterns #UnitTesting #InterviewPrep #SoftwareArchitecture


Dependency Injection thumbnail


What You'll Learn

  • Why hard-coded dependencies kill testability, and how constructor/parameter injection fix it
  • How to build a DI container that resolves transitive dependencies and detects circular ones
  • The difference between DI and the Service Locator, and why interviewers prefer the former

The Assembly Line Analogy

Imagine you're building a car. Instead of the car manufacturing its own engine inside the factory, the engine is built separately and injected into the car during assembly. If you want to test the car with a different engine (say, an electric one), you just inject a different engine — no need to rebuild the car. That's Dependency Injection: instead of a class creating its own dependencies, they are provided (injected) from outside.

The Problem DI Solves

// WITHOUT Dependency Injection — tightly coupled
class UserService {
  constructor() {
    // Hard-coded dependency — impossible to test without real DB
    this.database = new PostgresDatabase("postgres://prod-server:5432/users");
    this.logger = new FileLogger("/var/log/users.log");
    this.emailer = new SMTPEmailer("smtp://mail.company.com");
  }

  async createUser(name, email) {
    const user = { name, email, id: Date.now() };
    await this.database.insert("users", user);
    this.logger.log(`User created: ${name}`);
    await this.emailer.send(email, "Welcome!", "Thanks for signing up.");
    return user;
  }
}

// Testing this is a NIGHTMARE:
// - Needs a running Postgres server
// - Writes to a real log file
// - Sends a real email
// There's no way to mock these dependencies!

Constructor Injection

// WITH Dependency Injection — loosely coupled
class UserService {
  constructor(database, logger, emailer) {
    this.database = database;
    this.logger = logger;
    this.emailer = emailer;
  }

  async createUser(name, email) {
    const user = { name, email, id: Date.now() };
    await this.database.insert("users", user);
    this.logger.log(`User created: ${name}`);
    await this.emailer.send(email, "Welcome!", "Thanks for signing up.");
    return user;
  }
}

// Production — inject real implementations
const userService = new UserService(
  new PostgresDatabase("postgres://prod-server:5432/users"),
  new FileLogger("/var/log/users.log"),
  new SMTPEmailer("smtp://mail.company.com")
);

// Testing — inject mocks!
const mockDb = {
  users: [],
  async insert(table, record) { this.users.push(record); }
};
const mockLogger = { logs: [], log(msg) { this.logs.push(msg); } };
const mockEmailer = { sent: [], async send(to, subject, body) { this.sent.push({ to, subject, body }); } };

const testService = new UserService(mockDb, mockLogger, mockEmailer);
await testService.createUser("Test", "test@example.com");

console.log(mockDb.users.length);       // 1
console.log(mockLogger.logs[0]);         // "User created: Test"
console.log(mockEmailer.sent[0].to);     // "test@example.com"
// No real DB, no real files, no real emails!

Parameter Injection

// Inject dependencies as function parameters (functional style)
function createUserHandler(database, logger) {
  return async function handleCreateUser(req, res) {
    const { name, email } = req.body;
    const user = { name, email, id: Date.now() };

    try {
      await database.insert("users", user);
      logger.log(`Created user: ${name}`);
      res.status(201).json(user);
    } catch (err) {
      logger.error(`Failed to create user: ${err.message}`);
      res.status(500).json({ error: "Internal server error" });
    }
  };
}

// Wire up in your app
const handler = createUserHandler(realDatabase, realLogger);
app.post("/users", handler);

// Wire up in tests
const testHandler = createUserHandler(mockDatabase, mockLogger);

Simple DI Container

class DIContainer {
  constructor() {
    this._services = new Map();    // name -> { factory, singleton, instance }
    this._resolving = new Set();   // circular dependency detection
  }

  // Register a service
  register(name, factory, { singleton = true } = {}) {
    this._services.set(name, {
      factory,
      singleton,
      instance: null
    });
    return this;
  }

  // Resolve a service (with automatic dependency resolution)
  resolve(name) {
    const service = this._services.get(name);

    if (!service) {
      throw new Error(`Service "${name}" is not registered.`);
    }

    // Return cached singleton instance if available
    if (service.singleton && service.instance) {
      return service.instance;
    }

    // Detect circular dependencies
    if (this._resolving.has(name)) {
      throw new Error(
        `Circular dependency detected: ${[...this._resolving, name].join(" -> ")}`
      );
    }

    this._resolving.add(name);

    try {
      // The factory receives the container so it can resolve its own dependencies
      const instance = service.factory(this);

      if (service.singleton) {
        service.instance = instance;
      }

      return instance;
    } finally {
      this._resolving.delete(name);
    }
  }

  // Check if a service is registered
  has(name) {
    return this._services.has(name);
  }

  // Remove a service
  remove(name) {
    this._services.delete(name);
  }

  // Reset all singleton instances (useful for testing)
  reset() {
    for (const service of this._services.values()) {
      service.instance = null;
    }
  }
}

// Usage
const container = new DIContainer();

// Register services — each factory receives the container
container.register("config", () => ({
  dbUrl: "postgres://localhost:5432/mydb",
  logLevel: "info",
  smtpHost: "smtp://localhost"
}));

container.register("logger", (c) => {
  const config = c.resolve("config");
  return {
    log(msg) { console.log(`[${config.logLevel}] ${msg}`); },
    error(msg) { console.error(`[ERROR] ${msg}`); }
  };
});

container.register("database", (c) => {
  const config = c.resolve("config");
  const logger = c.resolve("logger");
  logger.log(`Database connecting to ${config.dbUrl}`);
  return {
    async query(sql) {
      logger.log(`Query: ${sql}`);
      return [];
    }
  };
});

container.register("userService", (c) => {
  const db = c.resolve("database");
  const logger = c.resolve("logger");
  return {
    async getUser(id) {
      logger.log(`Fetching user ${id}`);
      return db.query(`SELECT * FROM users WHERE id = ${id}`);
    }
  };
});

// Resolve — dependencies are automatically injected
const userService = container.resolve("userService");
await userService.getUser(42);
// [info] Database connecting to postgres://localhost:5432/mydb
// [info] Fetching user 42
// [info] Query: SELECT * FROM users WHERE id = 42

// For testing — register mock versions
const testContainer = new DIContainer();
testContainer.register("config", () => ({ dbUrl: "memory", logLevel: "silent", smtpHost: "" }));
testContainer.register("logger", () => ({ log() {}, error() {} }));
testContainer.register("database", () => ({
  async query() { return [{ id: 42, name: "Mock User" }]; }
}));
testContainer.register("userService", (c) => {
  const db = c.resolve("database");
  const logger = c.resolve("logger");
  return {
    async getUser(id) {
      logger.log(`Fetching user ${id}`);
      return db.query(`SELECT * FROM users WHERE id = ${id}`);
    }
  };
});

const mockUserService = testContainer.resolve("userService");

DI vs Service Locator

// Service Locator — similar idea, different access pattern
class ServiceLocator {
  static services = new Map();

  static register(name, instance) {
    ServiceLocator.services.set(name, instance);
  }

  static get(name) {
    if (!ServiceLocator.services.has(name)) {
      throw new Error(`Service "${name}" not found.`);
    }
    return ServiceLocator.services.get(name);
  }
}

// With Service Locator — class reaches OUT to find dependencies
class OrderService {
  processOrder(orderId) {
    const db = ServiceLocator.get("database");     // reaches out
    const logger = ServiceLocator.get("logger");   // reaches out
    // ...
  }
}

// With DI — dependencies are pushed IN
class OrderServiceDI {
  constructor(db, logger) {
    this.db = db;       // pushed in
    this.logger = logger; // pushed in
  }

  processOrder(orderId) {
    // uses this.db and this.logger — already available
  }
}
AspectDependency InjectionService Locator
Dependencies visible inConstructor signatureHidden inside methods
TestabilityEasy — inject mocks via constructorHarder — must register mocks on global locator
CouplingCoupled to interfaces onlyCoupled to the locator class
ReadabilityClear what a class needsMust read method bodies to discover deps
FlexibilityVery highMedium

When to Use / When NOT to Use

Use DI for:

  • Any class with external dependencies (database, API clients, loggers)
  • Code that needs to be unit-tested with mocks
  • Plugin systems or modular architectures
  • Large applications with many interconnected services

Avoid DI when:

  • Simple scripts with no dependencies to swap
  • Over-engineering small utilities
  • The DI container itself becomes more complex than the app

Dependency Injection visual 1


Common Mistakes

  • Passing a DI container into every class and calling container.resolve(...) inside methods — that is actually the Service Locator anti-pattern in disguise; keep explicit constructor injection at the boundaries.
  • Registering every service as a singleton without thinking — some services (per-request HTTP clients, per-user caches) must be transient, otherwise they share state across tenants.
  • Writing a DI container so elaborate it becomes a framework of its own; for small apps, plain constructor injection wired in one main.js is clearer and faster.

Interview Questions

Q: What is Dependency Injection and what problem does it solve?

Dependency Injection is a pattern where an object's dependencies are provided from the outside rather than created internally. It solves: 1) Testability — you can inject mocks instead of real implementations. 2) Loose coupling — classes depend on interfaces, not concrete implementations. 3) Flexibility — swap implementations without modifying the class. 4) Separation of concerns — creation logic is separate from business logic.

Q: What is the difference between DI and Service Locator?

With DI, dependencies are pushed into a class (usually via constructor). With Service Locator, the class reaches out to a global registry to pull its dependencies. DI is preferred because dependencies are explicit in the constructor signature, making the code more readable and testable.

Q: How would you implement a simple DI container?

A DI container is a registry that maps service names to factory functions. When you resolve a service, the container calls its factory, passing itself so the factory can resolve its own dependencies. The container can cache singleton instances and detect circular dependencies. (See the full implementation above.)

Q: What is the difference between constructor injection and parameter injection?

Constructor injection passes dependencies once, when the object is created, and stores them on this. Parameter injection passes dependencies on each function call (or via a closure-returning factory function). Constructor injection suits long-lived stateful services; parameter injection suits pure functions and HTTP handlers that only need the deps for the duration of a request.

Q: How does DI improve unit testing?

Because a class no longer reaches out to create its own dependencies, a test can hand it a fake — an in-memory database, a logger that records to an array, an emailer that just collects messages. Tests run in milliseconds, stay deterministic, and do not require real infrastructure.

Q: Name a scenario where you would NOT use each pattern.

Module Pattern — when you already have ES modules and a bundler. Singleton — when you need testability or multiple instances per tenant. Observer/PubSub — when two modules call each other directly with no fan-out. Factory — when the constructor is already simple. EventEmitter — when data flow is strictly synchronous and linear. DI — when the script has no external dependencies worth swapping.


Quick Reference — Cheat Sheet

DEPENDENCY INJECTION — QUICK MAP

Principle:
  Deps are pushed IN, not pulled OUT.

Styles:
  Constructor injection -> class UserService(db, log, mail)
  Parameter injection   -> createHandler(db, log) returns fn
  DI container          -> registry of name -> factory(container)

DI Container building blocks:
  register(name, factory, { singleton })
  resolve(name)   -> calls factory, caches if singleton
  _resolving Set  -> detects circular deps

DI vs Service Locator:
  DI     -> deps visible in constructor signature
  Locator-> class reaches out globally, hidden deps

Use when:
  - testability matters
  - swap real/mock implementations
  - large service graph

Avoid when:
  - tiny script with nothing to swap
  - container is more complex than the app

Previous: Event Emitter -> Production-Grade Observer Next: Memory Leaks -> Find Them Before Production


This is Lesson 12.6 of the JavaScript Interview Prep Course — 14 chapters, 87 lessons.

On this page