IoC container
At the heart of every AdonisJS application is an IoC container that can construct classes and resolve dependencies with almost zero config.
The IoC container serves the following two primary use cases.
- Exposing API for first and third-party packages to register and resolve bindings from the container (More on bindings later).
- Automatically resolve and inject dependencies to a class constructor or class methods.
Let's start with injecting dependencies into a class.
Basic example
The automatic dependency injection relies on the TypeScript legacy decorators implementation and the Reflection metadata API.
In the following example, we create an EchoService
class and inject an instance of it into the HomeController
class. You can follow along by copy-pasting the code examples.
Step 1. Create the Service class
Start by creating the EchoService
class inside the app/services
folder.
export default class EchoService {
respond() {
return 'hello'
}
}
Step 2. Inject the service inside the controller
Create a new HTTP controller inside the app/controllers
folder. Alternatively, you can use the node ace make:controller home
command.
Import the EchoService
in the controller file and accept it as a constructor dependency.
import EchoService from '#services/echo_service'
export default class HomeController {
constructor(protected echo: EchoService) {
}
handle() {
return this.echo.respond()
}
}
Step 3. Using the inject decorator
To make automatic dependency resolution work, we will have to use the @inject
decorator on the HomeController
class.
import EchoService from '#services/echo_service'
import { inject } from '@adonisjs/core'
@inject()
export default class HomeController {
constructor(protected echo: EchoService) {
}
handle() {
return this.echo.respond()
}
}
That's all! You can now bind the HomeController
class to a route and it will automatically receive an instance of the EchoService
class.
Conclusion
You can think of the @inject
decorator as a spy looking at the class constructor or method dependencies and informing the container about it.
When the AdonisJS router asks the container to construct the HomeController
, the container already knows about the controller dependencies.
Constructing a tree of dependencies
Right now, the EchoService
class has no dependencies, and using the container to create an instance of it might seem overkill.
Let's update the class constructor and make it accept an instance of the HttpContext
class.
import { inject } from '@adonisjs/core'
import { HttpContext } from '@adonisjs/core/http'
@inject()
export default class EchoService {
constructor(protected ctx: HttpContext) {
}
respond() {
return `Hello from ${this.ctx.request.url()}`
}
}
Again, we must place our spy (the @inject
decorator) on the EchoService
class to inspect its dependencies.
Voila, that's all we have to do. Without changing a single line of code inside the controller, you can re-run the code, and the EchoService
class will receive an instance of the HttpContext
class.
The great thing about using the container is that you can have deeply nested dependencies, and the container can resolve the entire tree for you. The only deal is to use the @inject
decorator.
Using method injection
Method injection is used to inject dependencies inside a class method. For method injection to work, you must place the @inject
decorator before the method signature.
Let's continue with our previous example and move the EchoService
dependency from the HomeController
constructor to the handle
method.
When using method injection inside a controller, remember the first parameter receives a fixed value (i.e., the HTTP context), and the rest of the parameters are resolved using the container.
import EchoService from '#services/echo_service'
import { inject } from '@adonisjs/core'
@inject()
export default class HomeController {
constructor(private echo: EchoService) {
}
@inject()
handle(ctx, echo: EchoService) {
return echo.respond()
}
}
That's all! This time, the EchoService
class instance will be injected inside the handle
method.
When to use Dependency Injection
Leveraging dependency injection in your projects is recommended because DI creates a loose coupling between different parts of your application. As a result, the codebase becomes easier to test and refactor.
However, you have to be careful and not take the idea of dependency injection to its extreme that you start to lose its benefits. For example:
- You should not inject helper libraries like
lodash
as a dependency of your class. Import and use it directly. - Your codebase might not need loose coupling for components that are ever likely to get swapped or replaced. For example, you may prefer importing the
logger
service vs. injecting theLogger
class as a dependency.
Using the container directly
Most classes within your AdonisJS application, like the Controllers, Middleware, Event listeners, Validators, and Mailers, are constructed using the container. Therefore you can leverage the @inject
decorator for automatic dependency injection.
For situations where you want to self-construct a class instance using the container, you can use the container.make
method.
The container.make
method accepts a class constructor and returns an instance of it after resolving all its dependencies.
import { inject } from '@adonisjs/core'
import app from '@adonisjs/core/services/app'
class EchoService {}
@inject()
class SomeService {
constructor(public echo: EchoService) {}
}
/**
* Same as making a new instance of the class, but
* will have the benefit of automatic DI
*/
const service = await app.container.make(SomeService)
console.log(service instanceof SomeService)
console.log(service.echo instanceof EchoService)
You can use the container.call
method to inject dependencies inside a method. The container.call
method accepts the following arguments.
- An instance of the class.
- The name of the method to run on the class instance. The container will resolve the dependencies and pass them to the method.
- An optional array of fixed parameters to pass to the method.
class EchoService {}
class SomeService {
@inject()
run(echo: EchoService) {
}
}
const service = await app.container.make(SomeService)
/**
* An instance of Echo class will get passed
* the run method
*/
await app.container.call(service, 'run')
Container bindings
Container bindings are one of the primary reasons for the IoC container to exist in AdonisJS. Bindings act as a bridge between the packages you install and your application.
Bindings are essentially a key-value pair, the key is the unique identifier for the binding, and the value is a factory function that returns the value.
- The binding name can be a
string
, asymbol
, or a class constructor. - The factory function can be asynchronous and must return a value.
You may use the container.bind
method to register a container binding. Following is a straightforward example of registering and resolving bindings from the container.
import app from '@adonisjs/core/services/app'
class MyFakeCache {
get(key: string) {
return `${key}!`
}
}
app.container.bind('cache', function () {
return new MyCache()
})
const cache = await app.container.make('cache')
console.log(cache.get('foo')) // returns foo!
When to use container bindings?
Container bindings are used for specific use cases, like registering singleton services exported by a package or self-constructing class instances when automatic dependency injection is insufficient.
We recommend you not make your applications unnecessarily complex by registering everything to the container. Instead, look for specific use cases in your application code before reaching for container bindings.
Following are some of the examples which are using container bindings inside the framework packages.
- Registering BodyParserMiddleware inside container: Since the middleware class requires configuration stored inside the
config/bodyparser.ts
file, there is no way for automatic dependency injection to work. In this case, we manually construct the middleware class instance by registering it as a binding. - Registering Encryption service as a singleton: The Encryption class requires the
appKey
stored inside theconfig/app.ts
file, therefore, we use container binding as a bridge to read theappKey
from the user application and configure a singleton instance of the Encryption class.
The concept of container bindings is not commonly used in the JavaScript ecosystem. Therefore, feel free to join our Discord community to clarify your doubts.
Resolving bindings inside the factory function
You can resolve other bindings from the container within the binding factory function. For example, if the MyFakeCache
class needs config from the config/cache.ts
file, you can access it as follows.
this.app.container.bind('cache', async (resolver) => {
const configService = await resolver.make('config')
const cacheConfig = configService.get<any>('cache')
return new MyFakeCache(cacheConfig)
})
Singletons
Singletons are bindings for which the factory function is called once, and the return value is cached for the application's lifetime.
You can register a singleton binding using the container.singleton
method.
this.app.container.singleton('cache', async (resolver) => {
const configService = await resolver.make('config')
const cacheConfig = configService.get<any>('cache')
return new MyFakeCache(cacheConfig)
})
Binding values
You can bind values directly to the container using the container.bindValue
method.
this.app.container.bindValue('cache', new MyFakeCache())
Aliases
You can define aliases for bindings using the alias
method. The method accepts the alias name as the first parameter and a reference to an existing binding or a class constructor as the alias value.
this.app.container.singleton(MyFakeCache, async () => {
return new MyFakeCache()
})
this.app.container.alias('cache', MyFakeCache)
Defining static types for bindings
You can define the static-type information for binding using TypeScript declaration merging.
The types are defined on the ContainerBindings
interface as a key-value pair.
declare module '@adonisjs/core/types' {
interface ContainerBindings {
cache: MyFakeCache
}
}
If you create a package, you can write the above code block inside the service provider file.
In your AdonisJS application, you can write the above code block inside the types/container.ts
file.
Swapping implementations during testing
When you rely on the container to resolve a tree of dependencies, you have less/no control over the classes in that tree. Therefore, mocking/faking those classes can become harder.
In the following example, the UsersController.index
method accepts an instance of the UserService
class, and we use the @inject
decorator to resolve the dependency and give it to the index
method.
import UserService from '#services/user_service'
import { inject } from '@adonisjs/core'
export default class UsersController {
@inject()
index(, service: UserService) {}
}
Let's say during testing, you do not want to use the actual UserService
as it makes external HTTP requests. Instead, you want to use a fake implementation.
But first, look at the code you might write to test the UsersController
.
import UserService from '#services/user_service'
test('get all users', async ({ client }) => {
const response = await client.get('/users')
response.assertBody({
data: [{ id: 1, username: 'virk' }]
})
})
In the above test, we interact with the UsersController
over an HTTP request and do not have direct control over it.
The container provides a straightforward API to swap classes with fake implementations. You can define a swap using the container.swap
method.
The container.swap
method accepts the class constructor you want to swap, followed by a factory function to return an alternative implementation.
import UserService from '#services/user_service'
import app from '@adonisjs/core/services/app'
test('get all users', async ({ client }) => {
class FakeService extends UserService {
all() {
return [{ id: 1, username: 'virk' }]
}
}
app.container.swap(UserService, () => {
return new FakeService()
})
const response = await client.get('users')
response.assertBody({
data: [{ id: 1, username: 'virk' }]
})
})
Once a swap has been defined, the container will use it instead of the actual class. You can restore the original implementation using the container.restore
method.
app.container.restore(UserService)
// Restore all
app.container.restore()
Contextual dependencies
Contextual dependencies allow you to define how a dependency should be resolved for a given class. For example, you have two services depending on the Drive Disk class.
import { Disk } from '@adonisjs/drive'
export default class UserService {
constructor(protected disk: Disk) {}
}
import { Disk } from '@adonisjs/drive'
export default class PostService {
constructor(protected disk: Disk) {}
}
You want the UserService
to receive a disk instance with the GCS driver and the PostService
to receive a disk instance with the S3 driver. You can do so using contextual dependencies.
The following code must be written inside a service provider register
method.
import { Disk } from '@adonisjs/drive'
import UserService from '#services/user_service'
import PostService from '#services/post_service'
import { ApplicationService } from '@adonisjs/core/types'
export default class AppProvider {
constructor(protected app: ApplicationService) {}
register() {
this.app.container
.when(UserService)
.asksFor(Disk)
.provide(async (resolver) => {
const driveManager = await resolver.make('drive')
return drive.use('gcs')
})
this.app.container
.when(PostService)
.asksFor(Disk)
.provide(async (resolver) => {
const driveManager = await resolver.make('drive')
return drive.use('s3')
})
}
}
Container hooks
You can use the container's resolving
hook to modify/extend the return value of the container.make
method.
Usually, you will use hooks inside a service provider when trying to extend a particular binding. For example, the Database provider uses the resolving
hook to register additional database-driven validation rules.
import { ApplicationService } from '@adonisjs/core/types'
export default class DatabaseProvider {
constructor(protected app: ApplicationService) {
}
async boot() {
this.app.container.resolving('validator', (validator) => {
validator.rule('unique', implementation)
validator.rule('exists', implementation)
})
}
}
Container events
The container emits the container_binding:resolved
event after resolving a binding or constructing a class instance. The event.binding
property will be a string (binding name) or a class constructor, and the event.value
property is the resolved value.
import emitter from '@adonisjs/core/services/emitter'
emitter.on('container_binding:resolved', (event) => {
console.log(event.binding)
console.log(event.value)
})
See also
- The container README file covers the container API in the framework agnostic manner.
- Why do you need an IoC container? In this article, the framework's creator shares his reasoning for using the IoC container.