Configuration
ModuleManager builds on Configurator, which is documented in its own section on this site.
It is useful to understand Configurator's ConfigurationSchema system to understand field definitions and child schemas.
Declarative Schema
Note for Configurator users: rather than the developer programmatically defining the configuration schema hierarchy and configurable fields,
ModuleManager automatically creates a child schema per module based on the declarative configurables module setting.
Keeping definition of a module's configurable fields 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.
class Demo {
static moduleInfo = {
configurables: [
{ field: "verbose", type: "boolean", flagHint: "V" },
{ child: "server", configurables: [
{field: "host", type: "string", validator: {$or: ["$ipv4", "$ipv6", "$resolves"]}},
{field: "port", type: "number", validator: "$positive", default: 3000}
]}
],
inject: true
}
doDemoStuff() {
// can access this.verbose, this.server.host, this.server.port
}
}
moduleManager.register(Demo)
Let's say this was in myapp.js, you could configure this module with:
% node myapp.js -V --demo-server-host 127.0.0.1
Modules as Configurable Types
Note for Configurator users:: Every module is associated with a dedicated type resolver!
In the Configurator library, every schema field type is associated with a type resolver that converts a configuration assignment (usually a string)
to a typed output value. ModuleManager builds on this system by associating a new dedicated type resolver with every module that is registered.
This resolver returns a singleton instance of the module, instantiating it on demand if necessary.
In other words, setting a configurable type to the name of a module acts as a request to have an instance of that module set in
the module's configuration.
import { ModuleManager } from '@versionzero/module-manager';
class Greeter {
static moduleConfigurables = [{ field: 'message', required: true }]
greet() {
console.log(this.message);
}
}
class WelcomeApp {
static moduleConfigurables = [{ field: 'greeter', type: 'Greeter'}]
async main() {
this.greeter.greet();
}
}
await new ModuleManager()
.register(Greeter)
.register(WelcomeApp)
.run()
% node welcome2.js --greeter-message="hello world"
This isn't especially useful on its own, as it's basically just a direct dependency with more steps.
Providers
Where things get more interesting is when a module setting marks it as a provider.
class Pet {
// just a base class, do not register directly
static moduleProvides = "Pet";
speak() { throw new Error('implement in subclass') }
}
class Cat extends Pet {
static moduleConfigurables = [{field: }]
speak() { console.log('meow'); }
}
class Dog extends Pet {
speak() { console.log('woof'); }
}
class MyApp {
static moduleConfigurables = [{field: "furryFriend", type: "Pet", required: true}]
async main() {
this.furryFriend.speak();
}
}
await new ModuleManager()
.register(Cat)
.register(Dog)
.register(MyApp)
.run();
% node myapp.js --pet cat
The existence of a provider creates an indirect schema field type that can only be set to
a matching module. In this case, both Cat and Dog inherit the provider setting from Pet.
This is where the "SOLID" Dependency Inversion Principle of "depend on abstractions" comes into play.
The selection of a concrete implementation becomes a dependency-injected configurable.
For a more complex fully documented example of providers, please see the
hello-world-advanced example on GitHub
Existential Crisis
One might reasonably question whether it makes sense for ModuleManager to conflate user-provided configuration with module dependency injection.
However, recall that 1) the goal of the core Configurator is to make formalize configuration to become nearly a data model, and 2) in many cases,
there is a need to allow the user to select a particular concrete implementation to use for a given interface. Consider ModuleManager an
experiment in proving the benefits of merging these concepts!