Introduction
Instantiating classes that require certain properties that must come from configuration is a chore.
You might encapsulate business logic in class that, in turn, needs access to external resources. Perhaps the credentials to interact with those resources can only be injected lazily at run-time.
If every consumer of the class is required to provide those credentials, then that logic is often repeated in many places. This can make maintenance of the application burdensome and resistant to change. If a new service is added and it too needs to be configured, impact analysis is required to see how this can be absorbed by the upstream consumers.
Rather than forcing consumers to know how to configure your service, the solution presented in this article is to create asynchronous, static
configuration and instantiation factories.
In this article, you will learn by example, a pattern for developing TypeScript service-layer classes that are capable of asynchronous, self-configuration.
This powerful technique provides two amazing capabilities:
First, zero arguments mean it takes zero effort to stand up a service that implements this pattern. For example:
const authorization = await Authorization.new();
Second, this pattern also makes it equally simple to compose multiple, arbitrarily nested services that implement this pattern. For example:
const orders = await Orders.new();
Note What's the difference? Only the return type! You cannot tell what service(s) either
Authorization
orOrders
uses or, for that matter, what service(s) those services may use and so on.
Configuration Object
Assume your Orders
service has a few properties that it needs at instantiation time. Perhaps there is a service that it consumes and that service needs some properties to be instantiated. This configuration object will describe those properties.
When you import
and export
from the service, it is expected that a namespace will be used. This prevents collisions with other services that have such conformity in naming.
import * as Orders from './orders';
For example purposes, assume the service has the following configuration:
export interface IServiceConfig { authorization: Authorization.IServiceConfig; tableName: string; timeoutMS?: number; zone: number; }
The fictitious properties above must be provided to the constructor
at instantiation time.
Note In this example,
authorization
is the container for the configuration for a nested service namedAuthorization
.
The Authorization
service being used by the service also needs to be configured. Here is an example configuration object:
export interface ICredentials { password: string; username: string; } export interface IServiceConfig { credentials: ICredentials; url: string; }
Note The properties of the
Authorization
service configuration will not be found in the code! They can only be obtained at runtime. This could be done with an asynchronous call to a secrets store of some type.
Options Object
In addition to the configuration object, your service will also declare the options sent to the constructor
. The constructor
will accept a single argument, the IServiceOptions
object:
export interface IServiceOptions { config: IServiceConfig; }
Note The
config
object is always present.
Additional properties that are provided to the constructor
would include those that cannot be determined automatically. These might be stateful parameters known only to the process running, for example.
getConfig
Now that you've described the configuration of the service, create a static
method that optionally accepts a Partial
configuration and asynchronously resolves to a complete configuration.
The idea here is you can influence the configuration—if you want to. Or, you can allow the service to configure itself. The choice is yours. You don't even need to send any information to the configuration function as the default is an empty object {}
for destructuring.
Here is an example of configuring the fictitious Authorization
service:
public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { credentials, url = process.env.URL }: Partial<IServiceConfig> = options; if (!url) { throw new Error('Missing required configuration parameter url.'); } return { credentials: await this.getCredentials(credentials), url, }; }
Note The
options
object here is entirely optional. If it is provided, it is aPartial
of the entire configuration object. In this way, you have the ability, but not the requirement to send some or all of the necessary configuration values.
Since options
is not required, then credentials
could be undefined
as well. The getCredentials
static method follows the same pattern of zero argument, asynchronous configuration, completing the credentials
as needed.
Nested getConfig
Here's where the power of zero argument, asynchronous configuration comes into play.
Consider this example where the Authorization
service is consumed by another service. This higher-order service also implements the zero argument, asynchronous configuration pattern, as shown here:
public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { authorization, tableName = process.env.TABLE_NAME, timeoutMS = Infinity, zone = 0, }: Partial<IServiceConfig> = options; if (!tableName) { throw new Error('Missing required configuration parameter tableName.'); } return { authorization: await Authorization.Service.getConfig(authorization), tableName, timeoutMS, zone }; }
Note Notice that
authorization
is an optional parameter of thegetConfig
function. It is passed as-is to theAuthorization
service'sgetConfig
function. If the parameter is not provided, then the lower-level service configures itself entirely. If all or part of the parameter is provided, then the lower-level service completes the configuration!
This pattern can be repeated to any depth.
new Factory
The final step is to create a static
factory function that can asynchronously instantiate these services.
An example of the Authorization
service new
factory:
public static async new( options: Partial<IServiceOptions> = {} ): Promise<Authorization> { const { config }: Partial<IServiceOptions> = options; return new this({ config: await this.getConfig(config) }); }
Note In
static
class methods,this
refers to the class itself. This leads to the interesting syntax wherenew
is applied againstthis
.
Finally, the higher order service consuming the fictitious Authorization
service also has a new
factory. For example:
public static async new( options: Partial<IServiceOptions> = {} ): Promise<Orders> { const { config }: Partial<IServiceOptions> = options; return new this({ config: await this.getConfig(config) }); }
Note What is the difference between these two factories? Only the return type!
These two factories both accept the options
that are sent to their respective constructor
functions. If the configuration is omitted in part or whole it is completed as needed and then the service is instantiated.
extends and super
So far you've learned about composing services together in an orchestration pattern.
The zero argument, asynchronous configuration pattern can also be applied to object-orientation.
The class that extends
the super
class needs to ensure that its IServiceConfig
and IServiceOptions
also both extend the higher-order interfaces.
Here is an example of extending the IServiceConfig
and IServiceOptions
from a subclass:
import * as Base from '../base'; export enum FactoryType { automated, manual, } export interface IServiceConfig extends Base.IServiceConfig { isActive: boolean; region: number; } export interface IServiceOptions extends Base.IServiceOptions { config: IServiceConfig; type: FactoryType; }
To make getConfig
work, simply connect the two classes as shown in this example:
public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { isActive = true, region = +(process.env.FACTORY_REGION ?? 0), ...rest }: Partial<IServiceConfig> = options; const config: Base.IServiceConfig = await super.getConfig(rest); return { ...config, isActive, region }; }
Note Notice the usage of variadic
rest
arguments.
The properties known to the configuration of the subclass are configured. The remainder are captured and sent to the super
class' getConfig
method to complete the configuration.
Finally, because the subclass includes the type
property, the new
factory example from before needs just a few small modifications:
- The
options
object can no longer have a default of{}
, as there are now required properties; - Further, only the
config
property of theoptions
should bePartial
; - Finally, any arguments that need to be passed to the
super
class are captured and forwarded by way of the variadicrest
arguments.
Here is an example:
public static async new( options: SomePartial<IServiceOptions, 'config'> ): Promise<Orders> { const { config, ...rest }: SomePartial<IServiceOptions, 'config'> = options; return new this({ config: await this.getConfig(config), ...rest }); }
Note
SomePartial
is a helper type that allows you to declare what properties are to be made optional.
export type SomePartial<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
Conclusion
Creating services that configure themselves opens up new possibilities and streamlines the development workflow.
Using the zero argument, asynchronous configuration pattern allows you to compose or extend services without fretting about the upstream or downstream consequences.
Existing services can be extended to orchestrate ever more complex flows without breaking the consumer. The consumer does not even know what it takes to stand up the service at all. Unless it wants to.
Passing no arguments also means that configuration is a black box to the consumer. This also means the configuration can be changed in a single place and all consumers will benefit—automagically!
Give the pattern a try and see for yourself how easy it is to build, compose, and extend services in new and exciting ways!
Read on if you want to see a sample class. Feel free to use it as a template in the future!
Putting It All Together
src/authorization.ts
export interface ICredentials { password: string; username: string; } export interface IServiceConfig { credentials: ICredentials; url: string; } export interface IServiceOptions { config: IServiceConfig; } export class Authorization { public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { credentials, url = process.env.URL }: Partial<IServiceConfig> = options; if (!url) { throw new Error('Missing required configuration parameter url.'); } return { credentials: await this.getCredentials(credentials), url, }; } public static async getCredentials( options: Partial<ICredentials> = {} ): Promise<ICredentials> { const { password, username }: Partial<ICredentials> = options; // @todo Retrieve credentials from e.g. consul, AWS Secrets Manager, etc. return { password: password || 'some-password', username: username || 'some-username', }; } public static async new( options: Partial<IServiceOptions> = {} ): Promise<Authorization> { const { config }: Partial<IServiceOptions> = options; return new this({ config: await this.getConfig(config) }); } #credentials: ICredentials; public readonly url: string; constructor(options: IServiceOptions) { const { config }: IServiceOptions = options; const { credentials, url }: IServiceConfig = config; this.#credentials = credentials; this.url = url; } } export { Authorization as Service };
src/base.ts
import * as Authorization from '../authorization'; export interface IServiceConfig { authorization: Authorization.IServiceConfig; tableName: string; timeoutMS?: number; zone: number; } export interface IServiceOptions { config: IServiceConfig; } export abstract class Base { public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { authorization, tableName = process.env.TABLE_NAME, timeoutMS = Infinity, zone = 0, }: Partial<IServiceConfig> = options; if (!tableName) { throw new Error('Missing required configuration parameter tableName.'); } return { authorization: await Authorization.Service.getConfig(authorization), tableName, timeoutMS, zone }; } public readonly authorization: Authorization.Service; public readonly config: IServiceConfig; constructor(options: IServiceOptions) { const { config }: IServiceOptions = options; const { authorization }: IServiceConfig = config; this.config = config; this.authorization = new Authorization.Service({ config: authorization }); } public abstract assemble(): Promise<void>; } export { Base as Service };
src/orders.ts
import * as Base from '../base'; import { SomePartial } from '..'; export enum FactoryType { automated, manual } export interface IServiceConfig extends Base.IServiceConfig { isActive: boolean; region: number; } export interface IServiceOptions extends Base.IServiceOptions { config: IServiceConfig; type: FactoryType; } export class Orders extends Base.Service { public static async getConfig( options: Partial<IServiceConfig> = {} ): Promise<IServiceConfig> { const { isActive = true, region = +(process.env.FACTORY_REGION ?? 0), ...rest }: Partial<IServiceConfig> = options; const config: Base.IServiceConfig = await super.getConfig(rest); return { ...config, isActive, region }; } public static async new( options: SomePartial<IServiceOptions, 'config'> ): Promise<Orders> { const { config, ...rest }: SomePartial<IServiceOptions, 'config'> = options; return new this({ config: await this.getConfig(config), ...rest }); } public readonly config: IServiceConfig; constructor(options: IServiceOptions) { super(options); const { config }: IServiceOptions = options; this.config = config; } public async assemble(): Promise<void> { // @todo Assemble the order! } } export { Orders as Service };