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:
.env
into process.env
(using dotenv)${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.apps
in the config/app.js
file.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:
index.js
.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.
Before considering the lifecycle of the App singleton, it's worth noting the various states that an app could be in.
A service has a few steps it goes through:
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).App.$app_name
.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.
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:
load
method is called (awaited).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.App
instance as App.$app_name
.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.");
}
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;
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 = {};
}
}
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.
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}`);
});