Skip to main content

Lifecycle Management

ModuleManager acts as an "Inversion of Control" (IoC) container, where it determines what modules are referenced as dependencies, instantiates them as needed, and then calls lifecycle methods on the modules in phases.

Module Lifecycle Methods

Modules may opt-in to having lifecycle methods called by ModuleManager.run() by implementing any of the following:

class MyModule
{
async init(config) { /*...*/ }
async start() { /*...*/ }
async main() { /*...*/ }
async stop() { /*...*/ }
async terminate() { /*...*/ }
}

In the case existing code has a conflicting method name, you can either set the lifecycle: false module setting to ensure they aren't called at all, or you can set individual module settings corresponding to each default lifecycle method name to an alternate name, e.g.

class MyModule {
static moduleInfo = {
lifecycleInit: "setup",
lifecycleStop: "halt"
}

async setup(config) {
// ...
}
async halt() {
// ...
}
}

See the Module Settings section for details on renaming lifecycle methods.

The existence of a main method on a class implicitly sets the isMain module setting that defines the application's entry point. Only one module may have the isMain setting active.

...Initialization

The initialization phase is where the module will receive any requested configurables. This may be done explicitly by implementing the init lifecycle method:

class MyModule {
static moduleInfo = {
configurables: [{field: 'magic', type: 'boolean'}]
}
async init(config) {
this.magic = config.magic;
}
async start() {
if (this.magic) {
//..
}
}
}

or by relying on dependency injection:

class MyModule {
static moduleInfo = {
configurables: [{field: 'magic', type: 'boolean'}],
inject: true // unnecessary in this case
}
async start() {
if (this.magic) {
//..
}
}
}

The inject module setting is implicitly set if a module has configurables but does not implement init. You can also use both by setting inject (which is performed first) and then using init for any additional initialization code, but this may be even more confusing than inject alone.

From an architectural perspective, implementing init(config) is a less magical approach that may be preferable if you're not fully committed to trusting dependency injection.

Alternatively, you may consider formalizing the injected fields as part of your API surface, and ensure that they have reasonable defaults if not injected.

caution

The initialization phase is where modules acquire handles to other dependencies, but they should yet not assume that those dependencies are available for use.

Example init activities: set up private logger, save references to other modules

...Start

The optional start lifecycle phase is where it should be considered "safe" for a module to invoke methods on its dependencies. It's entirely possible that a module's dependency may not yet be started yet (lifecycle methods are called in a best-effort depth-first dependency order starting from the application, but circular references are allowed, which may break the assumption) so modules should be written to be resilient, typically by retrying.

Example start activities: start server listener, connect to database

...Main

Most lifecycle methods are expected to complete their work within a short time (a 30-second timeout is set by default). The main entrypoint on the other hand is expected to not return until the application is done.

Only one module should typically implement main. If nothing implements main, the application will proceed straight through the rest of the lifecycle events and exit.

...Stop

In this lifecycle method, you should reverse whatever happened in start.

Example stop activities: shut down server, close sockets

...Terminate

The terminate method is used to release any resources acquired during init. While not strictly necessary, this allows the full lifecycle to be run in a loop for testing or other purposes without "leaking".