Simplest and straightforward implementation of IoC container in JavaScript
[![gh-workflow-image]][gh-workflow-url] [![npm-image]][npm-url] ![][typescript-image] [![license-image]][license-url]
Many existing implementations of IoC containers take the concept too far and start to feel more like Java. JavaScript inherently does not have all the bells and whistles; you need to have similar IoC container benefits as PHP or Java.
Therefore, with this project, I live to the ethos of JavaScript and yet build a container that can help you create loosely coupled systems.
I have explained the reasons for using an IoC container in this post. It might be a great idea to read the post first ✌️
Note: AdonisJS fold is highly inspired by the Laravel IoC container. Thanks to Taylor for imagining such a simple, yet powerful API.
@adonisjs/fold easy to read and follow.@adonisjs/fold works with vanilla JavaScript. It's just you have to write less code when using TypeScript. Thanks to its decorators metadata API.Install the package from the npm packages registry.
npm i @adonisjs/fold
Once done, you can import the Container class from the package and create an instance of it. For the most part, you will use a single instance of the container.
import { Container } from '@adonisjs/fold'
const container = new Container()
You can construct an instance of a class by calling the container.make method. The method is asynchronous since it allows for lazy loading dependencies via factory functions (More on factory functions later).
class UserService {}
const service = await container.make(UserService)
assert(service instanceof UserService)
In the previous example, the UserService did not have any dependencies; therefore, it was straightforward for the container to make an instance of it.
Now, let's look at an example where the UserService needs an instance of the Database class.
class Database {}
class UserService {
static containerInjections = {
_constructor: {
dependencies: [Database],
},
}
constructor(db) {
this.db = db
}
}
const service = await container.make(UserService)
assert(service.db instanceof Database)
The static containerInjections property is required by the container to know which values to inject when creating an instance of the class.
This property can define the dependencies for the class methods (including the constructor). The dependencies are defined as an array. The dependencies are injected in the same order as they are defined inside the array.
Do you remember? I said that JavaScript is not as powerful as Java or PHP. This is a classic example of that. In other languages, you can use reflection to look up the classes to inject, whereas, in JavaScript, you have to tell the container explicitly.
Wait, you can use decorators with combination of TypeScript's emitDecoratorMetaData option to perform reflection. You will also need to install reflect-metadata in order for TypeScript to extract metadata from your classes.
It is worth noting, TypeScript decorators are not as powerful as the reflection API in other languages. For example, in PHP, you can use interfaces for reflection. Whereas in TypeScript, you cannot.
With that said, let's look at the previous example, but in TypeScript this time.
import { inject } from '@adonisjs/fold'
class Database {}
@inject()
class UserService {
constructor(db: Database) {
this.db = db
}
}
const service = await container.make(UserService)
assert(service.db instanceof Database)
The @inject decorator looks at the types of all the constructor parameters and defines the static containerInjections property behind the scenes.
Note: The decorator-based reflection can only work with concrete values, not with interfaces or types since they are removed during the runtime.
When calling the container.make method, you can pass runtime values that take precedence over the containerInjections array.
In the following example, the UserService accepts an instance of the ongoing HTTP request as the 2nd param. Now, when making an instance of this class, you can pass that instance manually.
import { inject } from '@adonisjs/fold'
import { Request } from '@adonisjs/core/src/Request'
class Database {}
@inject()
class UserService {
constructor(db: Database, request: Request) {
this.db = db
this.request = request
}
}
createServer((req) => {
const runtimeValues = [undefined, req]
const service = await container.make(UserService, runtimeValues)
assert(service.request === req)
})
In the above example:
Database class since it is set to undefined inside the runtime values array.request), the container will use the req value.You can also call class methods to look up/inject dependencies automatically.
In the following example, the UserService.find method needs an instance of the Database class. The container.call method will look at the containerInjections property to find the values to inject.
class Database {}
class UserService {
static containerInjections = {
find: {
dependencies: [Database],
},
}
async find(db) {
await db.select('*').from('users')
}
}
const service = await container.make(UserService)
await container.call(service, 'find')
The TypeScript projects can re-use the same @inject decorator.
class Database {}
class UserService {
@inject()
async find(db: Database) {
await db.select('*').from('users')
}
}
const service = await container.make(UserService)
await container.call(service, 'find')
The runtime values are also supported with the container.call method.
Alongside making class instances, you can also register bindings inside the container. Bindings are simple key-value pairs.
string, a symbol or a class constructor.const container = new Container()
container.bind('db', () => {
return new Database()
})
const db = await container.make('db')
assert(db instanceof Database)
Following is an example of binding the class constructor to the container and self constructing an instance of it using the factory function.
container.bind(Database, () => {
return new Database()
})
The factory receives the following three arguments.
resolver reference. Resolver is something container uses under the hood to resolve dependencies. The same instance is passed to the factory, so that you can resolve dependencies to construct the class.container.make call.container.bind(Database, (resolver, runtimeValues) => {
return new Database()
})
I am answering this question from a framework creator perspective. I never use the @inject decorator on my classes shipped as packages. Instead, I define their construction logic using factory functions and keep classes free from any knowledge of the container.
So, if you create packages for AdonisJS, I highly recommend using factory functions. Leave the @inject decorator for the end user.
You can bind a singleton to the container using the container.singleton method. It is the same as the container.bind method, except the factory function is called only once, and the return value is cached forever.
container.singleton(Database, () => {
return new Database()
})
Along side the factory functions, you can also bind direct values to the container.
container.bindValue('router', router)
The values are given priority over the factory functions. So, if you register a value with the same name as the factory function binding, the value will be resolved from the container.
The values can also be registered at the resolver level. In the following example, the Request binding only exists for an isolated instance of the resolver and not for the entire container.
const resolver = container.createResolver()
resolver.bindValue(Request, req)
await resolve.make(SomeClass)
Container aliases allows defining aliases for an existing binding. The alias should be either a string or a symbol.
container.singleton(Database, () => {
return new Database()
})
container.alias('db', Database)
/**
* Make using the alias
*/
const db = await container.make('db')
assert.instanceOf(db, Database)
Contextual bindings allows you to register custom dependency resolvers on a given class for a specific dependency. You will be mostly using contextual bindings with driver based implementations.
For example: You have a UserService and a BlogService and both of them needs an instance of the Drive disk to write and read files. You want the UserService to use the local disk driver and BlogService to use the s3 disk driver.
Note Contextual bindings can be defined for class constructors and not for container bindings
import { Disk } from '@adonisjs/core/driver'
class UserService {
constructor(disk: Disk) {}
}
import { Disk } from '@adonisjs/core/driver'
class BlogService {
constructor(disk: Disk) {}
}
Now, let's use contextual bindings to tell the container that when UserService needs the Disk class, provide it the local driver disk.
container
.when(BlogService)
.asksFor(Disk)
.provide(() => drive.use('s3'))
container
.when(UserService)
.asksFor(Disk)
.provide(() => drive.use('local'))
When using the container to resolve a tree of dependencies, quite often you will have no control over the construction of a class and therefore you will be not able to swap/fake its dependencies when writing tests.
In the following example, the UsersController needs an instance of the UserService class.
@inject()
class UsersController {
constructor(service: UserService) {}
}
In the following test, we are making an HTTP request that will be handled by the UsersController. However, within the test, we have no control over the construction of the controller class.
test('get all users', async ({ client }) => {
// I WANTED TO FAKE USER SERVICE FIRST?
const response = await client.get('users')
})
To make things simpler, you can tell the container to use a swapped implementation for a given class constructor as follows.
test('get all users', async ({ client }) => {
class MyFakedService extends UserService {}
/**
* From now on, the container will return an instance
* of `MyFakedService`.
*/
container.swap(UserService, () => new MyFakedService())
const response = await client.get('users')
})
You can pass an instance of the EventEmitter or emittery to listen for events as container resolves dependencies.
import { EventEmitter } from 'node:events'
const emitter = new EventEmitter()
emitter.on('container:resolved', ({ value, binding }) => {
// value is the resolved value
// binding name can be a mix of string, class constructor, or a symbol.
})
const container = new Container({ emitter })
You can use container hooks when you want to modify a resolved value before it is returned from the make method.
Note: The hook callback can also be an async function
container.resolving(Validator, (validator) => {
validate.rule('email', function () {})
})
Container providers are static functions that can live on a class to resolve the dependencies for the class constructor or a given class method.
Once, you define the containerProvider on the class, the IoC container will rely on it for resolving dependencies and will not use the default provider.
import { ContainerResolver } from '@adonisjs/fold'
import { ContainerProvider } from '@adonisjs/fold/types'
class UsersController {
static containerProvider: ContainerProvider = (
binding,
property,
resolver,
defaultProvider,
runtimeValues
) => {
console.log(binding === UserService)
console.log(this === UserService)
return defaultProvider(binding, property, resolver, runtimeValues)
}
}
Custom providers can be handy when creating an instance of the class is not enough to construct it properly.
Let's take an example of AdonisJS route model binding. With route model binding, you can query the database using models bas