Why JavaScript Deserves Dependency Injection
The evolution of JavaScript from client-side to server-side has invalidated many reasons for avoiding Dependency Injection. This post explores how Dependency Injection can be used in JavaScript and highlights its advantages beyond testing.
In my previous post, I discussed Dependency Injection (DI) and how it improves overall developer experience (DX).
This post focuses on JavaScript developers and addresses some of the most common reasons for avoiding DI.
Dependency Injection isn’t a new concept. It has been widely used in ecosystems like Java, .NET, PHP, and others. It is not magic—it is simply a way to organize code so that dependencies are provided externally rather than hardcoded internally.
Starting from the Beginning
JavaScript began as a frontend language for browsers, where the code needed to be transferred over the network. In that context, there were valid reasons to avoid DI frameworks:
- Bundle size matters: A centralized DI container can increase code size, and workarounds to avoid this are often complex.
- Bundle chunking/tree shaking: Splitting dependencies becomes more challenging.
- Minification issues: DI metadata (such as variable names) can be removed during minification, breaking reflection-based DI frameworks.
- Immature decorators: JavaScript decorators are not yet fully mature, making them harder to use for DI compared to annotations in languages like Java or PHP.
These constraints led JavaScript developers to adopt a mindset of keeping things as simple as possible.
Enter Node.js
With the advent of Node.js, JavaScript expanded to server-side applications, enabling services, APIs, and background jobs.
On the server, the constraints changed:
- No network concerns: Bundle size is no longer a critical issue.
- No need for minification: Code does not need to be minified for server-side applications.
- Cheap resources: Disk space and RAM are inexpensive.
The valid reasons for avoiding DI in browser-based JavaScript disappeared in the server context. However, the mindset of "keeping things simple" persisted.
The Myth of the "Simple" Codebase
What happens in these “simple” codebases? For small libraries or single-endpoint services, this approach may work. But as the codebase grows to 10k lines, 50k lines, or more, the simplicity fades.
Large JavaScript projects often consist of tens or hundreds of files that manually import the same services, wiring dependencies between functions/classes in inconsistent ways. Global patches are applied because there’s no clean way to swap dependencies during testing. Suddenly, 30% of your code is not business logic — it’s wiring.
When changes are needed, such as adding a dependency to an existing function/class, developers must hunt down every instance where it is directly used. How is that simple? Onboarding new developers becomes a challenge.
JavaScript with and without DI
JavaScript supports multiple paradigms, including functional, object-oriented, and procedural styles. When using an object-oriented style, JavaScript’s classes and decorators can implement Dependency Injection in a way similar to what is done in Java or PHP.
Let’s consider a simple example of a service that reads a configuration file and returns a server URL.
Without DI
A plain TypeScript implementation without DI might look like this:
// server-url-provider.ts
import fs from 'fs';
export function getServerURL(fileName: string): string {
const json = JSON.parse(fs.readFileSync(fileName, 'utf-8'));
return json.url;
}
// main.ts (the main entry point of the application)
import { getServerURL } from './server-url-provider';
const serverURL = getServerURL('config.json');
This seems simple, but:
- How can you test
getServerURL
without creating a physical file to read? - What if you need to include the currently logged-in user in the
getServerURL
function to provide a different path based on the user?
For problem #1, over time JavaScript has developed various patching techniques to test some "hard-to-test" code using various mocking libraries and relying on global state. Most of them wouldn't be needed if Dependency Injection was more common in JavaScript.
This example shows how to mock the fetch
API using Jest.
It works but overrides the global fetch
function, which can lead to unexpected behavior in other parts of the codebase.
For problem #2, you might try to add a second argument to the getServerURL
function to pass the user information:
import fs from 'fs';
export function getServerURL(fileName: string, userId: string): string {
const json = JSON.parse(fs.readFileSync(`users/${userId}/` + fileName, 'utf-8'));
return json.url;
}
This approach has drawbacks: getServerURL
might be used in dozens of places,
and you would need to update all of them to pass the user information.
Alternatively, you might try to use a global state to get the user information, like this:
import fs from 'fs';
import session from 'app/session';
export function getServerURL(fileName: string): string {
const json = JSON.parse(fs.readFileSync(`users/${session.getUser().id}/` + fileName, 'utf-8'));
return json.url;
}
While this approach seems better, it introduces a global state dependency (session
), making the code harder to test and reason about.
Another example: what happens if there is no session because the code is running a background job?
The getServerURL
function becomes tightly coupled to the session management, making it less reusable in contexts where sessions are absent.
With DI
In this example, I will use InversifyJS DI framework to show how you can achieve better modularity and testability.
We define a Reader
interface that abstracts the file reading functionality.
// reader.ts
export interface Reader {
read(fileName: string): string;
}
Then we implement a ServerUrlProvider
class that uses this interface to read the data and return it as a string.
// server-url-provider.ts
import { Reader } from './reader';
import { inject, injectable } from 'inversify';
@injectable()
export class ServerUrlProvider {
constructor(@inject('Reader') private fileReader: Reader) {
}
getServerURL(fileName: string): string {
const json = JSON.parse(this.fileReader.read(fileName));
return json.url;
}
}
You can see that the ServerUrlProvider
class depends on a Reader
interface
but is totally unaware of the actual implementation of that interface.
Note the use of decorators from InversifyJS:
- The
@injectable()
decorator marks the class as injectable, allowing it to be used in the DI container. - The
@inject()
decorator specifies that thefileReader
dependency should be injected into the constructor, searching in the DI container for a dependency registered under the nameReader
.
// file-system-reader.ts
import fs from 'fs';
import { injectable } from 'inversify';
@injectable()
export class FileSystemReader implements Reader {
read(fileName: string): string {
return fs.readFileSync(fileName, 'utf-8');
}
}
This is just one possible implementation of the Reader
interface. You can have multiple implementations,
such as reading from a database, an API, or even a mock file system.
Then we can define a Dependency Injection container to manage our dependencies:
// di.ts (Dependency Injection Container definition)
import { Container } from 'inversify';
import { ServerUrlProvider } from './server-url-provider';
import { Reader } from './reader';
import { FileSystemReader } from './file-system-reader';
const container: Container = new Container();
container.bind<Reader>('Reader').to(FileSystemReader);
container.bind<ServerUrlProvider>('ServerUrlProvider').to(ServerUrlProvider);
export default container;
This part is where the all the available services are defined. It is important to notice that at this point we are not worrying about resolving the dependencies; we are just defining them. The DI container will take care of invoking the constructors and passing the needed dependencies them when needed. Order of definition does not matter either.
If you have circular dependencies, there are ways to resolve them, but I would recommend avoiding them as much as possible.
JavaScript DI frameworks like microsoft/tsyringe do not require this last step, but they do not fully support interfaces (because of the way TypeScript is translated into JavaScript).
Finally, we can use the ServerUrlProvider
in our main application code:
// main.ts
import 'reflect-metadata';
import container from './di';
const serverUrlProvider: ServerUrlProvider = container.get('ServerUrlProvider');
const serverURL = serverUrlProvider.getServerURL('config.json');
This version may seem verbose, but it shines when complexity increases.
Testing
Testing the ServerUrlProvider
is straightforward. You can create a mock Reader
implementation that returns predefined JSON data.
const mockReader = new class implements Reader {
read(fileName: string): string {
return '{"url": "http://localhost:3000"}'; // Mocked response
}
}();
const provider = new ServerUrlProvider(mockReader);
const serverURL = provider.getServerURL('config.json');
expect(serverURL).toBe('http://localhost:3000');
As you can see, the test does not depend on any file system operations, nor does it need complex mocking libraries.
Adding New Dependencies
If you need the currently logged-in user, you can simply inject a CurrentUserProvider
service.
You do not need to worry about finding all the places where ServerUrlProvider
was used,
nor do you need to worry about the dependencies needed for the CurrentUserProvider
service.
Adding a new dependency to the ServerUrlProvider
is as simple as:
// server-url-provider.ts
@injectable()
export class ServerUrlProvider {
constructor(
@inject('Reader') private fileReader: Reader,
@inject('CurrentUserProvider') private currentUserProvider: CurrentUserProvider,
) {}
}
If CurrentUserProvider
is already registered in the DI container, no additional changes are needed.
No need to worry about finding all the places where ServerUrlProvider
was used.
Of course, if we did not already have CurrentUserProvider
, we would need to implement and register it, but that
would have been done anyway, even if we were not using DI.
With DI, we can have different implementations of the CurrentUserProvider
service based on the context (web or background job),
and our ServerUrlProvider
does not need to know about it.
container.bind<CurrentUserProvider>('CurrentUserProvider').to(
backgroundJob
? ConsoleCurrentUserProvider
: SessionCurrentUserProvider
);
DX (developer experience)
As I mentioned in my previous post, an advantage of Dependency Injection is that as a developer, you do not need to worry if a service "can get the dependencies it needs". Questions as "Can I use this service in this context?" or "Does the parent scope/function have all the dependencies it needs this service?"
When the service is registered in the DI container (in our example CurrentUserProvider
),
it is guaranteed that any service requesting it with the@inject
decorator, will be able to obtain it when declared as constructor argument.
Conclusion
Dependency Injection is a powerful design pattern that significantly improves the maintainability, testability, and scalability of JavaScript applications. It is especially valuable as codebases grow in size and complexity.
Even languages not traditionally associated with DI, such as Go (1) or Rust (2), are adopting it to manage complexity.
If you are using frameworks like NestJS or Angular, you are likely already using DI. For custom applications, consider using frameworks like InversifyJS or microsoft/tsyringe.
In my previous post, I mentioned some of the DI benefits regarding developer experience.
Looking Ahead
While this post focuses on DI in an object-oriented style, JavaScript also supports functional programming. In my next post, I will explore how DI can be applied in a functional programming style (using Partial function application in Javascript.