Dependency Injection Does Not Need To Be Complex
Every time I see dependency injection, I think of Angular, C#, or Java. And rightly so - these languages and frameworks implement this technique (and no, DI is not a design pattern as many people mistakenly believe).
// user.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class UserService {}
// user.component.ts
import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';
@Component({
// New "UserService" instance per "UserComponent" instance.
providers: [UserService],
selector: 'app-user',
template: `<h1>Hello, {{ user.name }}!</h1>`,
})
export class UserComponent implements OnInit {
// Injecting UserService.
constructor(private userService: UserService) {}
}
But anyway, that's not the point. What I'm getting at is that in all these frameworks or languages, DI is usually implemented in a very 'advanced' way - there's nothing wrong with that. However, to truly understand DI, its benefits, and how it works in the more complex forms we see every day, it's worth getting to know the basics. Basics like these:
class UserService {
getUser() {
return { name: "John Doe", age: 30 };
}
}
class UserComponent {
// Service injected in constructor.
constructor(private userService: UserService) {}
render() {
// Renders component.
}
}
// Instead of relying on a fancy mechanism, we're creating the instance manually.
const userService = new UserService();
// The injection happens here!
const userComponent = new UserComponent(userService);
// Renders component.
userComponent.render();
As you can see, it's not Angular, but pure TypeScript. The "idea" is the same - we have a component that needs to be rendered. You can clearly see that the "manual" dependency injection we're doing here is a bit more boilerplate-heavy. That's precisely why the previously mentioned frameworks use more advanced implementations to reduce this boilerplate.
And that's what we're going to tackle today! Oh, and we'll also cover what IoC (Inversion of Control) is, what a DI Container is, what DI Token means, and what Hierarchical Dependency Injection involves – mostly, we'll be talking all about DI.
At the end, I'll try to show you why "Dependency Injection Does Not Need To Be Complex" in certain scenarios.
Basic Dependency Injection
Before we dive into the advanced stuff - let's cover the basics.
Dependency injection is a technique where, instead of relying on a specific piece of code directly (like importing, assigning another object to a class property, or initializing it within another function), we pass that instance from the outside.
Hence the term "injection" in the definition. We inject the dependency like a COVID vaccine (~ ̄▽ ̄)~. Below is some code without DI:
// @@@ Without Dependency Injection. @@@
import { sortBy } from 'lodash';
import { useCreatorStore } from 'store/creator/creator.store';
const sortUsers = () => {
const store = useCreatorStore.getState();
return sortBy(store.users, ['name']);
};
// Usage.
const sortedUsers = sortUsers();
console.log(sortedUsers);
There’s nothing inherently wrong with this code, as long as you don't want to sort a different array of users, not from the specific useCreatorStore
store, but from another source, or even any User[]
array.
Thanks to DI, we can make the code more reusable, and instead of directly getting the array of users inside sortUsers
function, we're passing an array of users
as an argument.
// @@@ With Dependency Injection. @@@
import { sortBy } from 'lodash';
interface User {
id: number;
name: string;
}
const sortUsers = (users: User[]) => {
return sortBy(users, ['name']);
};
// Usage.
import { useCreatorStore } from 'store/creator/creator.store';
import { useAnyStore } from 'store/any/any.store';
sortUsers(useCreatorStore.getState().users);
// or
sortUsers(useAnyStore.getState().users);
And that's basically it. Nothing too difficult, right? This is the whole point of DI: reusability and loose coupling, with the trade-off of a bit more boilerplate. As you can see, now we need to initialize everything on our own.
As always, nothing is perfect - no technique should be used just for the sake of it. You should understand the benefits and be willing to trade off one thing for another. There's an article about Overusing design patterns when not needed that you might want to check out.
Advanced Dependency Injection
We already know the advantages of using "simple" DI, as well as the drawbacks - like a bit more boilerplate and the hassle of passing dependencies around. Someone once came up with the idea of reducing this boilerplate by using mechanisms like reflection - which is essentially analyzing code at runtime (at least in JavaScript).
This concept is tied to something called metaprogramming - reading information about functions, the number of their parameters, types, the class constructor, its fields - often for the purpose of implementing some generic behavior.
It just so happens that JavaScript has such an API - you've probably used many of them (Object.keys
or Object.values
). These are generic functions that allow us to read metadata about the code we're writing.
The concept of metaprogramming and interesting APIs like (
Reflect
andProxy
) is explored in-depth in this article: Proxy and reflect in JavaScript.
So, let's implement a DI in a way similar to how Angular might do it. First of all, we need a Dependency Injection Container (DI Container). It will register instances that we'll provide in a "lazy" way, at the moment when someone uses the get
method for the first time. It's just a typical singleton pattern implementation combined with lazy code evaluation.
type Token = string;
type Dependency = { new (): unknown } | unknown;
const isConstructor = <TInstance>(
dependency: Dependency,
): dependency is { new (): TInstance } => {
return typeof dependency === `function` && `prototype` in dependency;
};
class DIContainer {
private instances = new Map<Token, Dependency>();
register = <TInstance>(
token: Token,
constructor: { new (): TInstance },
): void => {
this.instances.set(token, constructor);
};
get = <TInstance>(token: Token): TInstance => {
const Dependency = this.instances.get(token);
if (!Dependency) {
throw new Error(`You forgot to register dependency with token: ${token}`);
}
if (isConstructor<TInstance>(Dependency)) {
const instance = new Dependency();
this.instances.set(token, instance);
return instance;
}
return Dependency as TInstance;
};
}
Take a look at this: we're just defining what needs to happen, and the internal mechanism of the DI Container decides how it happens. This is called Inversion of Control (IoC) - a design principle that removes the responsibility from the developer for defining repetitive rules and algorithm steps, freeing you from the need to control the program's flow.
You've likely used IoC countless times without even realizing it! Consider these examples from your daily work. And yes, basically every callback you use is an implementation of IoC.
// JavaScript
setTimeout(() => {
console.log('Time’s up!');
// What...
}, 1000);
// Node
app.get('/user', (req, res) => {
res.send('Hello, User!');
// What...
});
// Angular
ngOnInit() {
// What...
};
// React
useEffect(() => {
// What...
}, []);
Alright, but let's get back to our example. How can we use the previously implemented DI Container? We can do it in the following way:
const container = new DIContainer();
class Logger {
log(message: string): void {
console.log(`[LOG]: ${message}`);
}
}
class ApiService {
fetchData(): void {
const loggerService = container.get<Logger>('logger');
loggerService.log('hi');
}
}
container.register('logger', Logger);
container.register('apiService', ApiService);
const apiService = container.get<ApiService>('apiService');
apiService.fetchData(); // Logs: "[LOG]: hi".
Everything that gets "registered" inside such a container will be accessible from anywhere in the code. So, if we register any class using register
, we’ll be able to access its instance from any level, without directly referencing it. Additionally, we’ll always get the same instance - no matter who or when someone tries to call get
, with built-in support for lazy instance creation.
This is a very primitive implementation that is meant only to demonstrate the more advanced concept. It's missing a lot of features and fancy stuff, such as tracking cyclic dependencies, more convenient injection using decorators (see Angular), and controlling the number of instances of specific classes via configuration (also Angular, with the providers
array).
// user.service.ts
import { Injectable } from '@angular/core';
@Injectable()
export class UserService {}
// user.component.ts
import { Component, OnInit } from '@angular/core';
import { UserService } from './user.service';
@Component({
// New "UserService" instance per "UserComponent" instance.
providers: [UserService],
selector: 'app-user',
template: `<h1>Hello, {{ user.name }}!</h1>`,
})
export class UserComponent implements OnInit {
// Injecting UserService.
constructor(private userService: UserService) {}
}
As you can see, the DI in Angular is much more advanced. In our case, we've used something called Injection Tokens (the strings that identify instances) to retrieve a specific one. Implementing such a complex mechanism like the one in the mentioned framework is really hard and insanely abstracted.
I still skipped a ton of other stuff, like Hierarchical Dependency Injection, which takes into account dependencies from outside (parent DI Container). If a dependency is missing - not created in the nearest container - it goes up and tries to find the next one, all the way to the top (main one). This is similar to the React Context API shadowing or just name shadowing in JavaScript.
Package For Metaprogramming
Implementing what you've seen earlier in a "custom" way is a fairly risky and time consuming approach. That's why it's better to use a package that handles what you need when implementing more advanced DI - such as Hierarchical DI.
There is a package reflect-metadata, which is ideal and will save you a lot of headaches when writing heavy code. It's more suited for when you're writing your own framework, rather than the application code itself.
import 'reflect-metadata';
function LogConstructorArgs() {
return function (target: any) {
const paramTypes = Reflect.getMetadata('design:paramtypes', target);
console.log(`Constructor for ${target.name} takes ${paramTypes.length} arguments.`);
};
}
@LogConstructorArgs()
class Example {
constructor(a: string, b: number) {}
}
If you're using TypeScript, remember to configure the compiler and adjust tsconfig.json
accordingly.
{
"compilerOptions": {
"emitDecoratorMetadata": true,
"experimentalDecorators": true
}
}
With a foundation in DI, understanding how more advanced DI works, and knowing about the key mechanisms along with a package that allows you to implement decorators (which are not officially supported in browsers yet - at least at the time of writing this article), you're now equipped with the essentials.
So, now you see how the DI mechanism in Angular can be implemented. As I mentioned earlier, it's massively complex, just like in other frameworks, but the key is to understand the concept. If you're not writing your own framework, this should be more than enough for you.
Use Simple DI On A Daily Basis
It's time to address the sentence from the article title. Unless you're using Angular - which is built around Hierarchical DI - you can use Dependency Injection in its simplest form to still gain its benefits.
Before understanding how, let's dive into the problem. A long time ago, I wrote a lot of backend APIs in a Node.js project. We used an approach called vertical-slicing - dividing the application into smaller, isolated directories that included all feature-related stuff without exposing them to other "features."
If there was something to share, it was added as a standalone library or placed into a shared folder. It's a similar approach to Angular’s way of grouping and managing application structure (often called Modular Monolith).
However, when it became necessary to import such controllers in one place during the application's construction, a small problem arose. The database provider was required to be used in the controllers.
import { protectedController } from 'framework/controller';
import { z } from 'zod';
// DB imported from another file.
import { Db } from 'database/providers';
const payloadSchema = z.object({
id: z.string(),
});
type Response = {
id: string;
};
const rateDocumentController = protectedController<Response>(
async (rawPayload, { uid }) => {
// Usage of imported DB.
const db = new Db();
const users = db.get('users');
// ...Logic
},
);
Now, this is just one controller, and every controller creates a Lambda that is created/killed based on usage. There's a noticeable "cold start" when calling inactive Lambdas due to the need to create a database provider instance, and its code being embedded into each Lambda.
Additionally, how do we ensure that the DB instance is created only once? Or, even more - how can we easily replace the database provider when migrating to something else?
Testing was also somewhat frustrating - because of the need to mock database/providers
using the jest.mock
function and specifying paths (please ignore the test quality - focus on the need for "mocking").
import { rateDocumentController } from './rateDocumentController';
import { Db } from 'database/providers';
jest.mock('database/providers', () => {
return {
Db: jest.fn().mockImplementation(() => ({
get: jest.fn(),
})),
};
});
describe('Rating works when', () => {
let dbMock: jest.Mocked<Db>;
beforeEach(() => {
dbMock = new Db() as jest.Mocked<Db>;
});
it('gets users', async () => {
const getMock = dbMock.get.mockReturnValue(['user1', 'user2']);
const mockPayload = { id: '123' };
const mockContext = { uid: 'abc' };
await rateDocumentController(mockPayload, mockContext);
expect(getMock).toHaveBeenCalledWith('users');
});
});
This is where simple DI comes in handy. We can create a db
instance in the code that consumes our controllers and inject it as a parameter. The consumer code now manages the number of instances. To achieve this, we just need to define an interface and slightly modify the controller's signature.
import { protectedController } from 'framework/controller';
import { z } from 'zod';
import type { Db } from 'database/models';
const payloadSchema = z.object({
id: z.string(),
});
type Response = {
id: string;
};
const rateDocumentController = (db: Db) =>
protectedController<Response>(async (rawPayload, { uid }) => {
// Now db is already injected.
const users = db.get('users');
// ...Logic
});
Here’s how it's used in the configuration file:
import { Db } from 'database/providers';
import { rateDocumentController } from 'modules/rating';
export const rateDocument = rateDocumentController(new Db());
// If needed, I can easily replace the DB implementation.
export const rateDocumentV2 = rateDocumentController(new Db('v2'));
And the testing file becomes much simpler:
import { rateDocumentController } from './rateDocumentController';
describe('Rating works when', () => {
it('gets users', async () => {
const getMock = jest.fn().mockReturnValue(['user1', 'user2']);
const mockPayload = { id: '123' };
const mockContext = { uid: 'abc' };
const controller = rateDocumentController({
get: getMock,
});
await controller(mockPayload, mockContext);
expect(getMock).toHaveBeenCalledWith('users');
});
});
This is a quick win - easier-to-maintain code. In this case, DI slightly reduced the amount of code, proving that when a pattern or technique is well-used, it can bring significant benefits without being forced.
Summary
Writing this article was pure joy, reflecting on how simple code changes can reduce complexity, simplify maintenance, and improve readability - something every developer loves the most.
Dependency Injection is really useful and something we use daily. Every time you pass a dependency through a parameter, you are essentially doing DI, and it doesn’t need to be an overly abstracted implementation with DI Containers, Tokens, and hierarchical structures.
Yes, the advanced concepts mentioned above are important if you’re writing code within a framework - but as long as you follow the rules of that framework, you don’t need to worry about how it’s implemented.
Simple DI can also be used in Angular - when you’re writing TypeScript code outside of the framework, like utility functions, etc...
After this article, you should be ready to start your journey in building your own framework and have a solid understanding of what’s important in the concept of DI.