Dependency Injection in Vanilla JavaScript: Loose Coupling Without a Framework
AI generated
JS
() =>
JavaScript · Dependency Injection · Inversion of Control · Framework agnostic
Dependency Injection in Vanilla JavaScript
Loose Coupling Without a Framework

Dependency injection reverses control over how dependencies are created, instead of letting every class instantiate its own collaborators. With no Angular or NestJS in sight, this article shows how constructor injection, a minimal DI container and clean lifecycle management enable loose coupling and easy testability in plain JavaScript.

19 min read Constructor Injection · DI Container · Singleton · Mocking Framework independent

1. What Dependency Injection Really Solves

Dependency injection reverses control over how dependencies get created: instead of a class instantiating its own collaborators with new, it receives them passed in from outside. This principle, known as inversion of control, decouples a class from the concrete implementations of its dependencies, making it independent of details like a specific database connection or a particular HTTP client.

Without dependency injection, knowledge of concrete implementations spreads across the entire codebase: every class that needs a logger imports and instantiates it itself, which turns swapping the logger implementation, for example for tests, into a tedious search across many files. With dependency injection, the logger is passed in from outside, and a test can plug in a mock logger without changing the code of the class under test.

Unlike in many Java or C# ecosystems, dependency injection in JavaScript is rarely baked into a framework like Angular or NestJS, but can be rebuilt as a standalone pattern in any codebase. This article walks through building constructor injection, a minimal DI container, lifecycle management and testability, all completely framework independent.

2. Constructor Injection: the Simplest Mechanism

Constructor injection is the simplest form of dependency injection: a class declares its dependencies as constructor parameters instead of creating them internally. The caller instantiating the class passes in concrete implementations of those dependencies. This simple reversal makes the class itself testable, because a test can plug in any implementation, such as mocks or stubs, at exactly the spot where production wiring passes in real implementations.

An important detail in constructor injection: the class should only depend on abstractions, not concrete implementations, a principle from SOLID known as dependency inversion. In JavaScript without interfaces, that practically means a class only uses the methods it actually needs, instead of expecting a concrete class as its type. This discipline makes dependency injection effective even without a formal type system.


// Constructor injection: dependencies passed in, not created internally
class OrderService {
  #logger;
  #paymentGateway;

  constructor(logger, paymentGateway) {
    this.#logger = logger;
    this.#paymentGateway = paymentGateway;
  }

  async placeOrder(order) {
    this.#logger.info(`Placing order ${order.id}`);
    await this.#paymentGateway.charge(order.total);
  }
}

// Production wiring
const service = new OrderService(consoleLogger, stripeGateway);

// Test wiring, no changes to OrderService itself needed
const testService = new OrderService(fakeLogger, fakePaymentGateway);

3. A Minimal DI Container From Scratch

As the number of dependencies grows, manual wiring, assembling all constructor arguments in one central place, quickly becomes unwieldy. A DI container automates this wiring: it manages a registry of factory functions under named tokens and resolves dependencies recursively when a service is requested. The container itself is just a simple object with a register() and a resolve() method.

The decisive advantage of a DI container over manual wiring: new dependencies are registered in one place rather than adjusted at every spot in the code where the affected class is instantiated. This central registry keeps dependency injection maintainable even in large codebases with hundreds of services, without every dependency change triggering cascades of adjustments elsewhere.


// Minimal DI container: registry of factories, resolved recursively
class Container {
  #factories = new Map();

  register(token, factory) {
    this.#factories.set(token, factory);
  }

  resolve(token) {
    const factory = this.#factories.get(token);
    if (!factory) throw new Error(`No registration found for "${token}"`);
    return factory(this); // pass the container so factories can resolve their own deps
  }
}

const container = new Container();
container.register('logger', () => new ConsoleLogger());
container.register('paymentGateway', () => new StripeGateway());
container.register('orderService', (c) =>
  new OrderService(c.resolve('logger'), c.resolve('paymentGateway'))
);

const orderService = container.resolve('orderService');

4. Singleton vs. Transient: Managing Lifecycles

Not every dependency should be recreated on every resolution: a database connection typically should exist as a singleton, a single instance reused across the entire application lifetime, while a request-specific object needs to be freshly created as transient on every resolution. A mature DI container distinguishes these two lifecycles explicitly at registration time.

Implementing a singleton lifecycle caches the factory function's result on the first call and returns the same instance on every subsequent request, instead of re-running the factory. Transient registrations invoke the factory anew on every resolve() call. This distinction is crucial for dependency injection with stateful services, a shared cache service should exist as a singleton, whereas a form validator often should be transient, to avoid sharing state between independent calls.


// Container with explicit singleton vs. transient lifecycles
class LifecycleContainer {
  #registrations = new Map();
  #singletonInstances = new Map();

  registerSingleton(token, factory) {
    this.#registrations.set(token, { factory, lifecycle: 'singleton' });
  }

  registerTransient(token, factory) {
    this.#registrations.set(token, { factory, lifecycle: 'transient' });
  }

  resolve(token) {
    const registration = this.#registrations.get(token);
    if (!registration) throw new Error(`No registration for "${token}"`);

    if (registration.lifecycle === 'singleton') {
      if (!this.#singletonInstances.has(token)) {
        this.#singletonInstances.set(token, registration.factory(this));
      }
      return this.#singletonInstances.get(token);
    }
    return registration.factory(this); // transient: always fresh
  }
}

5. Interfaces in JavaScript: Duck Typing and Tokens

JavaScript has no native interfaces, which makes dependency injection without TypeScript look harder at first glance, because classic registration by interface type is not possible. In practice, duck typing works as a pragmatic substitute: a dependency only needs to provide the methods actually used, regardless of its class lineage. A test double only needs to implement charge() to work as a payment gateway, with no formal interface declaration required.

Two approaches have become established for registration itself: string-based tokens like 'paymentGateway' are simple but prone to typos and name collisions. Symbol-based tokens, such as Symbol('PaymentGateway'), guarantee collision freedom through the uniqueness of symbols, even when two modules choose the same descriptive name. For larger applications with many registrations, the symbol variant is the more robust choice for dependency injection without TypeScript.

6. Detecting and Resolving Circular Dependencies

A circular dependency arises when service A needs service B, which in turn needs service A, a situation a naive DI container answers with infinite recursion and a stack overflow. A robust container detects this case by marking a token as "in progress" in a temporary set during its resolution and throwing a clear error when the same token is encountered again, instead of recursing endlessly.

The more sustainable solution, however, is architectural: a circular dependency between two services usually points to an unclean split of responsibilities that can be resolved by extracting a third, shared abstraction. Dependency injection makes such circularities visible, whereas in manually wired code they would often only surface at runtime as a hard-to-trace bug, instead of as a clear error already at container setup.


// Circular dependency detection during resolution
class SafeContainer {
  #factories = new Map();
  #resolving = new Set(); // tracks tokens currently being resolved

  register(token, factory) {
    this.#factories.set(token, factory);
  }

  resolve(token) {
    if (this.#resolving.has(token)) {
      throw new Error(`Circular dependency detected while resolving "${token}"`);
    }
    const factory = this.#factories.get(token);
    if (!factory) throw new Error(`No registration for "${token}"`);

    this.#resolving.add(token);
    try {
      return factory(this);
    } finally {
      this.#resolving.delete(token);
    }
  }
}

7. Dependency Injection in Test Environments

The greatest practical benefit of dependency injection shows up in testing: a class whose dependencies come through the constructor can be tested in isolation by passing in mocks or stubs instead of real implementations, without needing module-mocking tricks like jest.mock() at the module level. A test for OrderService passes in a fake logger that records calls, and a fake payment gateway that always responds successfully, with no real network connection involved.

This testability is not a side effect but the actual architectural payoff of dependency injection: code that is hard to test usually betrays a hidden, hardwired dependency that would be better injected through the constructor. Teams that consistently apply dependency injection regularly report noticeably cleaner unit tests, because every dependency is explicitly visible and swappable, instead of implicitly buried somewhere in the class body.


// Testing with injected fakes, no module mocking required
class FakeLogger {
  entries = [];
  info(message) { this.entries.push(message); }
}

class FakePaymentGateway {
  charged = [];
  async charge(amount) { this.charged.push(amount); }
}

// Unit test, no real network calls, no jest.mock() needed
const fakeLogger = new FakeLogger();
const fakeGateway = new FakePaymentGateway();
const service = new OrderService(fakeLogger, fakeGateway);

await service.placeOrder({ id: 1, total: 49.99 });

console.assert(fakeGateway.charged[0] === 49.99, 'Charge amount mismatch');
console.assert(fakeLogger.entries.length === 1, 'Logger should record one entry');

8. Dependency Injection Without a Container

Not every application needs a full DI container: for small to medium codebases, plain manual wiring at a central composition root, a single spot in the code where all dependencies are assembled by hand, is often enough. This approach keeps the core benefit of dependency injection, loose coupling and testability, without the added complexity of a generic container framework.

Factory functions and higher-order functions offer a functional alternative to class-based dependency injection: instead of a constructor, a factory function accepts dependencies as parameters and returns an object with bound methods, a pattern common in functional JavaScript that works entirely without class syntax. Both approaches, a composition root and factory functions, are often the more pragmatic choice for smaller projects than a full DI container.

9. DI Approaches Compared

The choice between a DI container, a service locator and manual wiring depends on project size and team conventions, with a service locator undoing some of the benefits of dependency injection by pulling dependencies from a global registry instead of through the constructor.

Approach Dependency Explicitness Testability Best Fit
Manual wiring Fully explicit Very good Small to medium codebases
DI container Explicit via registry Very good Large codebases, many services
Service locator Hidden inside class body Worse Usually best avoided
Factory functions Explicit via parameters Very good Functional code without classes

A service locator, where a class fetches its own dependencies from a global registry instead of receiving them through the constructor, is frequently described in the literature as an anti-pattern compared to true dependency injection, because the dependencies then stay hidden inside the class body instead of being visible in the constructor signature. For most JavaScript projects, genuine constructor-based dependency injection, with or without a container, remains the more sustainable choice.

Mironsoft

Architecture refactoring, testability and loose coupling in backend and frontend

Code that can't be tested because everything is hardwired?

We refactor tightly coupled code toward dependency injection, with clear constructor signatures, a fitting lifecycle model and noticeably simpler unit tests.

Coupling audit

Analysis of existing classes for hardwired, hard-to-test dependencies

DI rollout

Moving to constructor injection, with or without a custom DI container

Testability

Building unit tests with fakes and mocks instead of module-mocking hacks

10. Summary

Dependency injection reverses how dependencies get created: classes declare what they need instead of creating it themselves, and receive it through the constructor. Constructor injection is the simplest entry point, a minimal DI container automates wiring as the number of services grows, and distinguishing singleton from transient lifecycles prevents unnecessarily shared or unnecessarily recreated state.

The greatest practical payoff shows up in testing: injected dependencies can be replaced with fakes without module-mocking hacks, which makes unit tests noticeably simpler and faster. Whether implemented with a full container, a central composition root, or functional factory functions, dependency injection remains a standalone, framework-independent architectural pattern in JavaScript that structurally enforces loose coupling.

Dependency Injection in Vanilla JavaScript — Key Takeaways

Constructor Injection

Dependencies arrive as constructor parameters, not through internal new.

DI Container

A registry of factory functions, resolving dependencies recursively via resolve().

Lifecycle

Singleton for shared state, transient for fresh instances on every resolution.

Testability

Injected fakes replace real implementations, with no module mocking needed.

11. FAQ: Dependency Injection in Vanilla JavaScript

1What is dependency injection?
Dependencies are passed in from outside instead of being created internally, usually via the constructor.
2Do you need a framework for it?
No, constructor injection works with plain JavaScript.
3Singleton vs. transient?
Singleton is reused, transient is recreated on every request.
4Interfaces without TypeScript?
Through duck typing, only used methods need to exist.
5Detecting circular dependencies?
Via a set of tokens currently being resolved that throws an error on repeat.
6Why is it easier to test?
Dependencies can be replaced with fakes, without module mocking.
7Service locator = dependency injection?
No, a service locator hides dependencies inside the class body.
8Does every app need a container?
No, manual wiring at a composition root is often enough.
9What are factory functions?
A functional alternative, dependencies as parameters instead of a constructor.
10Worth it in small projects?
Yes, at least constructor injection without a container.