Container services

Container services

As we discussed in the IoC container guide, the container bindings are one of the primary reasons for the IoC container to exists in AdonisJS.

Container bindings keep your codebase clean from boilerplate code required to construct objects before they can be used.

In the following example before you can use the Database class, you will have to create an instance of it. Depending the class you are constructing, you might have write a lot of boilerplate code to get all of its dependencies.

import { Database } from '@adonisjs/lucid'
export const db = new Database(
// inject config and other dependencies
)

However, when using an IoC container, you can offload the task of constructing a class to the container and fetch a pre-built instance.

import app from '@adonisjs/core/services/app'
const db = await app.container.make('lucid.db')

The need for container services

Using the container to resolve pre-configured objects is great. However, using the container.make method has its own downsides.

  • Editors are good with auto imports. If you attempt to use a variable and the editor can guess the import path of the variable, then it will write the import statement for you. However, this cannot work with container.make calls.

  • Using a mix of import statements and container.make calls feels unintuitive compared to having a unified syntax for importing/using modules.

To overcome these downsides, we wrap container.make calls inside a regular JavaScript module, so you can fetch them using the import statement.

For example, the @adonisjs/lucid package has a file called services/db.ts and this file has roughly the following contents.

const db = await app.container.make('lucid.db')
export { db as default }

Within your application, you can replace the container.make call with an import pointing to the services/db.ts file.

import app from '@adonisjs/core/services/app'
const db = await app.container.make('lucid.db')
import db from '@adonisjs/lucid/services/db'

As you can see, we are still relying on the container to resolve an instance of the Database class for us. However, with a layer of indirection, we can replace the container.make call with a regular import statement.

The JavaScript module wrapping the container.make calls is known as a Container service. Almost every package that interacts with the container ships with one or more container services.

Container services vs. Dependency injection

Container services are an alternative to dependency injection. For example, instead of accepting the Disk class as a dependency, you ask the drive service to give you a disk instance. Let's look at some code examples.

In the following example, we use the @inject decorator to inject an instance of the Disk class.

import { Disk } from '@adonisjs/drive'
import { inject } from '@adonisjs/core'
@inject()
export default class PostService {
constructor(protected disk: Disk) {
}
async save(post: Post, coverImage: File) {
const coverImageName = 'random_name.jpg'
await this.disk.put(coverImageName, coverImage)
post.coverImage = coverImageName
await post.save()
}
}

When using the drive service, we call the drive.use method to get an instance of Disk with s3 driver.

import drive from '@adonisjs/drive/services/main'
export default class PostService {
async save(post: Post, coverImage: File) {
const coverImageName = 'random_name.jpg'
const disk = drive.use('s3')
await disk.put(coverImageName, coverImage)
post.coverImage = coverImageName
await post.save()
}
}

Container services are great for keeping your code terse. Whereas, dependency injection creates a loose coupling between different application parts.

Choosing one over the other comes down to your personal choice and the approach you want to take to structure your code.

Testing with container services

The outright benefit of dependency injection is the ability to swap dependencies at the time of writing tests.

To provide a similar testing experience with container services, AdonisJS provides first-class APIs for faking implementations when writing tests.

In the following example, we call the drive.fake method to swap drive disks with an in-memory driver. After a fake is created, any call to the drive.use method will receive the fake implementation.

import drive from '@adonisjs/drive/services/main'
import PostService from '#services/post_service'
test('save post', async ({ assert }) => {
/**
* Fake s3 disk
*/
drive.fake('s3')
const postService = new PostService()
await postService.save(post, coverImage)
/**
* Write assertions
*/
assert.isTrue(await drive.use('s3').exists(coverImage.name))
/**
* Restore fake
*/
drive.restore('s3')
})

Container bindings and services

The following table outlines a list of container bindings and their related services exported by the framework core and first-party packages.

Binding Class Service
app Application @adonisjs/core/services/app
ace Kernel @adonisjs/core/services/kernel
config Config @adonisjs/core/services/config
encryption Encryption @adonisjs/core/services/encryption
emitter Emitter @adonisjs/core/services/emitter
hash HashManager @adonisjs/core/services/hash
logger LoggerManager @adonisjs/core/services/logger
repl Repl @adonisjs/core/services/repl
router Router @adonisjs/core/services/router
server Server @adonisjs/core/services/server
testUtils TestUtils @adonisjs/core/services/test_utils