CAROLINA DOCS


Services & Lifecycle

The App Instance

A Carolina application is managed by a singleton service called App, exported directly by the package @carolina/core. You can access this instance like so:

const App = require('@carolina/core');

This instance is responsible for managing all the apps in your overall project. Early on when you run your project, the App service does the following things:

  • Reads the contents of your projects .env into process.env (using dotenv)
  • Reads the contents of ${process.env.CONFIG_DIR}/app.js. Note that if the CONFIG_DIR is not otherwise defined, the directory config/ is treated as the configuration directory.
  • Maintains a map of app names to app import paths as defined by apps in the config/app.js file.
  • Initially loads some or all of the listed apps.

The way that last step occurs is important as to when and how in your project you can access app services and features and in what state you can expect them to be.

Before an overview about how that works, note that there are generally two ways your project runs:

  • By invoking a CLI command via index.js.
  • By running the express server with npm start.

The aliases object in config/app.js defines two aliases for groups of apps. One is ":basics", which is loaded in index.js before the CLI command is parsed. The other is "*", which is all apps, and that is loaded when running the server with npm start. Additionally, some commands load additional apps at the beginning of their execution. The reason all apps are not automatically loaded in the CLI entry-point is that not all commands require all apps to be loaded. For example, the routes app builds an entire express router and the db app connects to a MongoDB instance.

Lifecycle

Before considering the lifecycle of the App singleton, it's worth noting the various states that an app could be in.

Service Lifecycle

A service has a few steps it goes through:

  • Being constructed: This can happen at any time and you can access service methods even when apps haven't been loaded.
  • Being loaded: This means invoking the load method of the service as well as the service's loadApp method against every other app (which may involve reading files from their directories - this is called integration and is described more later).
  • Being mounted to the App instance: This means making the service accessible via the App instance as App.$app_name.
  • Having other apps mounted to the service: This means making other loaded services accessible to the service as this.$other_app_name. This step happens in conjuction with the previous step.

Each app should document what functionality can be relied upon before it is loaded and what is only available after it is loaded.

These steps are all done by the App instance as described in the following section.

App Lifecycle

You project usually launches in one of two ways, via index.js (your custom CLI) or by npm start. At the beginning of each process, the method App.initialLoad is called. With index.js, only a few basic apps are loaded and with npm start all apps are loaded.

The App instance also has $ method which can be used to access any app, for example App.$('config') can be used to get the config service. That method will return a singleton instance of the service whether its loading methods have been called or not. If it doesn't exist yet it will be constructed.

The initialLoad loads each app in the order listed (which by default is and always should be in the order apps appear in config/app.js). The process of initialLoad is as follows:

  • Each app is loaded one at a time:
    • The app's service's load method is called (awaited).
    • The app's service's loadApp method is called against all other app directories (whether they are to be loaded or not). The method is awaited. More on how this works is later.
    • The app's service is mounted to the App instance as App.$app_name.
  • All apps' services are given a reference to all other apps' services that have been constructed.

This has implications for accessing other services. Files other than your app's index.js file (ie, main/index.js) may be read in by other apps when their loadApp method is called. For example, the file main/router.js is called when the "routes" app is loaded. Files within your app that are going to be read by other apps are part of integrations. All of this has implications when and where you should access other apps' services:

Rule #1: In a service constructor do not rely on other apps or services existing at all.

Your constructor should initialize some variables and do some very basic things.

Rule #2: In the load method of your service only other apps that are before that one in config/app.js will have been loaded, and you can't rely on them being accessible as this.$app_name.

The "config" app is always loaded first, so from any other app service load method you can rely on all configuration being accessible. You need to get the config service from the App object.

Example:

async load() {
// ok
let appName = this._app.$('config').get('app', 'appName', "Default App Name");
// not ok in load
let appName = this._app.$config.get('app', 'appName', "Default App Name");
// also not ok in load
let appName = this.$config.get('app', 'appName', "Default App Name");
}

Rule #3:: Integration files like main/router.js are executed when the calling app is loaded. In the case of main/router.js, this means the "routes" app, not the "main" app. In the case of main/cli.js, this means the "cli" app, not the "main" app. Only apps that are before that app will have been loaded.

This only applies to the root scope of such files. In the actual handlers for routes, db model instance methods, etc. all apps that are going to be loaded will be loaded (and mounted).

Example:

// some-app/router.js

// other stuff

// ok
App.$('config').get('some-app', 'some-key');
// not ok
App.$config.get('some-app', 'some-key');

router.get('/', (req, res) => {
// ok, config will have been mounted
App.$config.get('some-app', 'some-key');
// other stuff
});

Rule #4: Know what circumstances your service code may be executed in.

When writing your service, you should know when you expect it to be run. If your code in a service method is only going to run when the app webserver is running, all services will be loaded and mounted. If that code may be called as part of a custom CLI command, you should call await App.initialLoad(appName1, appName2, etc) for all apps your service depends on at the beginning of that command's execution function. This is documented more in the section on writing CLI commands.

If you want to use another service if its available and loaded but don't require it, you can just check to see if it is present.

if (App.$logger) {
App.$logger.debug("Logging is enabled.");
}

Defining Services

Each app defines a service in its index.js file. Here is a minimal example:

// main/index.js
const { BaseService } = require('@carolina/core/app');

class MainSvc extends BaseService {}

module.exports = MainSvc;

Constructor

If you define a constructor, you must call super(args). The constructor is also a good place to initialize service attributes. Otherwise, you should keep the constructor method simple.

class MainSvc extends BaseService {

constructor(args) {

super(args);

this.data = {};
}
}

Lifecycle Methods

There are two key lifecycle methods, load and loadApp. Both are async and are "awaited" by the App instance.

The load method happens first and is where you should do all initialization that must occur before your app can be fully functional.

You can access the App instance at this._app within a service method.

async load() {

// get some config value
let appName = this._app.$('config').get('app', 'appName');

// do some setup here
}

The loadApp method supports integrations. It is called against the directories of all other apps. Its main purpose is to allow you to look for certain files within another app's directory that are there to interact with your service. For example, the existing "db" service uses this method to read models.js from all app directories and then registers mongoose schemas.

Let's say that you are writing an app that allows other apps to integrate with it by defining a set of objects that should accessible from your service. You would need to document what file a participating app should define and what it should export. Suppose that you are expecting other apps to export an objects variable from a file called my_objects.js within their app directories. The BaseService class defines a method for this, _require(appPath, fileName)

async loadApp(appName, appPath) {
// load "objects" from the app's my_objects.js, if it exists
let { objects } = this._require(appPath, 'my_objects');
if (objects) {
this.collectedObjects = {
...this.collectedObjects,
...objects
};
}
}

Note that the _require method will return an empty object if a 'MODULE_NOT_FOUND' error takes place. This usually means the app doesn't define anything to interact with your service. This is fine, not every app defines models for the "db" service.

Other Methods

You can define whatever methods you want on your service, just don't use the names of these methods:

  • _require: The method for collecting from other apps.
  • _load: A loading method for internal use.
  • _updateServices: An internal method for updating mounts.
  • load: A lifecycle method described above.
  • loadApp: A lifecycle method described above.

Here is an example of adding an add method to your main app's service:

// main/index.js

const { BaseService } = require('@carolina/core/app');

class MainSvc extends BaseService {

constructor(args) {
super(args);
}

add(a, b) {
return a + b;
}
}

You could then access that method from your router.

// main/router.js
const express = require('express');
const router = express.Router();

const App = require('@carolina/core');

router.get('/', (req, res) => {
let sum = App.$main.add(5, 10);
return res.send(`Sum is ${sum}`);
});

CAROLINA DOCS