Zero Argument, Asynchronous Class Configuration & InstantiationZeroArgument,AsynchronousClassConfiguration&InstantiationZeroArgument,AsynchronousClassConfiguration&Instantiation
Michael Scott
2 Mar 2022
Share
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 =awaitAuthorization.new();
jsx
Second, this pattern also makes it equally simple to compose multiple, arbitrarily nested services that implement this pattern. For example:
const orders =awaitOrders.new();
jsx
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*asOrdersfrom'./orders';
jsx
For example purposes, assume the service has the following configuration:
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:
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:
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:
publicstaticasyncgetConfig(options:Partial<IServiceConfig> = {}): Promise<IServiceConfig>{const{ authorization, tableName = process.env.TABLE_NAME, timeoutMS =Infinity, zone =0,}:Partial<IServiceConfig> = options;
if (!tableName) {thrownewError('Missing required configuration parameter tableName.');} return {authorization:awaitAuthorization.Service.getConfig(authorization), tableName, timeoutMS, zone
};
}
jsx
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:
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:
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 the options should be Partial;
Finally, any arguments that need to be passed to the super class are captured and forwarded by way of the variadic rest arguments.
NoteSomePartial is a helper type that allows you to declare what properties are to be made optional.
export type SomePartial<T,KextendskeyofT>=Omit<T,K>&Partial<Pick<T,K>>;
jsx
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
exportinterfaceICredentials{password: string;username: string;}exportinterfaceIServiceConfig{credentials:ICredentials;url: string;}exportinterfaceIServiceOptions{config:IServiceConfig;}exportclassAuthorization{publicstaticasyncgetConfig(options:Partial<IServiceConfig> = {} ): Promise<IServiceConfig>{const{ credentials, url = process.env.URL}:Partial<IServiceConfig> =
options;
if (!url) {thrownewError('Missing required configuration parameter url.');} return {credentials:awaitthis.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:awaitthis.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 };
jsx
src/base.ts
import*asAuthorizationfrom'../authorization';exportinterfaceIServiceConfig{authorization:Authorization.IServiceConfig;tableName: string; timeoutMS?: number;zone: number;}exportinterfaceIServiceOptions{config:IServiceConfig;}export abstract classBase{publicstaticasyncgetConfig(options:Partial<IServiceConfig> = {} ): Promise<IServiceConfig>{const{ authorization, tableName = process.env.TABLE_NAME, timeoutMS =Infinity, zone =0,}:Partial<IServiceConfig> = options;
if (!tableName) {thrownewError('Missing required configuration parameter tableName.');} return {authorization:awaitAuthorization.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=newAuthorization.Service({config: authorization });} public abstract assemble(): Promise<void>;
}
export { Base as Service };
jsx
src/orders.ts
import*asBasefrom'../base';import{SomePartial}from'..';exportenumFactoryType{ automated, manual
}exportinterfaceIServiceConfigextendsBase.IServiceConfig{isActive: boolean;region: number;}exportinterfaceIServiceOptionsextendsBase.IServiceOptions{config:IServiceConfig;type:FactoryType;}exportclassOrdersextendsBase.Service{publicstaticasyncgetConfig(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;returnnewthis({config:awaitthis.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 };
jsx
But wait - there's more.
Nearform publishes real-world learnings on data & AI, engineering, and digital strategy - with more merged in weekly.
Insights
Perspectives on AI in engineering, product development, and strategy, for enterprise executives.