Skip to main content

Configuration

ModuleManager builds on two Version Zero projects; Schema to provide the system for modules to define their configuration requirements (documented here in its own section), and Configurator to coordinate using these schemas to load configuration assignments for the modules (documented here.

Declarative Schema

Keeping definition of a module's configurable properties co-located with the module implementation helps prevent any "abstraction leaks" where the application needs to know too much about the module internals. It also allows modules to be added or removed without having to keep configuration code in sync.

import { ModuleManager, Schema } from '@versionzero/module-manager';

// You can use simple data to define a schema if you want to avoid adding a dependency...
class Server {
static moduleInfo = {
schema: {
properties: {
host: { base: 'string', options: { default: 'localhost' }, metadata: { flagHint: 'S' } },
port: { base: 'number', options: { default: 8080 }, handlers: { validators: ['$integer']}}
}
}
}
}
// Or you can use the more ergonomic `Schema` builder API:
class Demo {
static moduleInfo = {
schema: new Schema('object')
.property('verbose', new Schema('boolean').meta('description', 'display extra info'))
.property('server', new Schema('Server').required().meta('hidden'))
}

async main() {
if (this.verbose) {
console.log(`server: ${this.server.host}:${this.server.port}`);
}
}
}

await new ModuleManager()
.register(Demo)
.register(Server)
.run()

Let's say this was in myapp.js, you could configure this module with:

% node myapp.js -v --server-host 127.0.0.1
server: 127.0.0.1:8080

The schema is also used to generate CLI help text that can be customized with metadata:

% node myapp.js --help
Usage: Demo [options]
--config (-C) [path|-] - load configuration from file (or - for stdin)
--help (-h) [advanced] - display help information
--verbose (-v) [true|false] - display extra info
--server-host (-S) [string] - (default:"localhost")
--server-port (--sp) [integer] - (default:«8080»)

See the Configurator docs for details on customization using metadata.

The computed configuration (which is then loadable via -C) can be dumped with:

% node myapp.js -vS 192.168.1.1 --sp 80 --dump -
{
"demo": {
"server": "Server",
"verbose": true
},
"server": {
"host": "192.168.1.1",
"port": 80
}
}
note

The demo.server value of "Server" is an artifact of --dump serialization back to a string; the runtime value is the actual instance of the Server class.

Configuration Values

Modules can receive configuration values in two ways;

  1. Property dependency injection, as used above (host and port are injected into the Server instance, which is itself injected into the Demo instance, which is auto-created due to having a main lifecycle method.
  2. By implementing an init lifecycle method; in this example, each module would have received only the populated contents of their respective demo or server configurations.

See the linked documentation for more details.

Module Instance References

Schemas define validation rules, but also may define data processing rules.

This can be seen above in the relatively simple transformation of the string "80" (that originated from the command line argv array) into the integer 80. This is defined via Schema core library $number value processor, which is used in a Schema registered with SchemaResolver under the name number. Using number as the base of a newly constructed Schema is how the port property inherits this behavior.

The reference to new Schema('Server') in Demo.server uses exactly the same mechanism.

ModuleManager registers a new base schema for every module. Unlike the schema inside the module, this newly created schema exists to transform string module names into module instances.

If you were to strip away error handling and convenience features, the core of module instance dependency injection in ModuleManager boils down to this simple schema transformation pipeline:

// Approximation of the actual logic...
class InternalModuleRepo
{
// ...
buildModuleReferenceSchema(moduleName) {
return new Schema()
.normalizer('$is-string')
.normalizer('$pascal-case')
.transformer(name => {
const module = this.getModule(name); // throws if name is not a known module
// if (name !== moduleName) { assigned to different value; check compatibility }

return module.getInstance(); // return existing known instance or construct new one
})
.validator(instance => {
const module = this.getModule(moduleName);
if (!(instance instanceof module.ModuleClass)) { throw new Error('incompatible!') }
return instance;
})
}
// ...
}
note

Do not confuse the property that holds a module's configuration with (often similarly named) properties elsewhere that hold references to a module instance!

Providers

In the previous example, we had a 1:1 association between a module reference ("Server") and a registered module that has that name.

Where things get more interesting is when a module setting marks itself as a provider:

pet.js
import { ModuleManager, Schema } from '@versionzero/module-manager';

class AbstractPet {
static moduleProvides = 'Pet';
speak() { throw new Error('implement in subclass') }
}
class Cat extends AbstractPet {
speak() { console.log('meow'); }
}
class Dog extends AbstractPet {
speak() { console.log('woof'); }
}
class MyApp {
static moduleSchema = new Schema().property('furryFriend', new Schema('Pet').required())

async main() {
this.furryFriend.speak();
}
}

await new ModuleManager()
.register(Cat)
.register(Dog)
.register(MyApp)
.run();
% node pet.js -f cat
meow

Both Cat and Dog inherit the provides module setting from AbstractPet, which marks their module implementations at providing values that are compatible with module references for Pet.

In addition to registering Cat and Dog as reference schemas as described in the previous section, the Pet reference is also registered as a schema, but it has a transform that checks whether its assigned input name corresponds to a module that has Pet in its provides setting.

This is an example of the "SOLID" Dependency Inversion Principle of "depend on abstractions".

The selection of a concrete implementation becomes a dependency-injected configurable value.

For a more complex fully documented example of providers, please refer to the hello-world-advanced example