Let's Learn Adonis 5 Service Providers Lesson 4.0

Service Providers & The IoC Container

tomgobich avatar
tomgobich
10 min
Quick Summary

We'll learn about Service Providers and how they interact with the IoC Container. We'll then put this to practice by wrapping a NodeJS package so it's easy to use within AdonisJS

Why Use A Service Provider?

Service Providers are a great place to turn when you need to bootstrap, or register, something inside of your AdonisJS application. Service providers can be used to register event listeners, middleware, routes, and more.

They’re also great when you need to extend AdonisJS as well. For example, back in the routing module of this series, we used a RouteProvider to go over how we can extend the Route module within AdonisJS.

We can also use Service Providers to register a package from outside the AdonisJS ecosystem so that it’s available within the AdonisJS ecosystem. You’ll only want to use Service Providers for this if the package requires some setup work. Packages like gravatar, and others that can be imported and immediately used, don’t require a Service Provider.

What Is A Service Provider

The reason there are so many different reasons service providers can be used is because, at the core, they’re classes that contain AdonisJS lifecycle hook methods. Meaning, AdonisJS will import each registered provider and assign the applicable lifecycle hook method to run when your AdonisJS application reaches that lifecycle hook.

When you create a new provider using the Ace CLI, below is the file that’ll be created.

import { ApplicationContract } from '@ioc:Adonis/Core/Application'

/*
|--------------------------------------------------------------------------
| Provider
|--------------------------------------------------------------------------
|
| Your application is not ready when this file is loaded by the framework.
| Hence, the top level imports relying on the IoC container will not work.
| You must import them inside the life-cycle methods defined inside
| the provider class.
|
| @example:
|
| public async ready () {
|   const Database = this.app.container.resolveBinding('Adonis/Lucid/Database')
|   const Event = this.app.container.resolveBinding('Adonis/Core/Event')
|   Event.on('db:query', Database.prettyPrint)
| }
|
*/
export default class ExampleProvider {
  constructor(protected app: ApplicationContract) {}

  public register() {
    // Register your own bindings
  }

  public async boot() {
    // All bindings are ready, feel free to use them
  }

  public async ready() {
    // App is ready
  }

  public async shutdown() {
    // Cleanup, since app is going down
  }
}

As you can see there are four lifecycle hook methods populated on the provider class.

  • register
    Called before your application is booted and before namespace bindings have been registered. This is where you can register your own namespace bindings within your application.

  • boot
    Called when your application has booted. At this point and time, all namespace bindings within your application have been registered and are ready to use.

  • ready
    Called when your application is fully running. Think of this as the equivalent of client-side JavaScript’s DOMContentLoaded event.

  • shutdown
    Called when your application is being shut down. This is where you’ll want to clean up anything you’ve registered that needs to be destroyed in order for your application to gracefully shut down.

Top-Level Imports

An important thing to note, as commented in the provider code above, is that when the provider file is imported, the IoC Container is not yet ready. So, in order to import files that use the IoC Container, you’ll need to import them within the lifecycle method you need to use them within.

As the comment example outlines, an example of this is:

public async ready () {
  const Database = this.app.container.resolveBinding('Adonis/Lucid/Database')
  const Event = this.app.container.resolveBinding('Adonis/Core/Event')
  Event.on('db:query', Database.prettyPrint)
}

Here, we’re importing the Database and Event modules within the ready lifecycle method inside of a provider.

Inside of these lifecycle hooks, we can make use of the IoC Container via this.app.container. So, in the example, we’re using the IoC Container to resolve a registered namespace binding for both the Database and Event modules.

Creating A Service Provider

When it comes to creating a Service Provider, you could manually create and register a file equivalent to the above within your application, however, you can also use the Ace CLI to easily generate one into your project for you.

The Ace CLI command we’ll want to run to generate a new provider file is:

node ace make:provider <name>

Let’s take a look at the Ace CLI help options for this command.

node ace make:provider -h

Make a new provider class

Usage: make:provider <name>

Arguments
  name                 Name of the provider class

Flags
  --ace boolean        Register provider under the ace providers array
  -e, --exact boolean  Create the provider with the exact name as provided

Here we can see the command expects a name, which should be the name of the provider class generated. We also have two flags.

  • --ace, which we can use to specify whether we want the provider to be registered under the providers array inside our .adonisrc.json file for us. This will default to true when excluded.

  • -e, which you can use to bypass AdonisJS’ naming normalizations.

Using A Service Provider to Register a Third-Party Package

Now that we have an idea of what providers are and what they can be used for, let’s walk through an example of using a Service Provider to wrap a NodeJS package and register it within our application.

Installing The Package

For this example, we’ll be wrapping the node-discord-logger package for easy use inside of our AdonisJS application. However, before we can use the package we must first install it!

npm i node-discord-logger

Creating The Provider

Next, let’s go ahead and create the provider file. The below command will also register the provider file inside our .adonisrc.json file as well.

node ace make:provider LogProvider

This will create our LogProvider file within our app’s providers directory. However, in order to leave space for future loggers to be configured within our application, let’s actually move this into a directory called LogProvider and rename the file to index.ts so it’s the default import. Our updated file path should now be:

/providers/LogProvider/index.ts

Wrapping The Package

This step is optional, you could just use the package as-is. However, for demonstration purposes and to provide a slightly more lenient API, let’s go over how to wrap the package.

So, firstly let’s create a discord.ts file within our /providers/LogProvider directory. Let’s also define a default exported class called Logger here as well with an instance of the node-discord-logger package on it.

// providers/LogProvider/discord.ts

import DiscordLogger from 'node-discord-logger'

export default class Logger {
  protected logger: DiscordLogger

  constructor() {
    this.logger = new DiscordLogger()
  }
}

Config

The node-discord-logger has a number of configuration options as well. So, it’d be great to accept a configuration into the constructor to pass into our node-discord-logger instance. Let’s create a new file within our config directory called log.ts. This config file can house all configurations for any future loggers we may add to the project.

For now, let’s use the following.

// config/log.ts

import Env from '@ioc:Adonis/Core/Env'

export const discordLoggerConfig = {
  hook: Env.get('DISCORD_WEBHOOK'),
  serviceName: Env.get('NODE_ENV')
}

The node-discord-logger package accepts a lot more config options, but let’s keep it simple to a hook and serviceName for now. Both of which, we’ll load from our environment variables.

  • hook should be the webhook URL. You’ll need to go and grab one from Discord if you’re following along.

  • serviceName is text displayed in the footer of Discord messages. In our case, we’ll use the NODE_ENV to display whether the message was sent in production, development, or testing.

Applying The Configuring

Now that we’ve got our package’s configuration setup, let’s put it to use in our Logger class.

// providers/LogProvider/discord.ts

import DiscordLogger from 'node-discord-logger'
import { discordLoggerConfig } from 'Config/log'

export default class Logger {
  protected logger: DiscordLogger

  constructor(config: typeof discordLoggerConfig) {
    this.logger = new DiscordLogger(config)
  }
}

Here we’re accepting the configuration as the class constructor and passing it into the DiscordLogger constructor.

Adding Methods

Lastly, let’s add some methods to our wrapper.

// providers/LogProvider/discord.ts

import DiscordLogger from 'node-discord-logger'
import { discordLoggerConfig } from 'Config/log'

export default class Logger {
  protected logger: DiscordLogger

  constructor(config: typeof discordLoggerConfig) {
    this.logger = new DiscordLogger(config)
  }

  public async info(title: string, message?: string | object| Array<any>) {
    return this.logger.info(this.build(title, message))
  }

  public async warn(title: string, message?: string | object| Array<any>) {
    return this.logger.warn(this.build(title, message))
  }

  public async error(title: string, message?: string | object| Array<any>) {
    return this.logger.error(this.build(title, message))
  }

  public async debug(title: string, message?: string | object| Array<any>) {
    return this.logger.debug(this.build(title, message))
  }

  public async silly(title: string, message?: string | object| Array<any>) {
    return this.logger.silly(this.build(title, message))
  }

  private build(title: string, message?: string | object | Array<any>) {
    return {
      message: title,
      description: JSON.stringify(message, null,  4)
    }
  }
}

Here we have an info, warn, error, debug, and silly method that all accept the same arguments. Then, we have a build method that all the other methods call, that returns back an object in the format DiscordLogger expects. We’ll also stringify the description message so we can provide a string, object, or array to it.

Registering The Package Wrapper

Next, let’s register our Logger class to the Logger/Discord namespace within our application. Once we do this, and inform TypeScript about it, we’ll be able to import our package via @ioc:Logger/Discord.

Let’s focus on the register method of our LogProvider class. First, we’ll want to use the IoC Container to register our namespace as a singleton.

// providers/LogProvider/index.ts

export default class LogProvider {
  constructor (protected app: ApplicationContract) {
  }

  public register () {
    // Register your own bindings
    this.app.container.singleton('Logger/Discord', () => {
	  // 1. import the discord logger configuration
      const { discordLoggerConfig } = this.app.config.get('log')
      
      // 2. import our Logger wrapper class
      const DiscordLogger = require('./discord').default

      // 3. return a new instance
      return new DiscordLogger(discordLoggerConfig)
    })
  }

  // ...
}

Here we’re using the IoC Container to register a singleton to the namespace Logger/Discord. Since we’re using the IoC Container container property here, we don’t want to specify the @ioc: prefix on the namespace.

The second argument is where we’ll create our Logger class instance and return it back as the value for our Logger/Discord namespace.

Inside that callback, we’re using the app config property to call get. This allows us to easily get a configuration file within our provider’s lifecycle methods. Here all we need to provide is the file’s name, log. Then, we import our Logger class, calling it DiscordLogger. Lastly, we return back a new instance of the DiscordLogger class, passing it our configuration.

Singleton VS Bind

Now, a small aside. Above we defined our namespace as a singleton. However, we could define it as a normal binding as well, by replacing singleton with bind.

this.app.container.bind('Logger/Discord', () => {
})

You may be asking, what’s the difference? We’ll bind will call the callback function every time we import our Logger/Discord namespace. Singleton, however, will only call the callback function once throughout the lifetime of our server.

So, with bind, we’d end up creating multiple Logger instances, one each time we import the namespace. With singleton, we’ll only be creating one, regardless of how many times we import the namespace.

Informing TypeScript About the Binding

The last thing to do before we can use it is to inform TypeScript about the new namespace binding! Again, to allow the possibility for additional loggers to be added in the future, let’s add a logger directory under our contracts directory. Then, within /contracts/logger, let’s create a file called discord.ts.

Here we’ll declare our namespace to TypeScript.

// contracts/logger/discord.ts

declare module '@ioc:Logger/Discord' {
  import Logger from 'providers/LogProvider/discord'

  const DiscordLogger: Logger

  export default DiscordLogger
}

Within our declaration, we’re importing the Logger type from providers/LogProvider/discord. Then, we’re stating we’ll have a default export property called DiscordLogger that’s the type of our Logger.

Let’s Use It!

The last thing to do is to put it to use! So, let’s head into our App/Controllers/Http/PostsController.ts file, import it, and use it.

// app/Controllers/Http/PostsController.ts

import { HttpContextContract } from '@ioc:Adonis/Core/HttpContext'
import DateService from 'App/Services/DateService'
import { inject } from '@adonisjs/fold'
import DiscordLogger from '@ioc:Logger/Discord' // 👈 import it using IoC Container

@inject()
export default class PostsController {
  public dateService = DateService

  public async store ({}: HttpContextContract) {
    const dateTime = DateService.toDateTime()
    const formattedDate = this.dateService.toDate(dateTime)

    // 👇 put it to use
    await DiscordLogger.info('New Post Created', { dateTime })

    return `creating a post ${formattedDate}`
  }

  // ...
}

So, here we’re importing it using the IoC Container from @ioc:Logger/Discord. Then, we’re using it by calling our info method.

Boot up your server, and hit your [PostsController.store](<http://PostsController.store>) route and you should get a fresh Discord message stating a new post was created!