Dependency Injection in JavaScript: A Functional Approach
Learn how to apply Dependency Injection in JavaScript using a functional programming style. This approach improves testability, modularity, and developer experience by decoupling dependencies from function logic.
In my first post, I explained how Dependency Injection (DI) enhances developer experience. If you already know what DI is and you are not convinced that it is worth using in JavaScript, I really encourage you to read it first.
In the second post, I discussed why DI is uncommon in the JavaScript ecosystem and shown a possible implementation in TypeScript using an object-oriented approach.
This post builds on the second one and focuses on applying DI in JavaScript using a functional programming style.
Javascript Without DI
For simplicity, I’ll reuse the example from my previous post. Let’s consider a service that reads a configuration file and returns a server URL.
Here’s a basic TypeScript implementation without DI:
// 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;
}
// app.ts
import { getServerURL } from './server-url-provider';
const serverURL = getServerURL('config.json');
This looks simple, but it has issues:
- 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?
Possible solutions to problem #1
Over time, JavaScript has developed various patching techniques to test some "hard-to-test" code using mocking libraries and relying on global state. Most of them wouldn't be needed if Dependency Injection was more common in JavaScript.
We will see a complete example in a moment.
Possible solutions to problem #2
To provide additional dependencies,
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 one big drawback: 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.getUserId()} /` + fileName , 'utf-8'));
return json.url;
}
While this approach seems better (and in practice is the most common),
it introduces a global-state/hidden dependency (session
), making the code harder to test and reason about.
Another issue with this approach: 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.
Testing without Dependency Injection
A test for the getServerURL
function without DI can look like this:
import fs from 'fs';
import session from 'app/session';
import { getServerURL } from './server-url-provider';
jest.mock('fs');
jest.mock('app/session');
(session.getUserId as jest.Mock).mockReturnValue(42);
(fs.readFileSync as jest.Mock).mockReturnValue('{"url": "https://example.com"}');
const url = getServerURL('config.json');
expect(session.getUserId).toHaveBeenCalled();
expect(fs.readFileSync).toHaveBeenCalledWith('users/42/config.json', 'utf-8');
expect(url).toBe('https://example.com');
You can see that:
- We are mocking the
fs
import to be able to mock thereadFileSync
function. - We are mocking the
app/session
import to be able to mock thesession.getUserId
function. - We are mocking the return value of
readFileSync
to return a predefined JSON string. - We are mocking the return value of
session.getUserId
to return a predefined user ID. - The test is supposed to test the
getServerURL
function, but it also tests the way we interact with the file system (the fact thatfs.readFileSync
requires autf-8
argument is totally irrelevant to thegetServerURL
function logic; it is much more related to the filesystem API).
This approach relies heavily on Jest's ability to mock module imports and global functions. It seems fine but gets messy quickly, especially if the function to test requires different dependencies. Without noticing it, most of your test code will be Jest's mocking magic.
Dependency Injection to the rescue
A better version of the above code, using Dependency Injection, could look like this:
// server-url-provider.ts (a service that reads a configuration file and returns a server URL)
export type Reader = (fileName: string) => string;
export type ServerUrlProvider = (fileName: string) => string;
export const createGetServerUrl = (fileReader: Reader): ServerUrlProvider => {
return (fileName: string): string => {
const json = JSON.parse(fileReader(fileName));
return json.url;
};
}
The key part here is that createGetServerUrl
takes as an argument the dependencies the inner function needs to operate on,
while the inner function has as arguments what it needs to operate on.
By decoupling the dependencies from the function itself, we can:
- Easily test the function by passing a mock implementation of the dependencies.
- Add additional dependencies without changing the function signature or hunting down all the places where the function is used.
This technique (where you create a new function with some of the dependencies already applied) in functional programming is often referred to as "partial application" or "function currying".
Notice also how the createGetServerUrl
function does not depend on any global state, nor does it depend on the file system.
It requires only a Reader
function that reads a file and returns its content as a string.
Completing the example
We can proceed and implement the Reader
function that reads a file.
// file-reader.ts (a simple file reader implementation)
import Reader from './server-url-provider';
import fs from 'fs';
export const fileReader: Reader = (fileName: string): string => {
return fs.readFileSync(fileName, 'utf-8');
}
Now that we have a function that reads a file, we can create the getServerURL
function using the createGetServerUrl
function:
// services.ts
import { createGetServerUrl } from './server-url-provider';
import { fileReader } from './file-reader';
export const getServerURL = createGetServerUrl(fileReader);
And lastly, we can use the getServerURL
function in our application:
// app.ts (the main entry point of the application)
import { getServerURL } from "./services";
const serverURL = getServerURL('config.json');
This is a much better approach, we have:
- Decoupled the dependencies from the function itself. The function creation is now separated from its invocation.
serverURL
takes only one parameter, the file name. If additional dependencies are needed,app.ts
does not need to be updated. We do not need to hunt down all the places wheregetServerURL
is used, and we can easily add new dependencies to thecreateGetServerUrl
function since it will have to be defined exactly once in theservcies.ts
file.
Adding the currently logged-in user
Implementing the getServerURL
function to include the currently logged-in user is easy.
// server-url-provider.ts
export type Reader = (fileName: string) => string;
export type ServerUrlProvider = (fileName: string) => string;
export type CurrentUserProvider = () => number;
export const createGetServerUrl = (fileReader: Reader, currentUser: CurrentUserProvider): ServerUrlProvider => {
return (fileName: string): string => {
const json = JSON.parse(fileReader(`users/${currentUser()}/` + fileName));
return json.url;
};
}
We have defined a new type CurrentUserProvider
, which is a function that returns the currently logged-in user's ID.
For simplicity, we will assume it returns a user ID as a number stored in the session.
Each framework has its own way of managing sessions, so I won't go into details here.
// current-user.ts (a service that provides the currently logged-in user)
export const createCurrentUserProvider = (session: Session): CurrentUserProvider => {
return (): number => {
// Logic to get the currently logged-in user
};
}
// services.ts
import { createGetServerUrl, ServerUrlProvider } from './server-url-provider';
import { fileReader } from './file-reader';
import { createCurrentUserProvider } from './current-user';
export const session = retrieveSession(); // Assume this function retrieves the session object from your framework
export const currentUserProvider = createCurrentUserProvider(session);
export const getServerURL = createGetServerUrl(fileReader, currentUserProvider);
We could stop here; our implementation is complete. The services.ts
is the central place where we define
our dependencies and all services/functions available in our application.
The "domain-specific" code is simple and testable without special mocking libraries.
Testing with Dependency Injection
To test the getServerURL
function, since now it is decoupled from the file system and global state,
we can create a mock implementation of dependencies without the need for Jest.
import { createGetServerUrl } from './server-url-provider';
const mockReader = (path) => {
expect(path).toBe('users/42/config.json');
return '{"url": "https://example.com/api"}';
};
const mockUserProvider = () => 42;
const getServerURL = createGetServerUrl(mockReader, mockUserProvider);
const url = getServerURL('config.json');
expect(url).toBe("https://example.com/api");
If you compare this test with the previous one,
you can see that it does not rely on any global state or mocking libraries.
This test is sharply focused on the getServerURL
function logic,
and does not test the way we interact with the file system or the session management.
Adding a Dependency Injection Container
The main problem with the services.ts
above is that
it forces you to know and resolve all the dependencies needed to create a particular service.
In our specific case, in order to call createGetServerUrl
:
- We need to call
createCurrentUserProvider
. - In order to call
createCurrentUserProvider
, we need to callretrieveSession
. - In order to call
retrieveSession
, we need to know how the session is managed in our framework.
In a complex application, this can become cumbersome and might require skills that
not all the developers working on the project have. On top of that,
in more complex cases when the services.ts
file
is being interpreted, the session might not be available yet.
That would force us to implement complex logic to delay the session
function creation until the session is available
(as an example, returning a function that wraps the session function and does not try to resolve the session until it is invoked
for the first time).
Despite the fact that there are solutions to some of the problems above, they tend to introduce complexity and make the code harder to maintain. A better approach would be to use a Dependency Injection (DI) container.
In this example, I will use microsoft/tsyringe, but InversifyJS is also a great choice.
A dependency injection container works in two steps:
- Service Registration: You register your services and "tell" the container which dependencies each service needs. It is important to note that the dependencies are not resolved at this point. They are just a flat list of declarations.
- Service Resolution: You ask the container to resolve a service. It will provide you with an instance of that service, taking care of resolving all its dependencies recursively.
A possible registration of the services in the DI container could look like this:
// services.ts (Dependency Injection Container definition)
import { container } from "tsyringe";
import { createGetServerUrl, ServerUrlProvider, Reader, CurrentUserProvider } from './server-url-provider';
import { fileReader } from './file-reader';
import { createCurrentUserProvider } from './current-user';
container.register<Reader>("Reader", { useValue: fileReader });
container.register<ServerUrlProvider>("ServerUrlProvider", {
useFactory: (c) => createGetServerUrl(
c.resolve<Reader>("Reader"),
c.resolve<Reader>("CurrentUserProvider")
)
});
container.register<CurrentUserProvider>("CurrentUserProvider", {
// assuming Session is already registered in the container via framework-specific dependency
useFactory: (c) => createCurrentUserProvider(c.resolve<Session>("Session"))
});
export { container };
Notice how in services.ts
, we are defining the ServerUrlProvider
without worrying about
the fact that its CurrentUserProvider
dependency is declared later in the file.
This is because at this point we are only registering the services and not resolving them.
Now in our application, we can use the DI container:
// app.ts
import { container } from "./services";
const getServerURL = container.resolve<ServerUrlProvider>("ServerUrlProvider");
const serverURL = getServerURL('config.json');
A DI container allows you to scale your application to thousands of services without worrying about how to obtain the dependencies needed for each of those services. As long as all the needed dependencies are registered in the DI container, they will be resolved automatically when you request a service.
Conclusion
Now that we have used Dependency Injection in our JavaScript application, we can see the benefits:
- Testing
getServerURL
is straightforward. You can callcreateGetServerUrl
with a mock implementation ofReader
that returns a predefined JSON string. - Adding additional dependencies, such as the currently logged-in user, is now straightforward.
The only place that needs to be updated is the service creation in
services.ts
. That's it! Now thegetServerURL
function can access the currently logged-in user without needing to change its signature or hunt down all the places where it is used. The initialapp.ts
file remains unchanged.
Are there any drawbacks to this approach?
The only "real" drawback I see is that it requires a bit more boilerplate code to set up the DI container and register the services.
This is mostly because JavaScript DI container implementations are not yet mature like some other languages (e.g., Java or PHP)
where the services.ts
file is not needed at all, it is created automatically by the framework and the developers do
not even need to know it exists.
Some developers are also obsessed with type safety and might argue that dependencies provided by the DI container are not type-safe. That is certainly true, but i think that is a small price to pay for the benefits of using DI.
Looking Ahead
Embracing Dependency Injection in your JavaScript projects leads to cleaner, more modular, and easily testable code. By decoupling dependencies from your functions, you gain flexibility and make your applications easier to maintain and scale. Whether you use a DI framework or simple function composition, adopting this pattern will improve your developer experience and the overall quality of your codebase.
Using a DI framework like microsoft/tsyringe or InversifyJS is not mandatory, but it certainly helps. What is important is to decouple the function creation from its invocation.
Give it a try in your next project! (please!)