Service Container
Understand the purpose and how to use the Athenna 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:
import { Controller, type Context } from '@athenna/http'
import { WelcomeService } from '#src/services/WelcomeService'
@Controller()
export class WelcomeController {
private welcomeService: WelcomeService
public constructor(welcomeService) {
this.welcomeService = welcomeService
}
public async show({ response }: Context) {
const data = await this.welcomeService.getWelcomeData()
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.
Simple resolution
You may place the following code in your
Path.routes('http.ts')
./src/routes/http.ts
import { Route } from '@athenna/http'
export class AppService {
public ok() {
return { message: 'ok' }
}
}
ioc.bind('appService', AppService)
Route.get('/', ({ response }) => {
const appService = ioc.use<AppService>('appService')
const body = appService.ok()
return response.status(200).send(body)
})
In this example, when bootstrapping your application, Athenna will bind the
AppService
class inside the container. When hitting your application's / route,
it will be resolved by using the ioc
global variable. 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 and middlewares. Once you taste the power of automatic and zero configuration dependency injection, it feels impossible to develop without it.
The example above is just to show that we can place our services anywhere in our
application, without depending on configuration files or any other kind of
setup. We recommend you placing your services in specifics directory and not
inside your route file. A good place to put your services is src/services
directory, since make:service
command will save your services there.
But remember that this is only a tip, at the end of the day you can do
whatever you want with Athenna 😎.
When to use the container
Thanks to this simple configuration resolution, you will often just import your
dependency on your routes, controllers, and elsewhere without ever manually
interacting with the container. For example, you might just import your
AppService
in your controller so that you can easily access the business
logic wrote in the service class. Even though we never have to interact with
the container to write this code, it is managing the injection of their
dependencies behind the scenes.
import { AppService } from '#src/services/AppService'
import { Controller, type Context } from '@athenna/http'
@Controller()
export class AppController {
public constructor(private appService: AppService) {}
public async show({ response }: Context) {
const data = await this.appService.getDate()
return response.status(200).send(data)
}
}
In many cases, thanks to automatic dependency injection and facades, 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?
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
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.
In your application you will 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 '#src/helpers/StringNormalizer'
ioc.bind('App/Helpers/StringNormalizer', StringNormalizer)
Binding transients
The transient
method binds a class into the container that will resolve
different instances of it each time. Meaning that once a transient binding is
resolved, a new object instance will be returned on subsequent calls into the
container:
import { StringNormalizer } from '#src/helpers/StringNormalizer'
ioc.transient('App/Helpers/StringNormalizer', StringNormalizer)
By default, the bind
method will always register your dependencies as
transient
.
Binding a singleton
The singleton
method binds a class 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 '#src/helpers/StringNormalizer'
ioc.singleton('App/Helpers/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 '#src/helpers/StringNormalizer'
ioc.instance('App/Helpers/StringNormalizer', new StringNormalizer())
The @Service()
annotation
The @Service()
annotation is just a helper that will map all the
configurations that a service needs to be registered in the container,
this means that this annotation does not have the responsibility to
bind your service in the container.
Let's create a simple service to understand how this annotation works:
node artisan make:service StringNormalizer
The command above will create the
Path.services('StringNormalizer.ts')
./src/services/StringNormalizer.ts
services
property
of the .athennarc.json
file.
Let's add some configuration to it:
import { Service } from '@athenna/ioc'
@Service({
type: 'singleton',
camelAlias: 'stringNormalizer',
alias: 'App/Services/StringNormalizer',
})
export class StringNormalizer {
public run(value: string) {
return value.trim().toLowerCase()
}
}
The following operations will be done in StringNormalizer
class
when bootstrapping the Athenna application:
- The class will be registered in the container with the
alias
App/Services/StringNormalizer
. - The registration type will be singleton, meaning that the same instance of the class will be resolved from the container everytime it is requested.
- The camel alias of the class will be
stringNormalizer
, that is typically used in automatic constructor injections and also by the@Inject()
annotation to resolve the class from the container.
By default, the @Service()
annotation will always register
your services as transient,
meaning that a new instance of your class will be resolved from the container
everytime.
Resolving
You may use the use
or safeUse
methods from the ioc global property to
resolve a class instance from the container. The use method accepts the alias
of the dependency you wish to resolve:
import { StringNormalizer } from '#src/helpers/StringNormalizer'
const sn = ioc.use<StringNormalizer>('App/Helpers/StringNormalizer')
If the dependency alias cannot be found in the container, sn
const will be
set as 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, middlewares, and more.
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:
import { AppService } from '#src/services/AppService'
import { Controller, type Context } from '@athenna/http'
@Controller()
export class AppController {
public constructor(private appService: AppService) {}
public async show({ response }: Context) {
const data = await this.appService.getData()
return response.status(200).send(data)
}
}
Using @Inject()
annotation
You can also use the @Inject()
annotation instead of the constructor. The
annotation follows the same logic of the constructor, you need to use the
camelCase name of your dependency as the property name to be resolved properly:
import { Inject } from '@athenna/ioc'
import { AppService } from '#src/services/AppService'
import { Controller, type Context } from '@athenna/http'
@Controller()
export class AppController {
@Inject()
private appService: AppService
public async show({ response }: Context) {
const data = await this.appService.getData()
return response.status(200).send(data)
}
}
When using the @Inject()
annotation you could also pass as argument a
specific alias to be resolved in the container:
import { Inject } from '@athenna/ioc'
import { AppService } from '#src/services/AppService'
import { Controller, type Context } from '@athenna/http'
@Controller()
export class AppController {
@Inject('App/Services/AppService')
private appService: AppService
public async show({ response }: Context) {
const data = await this.appService.getData()
return response.status(200).send(data)
}
}