Service Container
Introduction
The Athenna service container is a powerful tool for managing class dependencies and performing dependency injection. Dependency injection is a fancy phrase that essentially means this: class dependencies are "injected" into the class via the constructor or, in some cases, "setter" methods.
Let's look at a simple example:
export class WelcomeController {
/** @type {import('#app/Services/WelcomeService').WelcomeService} */
#welcomeService
/**
* Create a new controller instance.
*
* @param {any} welcomeService
*/
constructor(welcomeService) {
this.#welcomeService = welcomeService
}
/**
* Show the welcome payload.
*
* @param {import('@athenna/http').ContextContract} ctx
*/
async show({ response }) {
const data = await this.#welcomeService.findOne()
return response.status(200).send(data)
}
}
In this example, the WelcomeController
needs to retrieve the welcome payload from a data source. So, we will inject a
service that is able to retrieve the payload. In this context, our WelcomeService
. Since the WelcomeService
is injected,
we are able to easily swap it out with another implementation. We are also able to easily "mock", or create a dummy
implementation of the WelcomeService
when testing our application.
A deep understanding of the Athenna service container is essential to building a powerful, large application, as well as for contributing to the Athenna core itself.
Zero configuration resolution
If the class that you are working is in the app/Services
directory, the container autoload this service because of the
ServiceProvider
class. For example, you may place the following code in your routes/http
file:
If a class has no dependencies or only depends on other concrete classes (not interfaces), the container does not need
to be instructed on how to resolve that class. For example, you may place the following code in your routes/http
file:
import { Route } from '@athenna/http'
Route.get('/welcome', async (ctx) => {
/** @type {import('#app/Services/WelcomeService').WelcomeService} */
const welcomeService = ioc.use('App/Services/WelcomeService')
return ctx.response.status(200).send(await welcomeService.findOne())
})
In this example, hitting your application's /welcome
route will automatically resolve the WelcomeService
class and
inject it into your welcomeService
constant. This is game changing. It means you can develop your application and take
advantage of dependency injection without worrying about bloated configuration files.
Thankfully, many of the classes you will be writing when building an Athenna application automatically receive their dependencies via the container, including controllers, middleware, and more. Additionally. Once you taste the power of automatic and zero configuration dependency injection it feels impossible to develop without it.
When to use the container
In many cases, thanks to automatic dependency injection, you can build Athenna applications without ever manually binding or resolving anything from the container. So, when would you ever manually interact with the container? Let's examine two situations.
If you are writing an Athenna package that you plan to share with other Athenna developers, you may need to bind your package's services into the container.
Binding
Binding basics
Simple bindings
Almost all of your service container bindings will be registered within service providers, so most of these examples will demonstrate using the container in that context.
Within a service provider, you always have access to the container via the ioc
global property. We can register a binding
using the bind
method, passing the alias name that we wish to register along with our dependency:
import { StringNormalizer } from '#app/Utils/StringNormalizer'
ioc.bind('App/Utils/StringNormalizer', StringNormalizer)
Binding a singleton
The singleton
method binds a class or interface into the container that should only be resolved one time. Once a singleton
binding is resolved, the same object instance will be returned on subsequent calls into the container:
import { StringNormalizer } from '#app/Utils/StringNormalizer'
ioc.singleton('App/Utils/StringNormalizer', StringNormalizer)
Binding instances
You may also bind an existing object instance into the container using the instance
method. The given instance will
always be returned on subsequent calls into the container:
import { StringNormalizer } from '#app/Utils/StringNormalizer'
ioc.instance('App/Utils/StringNormalizer', new StringNormalizer())
Resolving
The global ioc class
You may use the use
or safeUse
method to resolve a class instance from the container. The use
method accepts the alias of the
dependency you wish to resolve:
/** @type {import('#app/Utils/StringNormalizer').StringNormalizer} */
const stringNormalized = ioc.use('App/Utils/StringNormalizer')
If the dependency cannot be found, stringNormalized
will be undefined
. To throw errors when the dependency does not
exist, use the safeUse
method.
Automatic constructor injection
Alternatively, and importantly, you can use the constructor of a class that is resolved by the container, including controllers, services, middleware, and more. In practice, this is how most of your objects should be resolved by the container.
For example, you may add your provider name in camelCase in the controller's constructor. The service will automatically be resolved and injected into the class:
export class WelcomeController {
/** @type {import('#app/Services/WelcomeService').WelcomeService} */
#welcomeService
/**
* Create a new controller instance.
*
* @param {any} welcomeService
*/
constructor(welcomeService) {
this.#welcomeService = welcomeService
}
/**
* Show the welcome payload.
*
* @param {import('@athenna/http').ContextContract} ctx
*/
async show({ response }) {
const data = await this.#welcomeService.findOne()
return response.status(200).send(data)
}
}