Zero Argument, Asynchronous Class Configuration & Instantiation

March 2, 2022
Multiple streams of light, abstract

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 or Orders 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 named Authorization.

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 a Partial 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 the getConfig function. It is passed as-is to the Authorization service's getConfig 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 where new is applied against this.

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:

  1. The options object can no longer have a default of {}, as there are now required properties;
  2. Further, only the config property of the options should be Partial;
  3. Finally, any arguments that need to be passed to the super class are captured and forwarded by way of the variadic rest 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 };

Related Posts

Blazing Fast Testing in Node with MongoDB

February 6, 2019
MongoDB is a document-based NoSQL database, and is a popular choice for applications in the Node ecosystem. This post is about testing in Node with MongoDB, however, and not about why you should or should not use MongoDB, so I’ll leave it to the pundits on Hacker News to hash that out.

trace-pkg: Package Node.js apps for AWS Lambda and beyond

December 15, 2020
Packaging Node.js applications for the cloud can be slow and tedious. We introduce trace-pkg, a general purpose tool to quickly and efficiently package up your application code and dependencies for deployment in AWS Lambda and beyond!

Implementing a Distributed Lock State Machine

December 17, 2018
A complex distributed computing problem application developers may encounter is controlling access to a resource or entity in a multi-user, concurrent environment. How can we guard against an arbitrary number of concurrent processes from potentially mutating or even having any access to an entity simultaneously?