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.
Table of Contents
- 1. What Dependency Injection Really Solves
- 2. Constructor Injection: the Simplest Mechanism
- 3. A Minimal DI Container From Scratch
- 4. Singleton vs. Transient: Managing Lifecycles
- 5. Interfaces in JavaScript: Duck Typing and Tokens
- 6. Detecting and Resolving Circular Dependencies
- 7. Dependency Injection in Test Environments
- 8. Dependency Injection Without a Container
- 9. DI Approaches Compared
- 10. Summary
- 11. FAQ
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.