Architecture
The ModuleManager library is a relatively thin abstraction on top of the Configurator and Schema
libraries (which were created for this purpose!)
TL;DR - dependency injection is just a specialized type of configuration, and IoC handles both!
Rationale
The motivation to build ModuleManager emerged from a frustration described
in the intro:
One challenge that emerges from a decoupled software architecture is that an ever-increasing number of components needs to be interconnected and managed at runtime. What starts as a few cleanly separated modules quickly becomes a web of interdependencies that must be carefully orchestrated. Configuration becomes scattered across multiple locations, making it difficult to understand how the system fits together. Dependencies between modules create implicit coupling that breaks encapsulation. And coordinating the startup and shutdown of various subsystems becomes increasingly fragile as the dependency graph grows.
When you want to focus on building your application, managing configuration, dependencies, and lifecycles is a distraction. Avoiding the issues leads to inflexible systems with unexpected hard-coded configuration. Quick and dirty implementations introduce leaky abstractions that require any changes be synchronized between unrelated subsystems. Properly handling things takes a lot of time, and may result in lots of low-value glue code to maintain.
Solving this challenge using frameworks helps, but tight coupling inevitably makes it hard to migrate to newer technologies in the future, or even to keep up with breaking changes within the framework. Getting stuck with an obsolete framework is one of the major causes of technical debt.
The approach taken by ModuleManager is to keep a "light touch", and to not require
deep integration at all.
Avoiding Introducing Typed Dependencies
In general, typed dependencies are easily managed when kept to the application level. Where things become a chore is when all subsystems also need to share a typed dependency. Tight coupling implies continual maintenance at best, future tech debt at worst.
This is why ModuleManager does not impose a rigidly typed contract on the "modules" it manages.
Nearly anything can be treated as a module; if it already is implemented as a class with a zero argument constructor, it may work without any change at all. Static "module metadata" can be provided to customize behavior. Optional lifecycle methods can all be renamed to avoid conflicts. If the candidate module is code that cannot be modified, it can either be registered with "sidecar metadata", have a thin wrapper, or be pre-instantiated.
Configuration Schema
Allowing modules to advertise their own configuration requirements has the significant benefit of keeping the validation code co-located with the code that needs it. If the module implementation evolves to require new or different configuration, the application code does not need to be modified. If the module is removed, the configuration also goes away.
The "language" of configuration for ModuleManager is defined using a Schema:
class Notifier {
static moduleSchema = new Schema('object')
.property('events', new Schema('array').property('*', new Schema('string').validator('$alphanum')))
.property('webhook', new Schema('string').validator('$url'))
}
But per the previous section, the schema can be fully defined using a verbose but straightforward "data" description, thus avoiding introducing any import dependencies:
class Notifier {
static moduleSchema = {
properties: {
'events': { base: 'array', properties: {'*': {base: 'string', handlers: {validators: ['$alphanum']} }}},
'webhook': { base: 'string', handlers: { validators: ['$url'] } }
}
}
}
Note: there is active work to enable use of an alternate "shorthand" data syntax for schema specification to avoid the need to exactly match the schema serialization format
Dependency Injection as Configuration?
One might reasonably question whether it makes sense for ModuleManager to conflate user-provided configuration with module dependency injection.
However, 1) the goal of the core Configurator is to make formalize configuration to become usable as 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, and to only instantiate
the selected implementation. This maps directly to a Schema transform from a string name to a singleton instance.
So, consider ModuleManager an experiment in proving the benefits of merging these concepts!
Inversion of Control and Transitive Dependencies
The concept of dependency injection does not require any sort of library; you can write your own boilerplate to inject dependencies (and in fact, an application that initializes subsystems with a long explicit sequence of DI boilerplate is far superior in design to an application built with subsystems that have direct dependencies on specific concrete implementations!)
However, this means that the application itself may now have dependencies on internal details of inner sub-sub-sub-systems.
This is where Inversion of Control becomes useful. Instead of the application directly sequencing initialization and dependency injection, sub-systems are formalized (just enough) into modules that can advertise their dependencies in an introspectable manner, and control is delegated ("inverted") to a system that tracks dependencies, determines what needs to be instantiated, and injects them where needed.
ModuleManager implements this role, often referred to as an "IoC Container".
When the run() method is called, the initial set of instantiated modules (such as the main module)
are checked for dependencies (as defined by their module schema), to populate configuration data for the module.
This causes module references inside this configuration to be instantiated, triggering those modules to have
their configuration populated, and so forth. This cascading effect is the natural result of Configurator
loading the top-level Schema aggregated from all the known modules. The key is that only modules referenced
in this dependency graph are instantiated, and only instantiated modules have their configuration populated.
After the configuration/instantiation process is complete, ModuleManager proceeds through the various
lifecycle phases, kicking off with the init phase, where dependency injection actually takes place.