Manager/Builder pattern
Abstract class to incapsulate the behavior of constructing and re-using named objects.
The module is used heavily by AdonisJs to build it's driver based features for components like hash
, mail
, auth
, social auth
and so on.
If you are a user of AdonisJs, then you will be familiar with the following API.
mail.use('smtp').send()
hash.use('argon2').hash('')
The use
method here constructs the driver for the mapping defined in the config file with the ability to cache the constructed drivers and add new drivers using the extend
API.
Table of contents
Installation
Install the package from npm registry as follows:
npm i @poppinss/manager
yarn add @poppinss/manager
Usage
Let's imagine we are building a mailer (with dummy implementation) that supports multiple drivers and each driver can be used for multiple times. Example config for same
{
mailer: 'transactional',
mailers: {
transactional: {
👇
driver: 'smtp',
host: '',
user: '',
password: ''
},
promotional: {
driver: 'mailchimp',
apiKey: ''
}
}
}
Our module supports two drivers smtp
and mailchimp
and here's how we can construct them.
class SmtpDriver {
constructor (config) {}
send () {}
}
class MailchimpDriver {
constructor (config) {}
send () {}
}
The consumer of our code can import and use these drivers manually by constructing a new instance every time. However, we can improve the developer experience, by creating the following manager class.
import { Manager } from '@poppinss/manager'
class Mailer extends Manager {
constructor (container, private config) {
super(container)
}
protected $cacheMappings = true
protected getDefaultMappingName (): string {
return this.config.mailer
}
protected getMappingConfig (name): any {
return this.config.mailers[name]
}
protected getMappingDriver (name): any {
return this.config.mailers[name].driver
}
protected createSmtp (name, config) {
return new Smtp(config)
}
protected createMailchimp (name, config) {
return new MailchimpDriver(config)
}
}
The Manager
class forces the parent class to define some of the required methods/properties.
- $cacheMappings: When set to true, the manager will internally cache the instance of drivers and will re-use them.
- getDefaultMappingName: The name of the default mapping. In this case, it is the name of the
mailer
set in the config - getMappingConfig: Returning the config for a mapping. We pull it from the
mailers
object defined in the config. - getMappingDriver: Returning the driver for a mapping inside config.
The other two methods createSmtp
and createMailchimp
are the methods invoked when the end user will access a driver. This is how it works.
const mailer = new Mailer({}, config)
mailer.use('smtp').send()
The mailer.use('transactional')
will invoke createTransactional
as part of the following convention.
create
+ PascalCaseDriverName
create
+ Smtp
= createSmtp
create
+ Mailchimp
= createMailchimp
NOTE: You need one method for each driver and not the mapping. The mapping names can be anything the user wants to keep in their config file.
Release mapping from cache
The release
method can be used release the mappings from cache.
mailer.release('smtp')
Extending drivers
The manager class also exposes the API to add new drivers from outside in and this is done using the extend
method.
mailer.extend('postmark', (container, name, config) => {
return new Postmark(config)
})
What is container?
The Manager
class needs a container value, which is then passed to the extended
drivers. This can be anything from application state to an empty object.
In case of AdonisJs, it is the instance of the IoC container, so that the outside world (extended drivers) can pull dependencies from the container.
Typescript types
In order for intellisense to work, you have to do some ground work of defining additional types. This in-fact is a common theme with static languages, that you have to rely on loose coupling when creating or using extensible objects.
Driver interface
The first thing you must have in place is the interface that every driver adheres to.
interface MailDriverContract {
send (): void
}
And then pass it as a generic to the Manager
constructor
class Mailer extends Manager<MailDriverContract> {
}
Now, extend
method will ensure that all drivers adhere to the MailDriverContract
.
use
method intellisense
Currently the output of use
method will be typed to MailDriverContract
and not the actual implementation class. This is fine, when all drivers have the same properties, methods and output. However, you can define a list of mappings as follows
type MappingsList = {
transactional: SmtpDriver,
promotional: MailchimpDriver,
}
And then pass this mapping as a 2nd argument to the Manager
constructor.
class Mailer extends Manager<
MailDriverContract,
MailDriverContract,
MappingsList
> {
}
The use
method will now return the concrete type. You can also use this same mapping to enforce correct configuration.
type SmtpConfig = {
driver: 'smtp',
host: string,
user: string,
password: string,
port?: number,
}
type MailchimpConfig = {
driver: 'mailchimp',
apiKey: string,
}
type MappingsList = {
transactional: {
config: SmtpConfig,
implementation: SmtpDriver,
},
promotional: {
config: MailchimpConfig,
implementation: MailchimpDriver,
},
}
The master MappingsList
will ensure that static types and the configuration is always referring to the same driver instance.
Typed config
import { ExtractConfig } from '@poppinss/manager'
type Config<Mailer extends keyof MappingsList> = {
mailer: Mailer,
mailers: ExtractConfig<MappingsList>,
}
const config: Config<'transactional'> = {
mailer: 'transactional',
mailers: {
transactional: {
driver: 'smtp',
host: '',
user: '',
password: '',
},
promotional: {
driver: 'mailchimp',
apiKey: '',
},
},
}
Updating mapping list generic for Manager
import { ExtractImplementations, Manager } from '@poppinss/manager'
class Mailer extends Manager<
MailDriverContract,
MailDriverContract,
ExtractImplementations<MappingsList>
> {
}
Finally, you have runtime functionality to switch between multiple mailers and also have type safety for same.