bootwire
Application and dependencies bootstrap for node.js.
npm install --save bootwire
A super-minimal way to boot and compose application dependencies using ES6.
Bootwire is a very simple library that leverages ES6 destructuring
to provide a no-black-magick-please way to boot node js applications
and perform dependency injection.
Features
- Support asynchronous boot (ie. wait for database connection to be ready).
- Simplify tests allowing to mock parts of your application without
stub
and spies
. - Predictable top-down flow.
- IoC with plain old functions and constructors.
- No
require
hijacking. - No
module.exports['@something-magick-and-weird-here']
- Wire components together not to the IoC container
Getting Started
Bootwire provides a way to create an application context and pass it down to a boot procedure.
The context object is just an object that exposes a few methods to manipulate and use it:
$set
: set one or many properties$provide
: set a property to the result of the invocation of a provider function.$wire
: wire a function passing the context as parameter$wireGlob
: wire any files matching a glob pattern$waitFor
: await for specific dependencies to be wired$get
: get a value in the context by key or by path
Using $set
and $provide
on the context object will ensure that all of its properties will be only set once, allowing to inject providers, services connections, configs and so on during tests.
The boot procedure to which the context is passed is a function that acts as the single starting point of an application.
Dependency injection
As opposed to many IoC containers bootwire
takes a radical approach to handle dependencies:
- Dependencies resolution is not lazy: all of the components are wired together during the boot phase.
- Dependency injection follows one and only one simple rule: if a dependency is already set it will not be set again.
Which result into an extremely simple way to replace a component or a setting during tests: just set it before the boot phase.
require('./app').boot().catch(console.error);
const bootwire = require('bootwire');
function bootProcedure({$provide, $set, $wire} ) {
$set({
logger: require('winston'),
config: require('./config')
});
await $provide('db', async function({config}) {
return await MongoClient.connect(config.mongodbUrl);
});
await $provide('userRepository', async function({db}) {
return new UserRepository({db});
});
await $wire(startExpress);
}
module.exports = bootwire(bootProcedure);
module.exports = function({router, userRepository}) {
router.get('/users', async function(req, res) {
res.json(await userRepository.find());
});
};
Integration tests are now extremely easy:
const app = require('./app');
it('does something', async function() {
await app.boot({
config: {port: await randomAvailablePort()},
db: fakeMongodb
});
});
And unit tests as well:
const UserRepository = require('./services/UserRepository');
it('retrieves all the users', async function() {
const repo = new UserRepository({db: {
find() {
return Promise.resolve(usersFixture);
}
}});
deepEqual(await repo.find(), expectedUsers);
});
The boot procedure also accepts multiple initial contexts that will be merged
together, doing so will be easy to $provide a default initial context on each tests
and override it on each test case:
const app = require('./app');
const defaultTestContext = {
config: defaultConfig
};
it('does something', async function() {
await app.boot(defaultTestContext,
{
config: {port: await randomAvailablePort()},
db: fakeMongodb
}
);
});
Usage patterns for complex applications
Split bootstrap into phases
const {promisify} = require('util');
const express = require('express');
const winston = require('winston');
module.exports = async function({$wireGlob, $set, $context}) {
const config = require('./config');
const app = express();
const logger = winston;
$set({
config,
app,
logger
});
await $wireGlob('./services/**/*.wire.js');
await $wireGlob('./middlewares/**/*.wire.js');
await $wireGlob('./routes/**/*.wire.js');
await promisify(app.listen)(config.port);
logger(`Application running on port ${config.port}`);
};
Top Down $wireGlob
$wireGlob
never process a file twice and ensure files are always processed in
depth order from the most generic path to the deepest.
It can be leveraged to delegate complex wiring from a general boot file to more
specialized procedures.
const {promisify} = require('util');
const express = require('express');
module.exports = async function({$wireGlob, $set, $context}) {
const app = express();
$set({
app
});
await $wireGlob('./routes/**/*.wire.js');
await promisify(app.listen)(config.port);
};
module.exports = async function({$wireGlob, $set, $context}) {
await $wireGlob('./middlewares/**/*.middeware.js');
await $wireGlob('./api/**/wire.js');
};
Bootstrap of many components
Using $wireGlob
and $waitFor
is possible to create self contained modules that
can be wired together without having a main boot procedure knowing about everything.
module.exports = async function({$wireGlob}) {
await $wireGlob('./*.wire.js');
};
module.exports = async function({$waitFor, $set}) {
const {correlator} = await $waitFor('correlator');
$set('logger', new CorrelationLogger(correlator));
};
module.exports = function({$set}) {
$set('correlator', new ZoneCorrelator());
};
Wiring classes and services
One way to perform IoC without any magic container is to use explicitly the
constructor of services to inject dependencies.
Although it may seem a tight constraint it is actually a good way to create
independent components that are easy to reuse in different context
and applications.
This explicit and manual injection is intended and is necessary to achieve one of
the goal of bootwire
: don't require components to depend on the dependency injection
framework.
class UserRepository {
constructor({db}) {
this.collection = db.collection('users');
}
find() {
return this.collection.find().toArray();
}
}
Note how the UserRepository
class is completely usable both with bootwire
:
module.exports = function({$provide}) {
await $provide('db', async function({config}) {
return await MongoClient.connect(config.mongodbUrl);
});
await $provide('userRepository', async function({db}) {
return new UserRepository({db});
});
};
And without bootwire
:
async main() {
const db = await MongoClient.connect(process.env.MONGODB_URL);
const repo = UserRepository({db});
const users = await repo.find();
console.info(JSON.stringify(users, null, 2));
}
main().catch(console.error);
Api
Classes
- App :
Object
App is a bootable application.
- Context :
Object
Context
is the main application context object. It acts as dependency
container and is intended to be passed down through all the initialization
procedure.
Functions
- bootwire(bootAndWireFn) ⇒
App
Build a new App that will use invoke the boot and $wire procedure passed
as parameter on boot.
Example usage:
const bootwire = require('bootwire');
const app = bootwire(require('./src/boot'));
if (require.main === module) {
app.boot()
.catch((err) => {
console.error(err);
process.exit(1);
});
}
module.exports = app;
module.exports = app;
Example tests:
const app = require('../..');
describe('app', function() {
it('runs', async function() {
const port = await getRandomPort();
await app.boot({
config: { port }
});
await request('http://localhost:${port}/health');
// ...
});
});
App : Object
App is a bootable application.
Kind: global class
app.boot(...initialContext) ⇒ Promise
Start an application with an initialContext
Kind: instance method of App
Returns: Promise
- A promise resolving to Context when the boot procedure will complete.
Param | Type | Description |
---|
...initialContext | Object | One or more object to be merged in the context and build the initialContext. Note that any function already present in the prototype of Context (ie. $wire, $set, $provide) will NOT be overriden. |
Context : Object
Context
is the main application context object. It acts as dependency
container and is intended to be passed down through all the initialization
procedure.
Kind: global class
context.$context ⇒ Context
Returns the same context instance.
Useful in factory and provider functions to destructure both the context
and its internal properties.
ie.
module.exports = function setupRoutes({app, context}) {
app.get('/users', require('./users.routes')(context));
}
Kind: instance property of Context
Returns: Context
- the context object itself
context.$set(keyOrObject, value)
$set
sets one or more keys in the context if they are not already present.
ie.
$set('logger', winston);
$set({
config: require('./config'),
logger: winston
});
Kind: instance method of Context
Param | Type | Description |
---|
keyOrObject | String | Object | a string key in case of single assignment or a key-value map in case of multiple assignment. |
value | Any | the value to be assigned in case a string key is provided. |
context.$provide(key, fn) ⇒ Promise
$provide
allows to assign to a contpext key the result of a function (provider)
that is invoked with context as parameter.
If the context key is already taken the $provide
returns without doing
anything.
The function to be evaluated can be synchronous or asynchronous. In either
cases $provide
returns a Promise to wait for to be sure the assignment took
place (or has been rejected).
Kind: instance method of Context
Returns: Promise
- a promise that will be resolved once $provide
has completed the
assignment or refused to assign.
Param | Type | Description |
---|
key | String | the key to be assigned |
fn | function | the function to be evaluated. Context will be passed as param to this function. |
context.$wire(...fns) ⇒ Promise
$wire
invokes one or more asynchronous function passing the context as first parameter.
Kind: instance method of Context
Returns: Promise
- a promise that will be resolved once fn
will complete.
Param | Type | Description |
---|
...fns | function | the function or functions to be evaluated. Context will be passed as param. |
context.$wireGlob(...patterns) ⇒ Promise
$wireGlob
requires and wires files by patterns from the caller folder.
ie.
await $wireGlob('routes/*.wire.js');
Kind: instance method of Context
Returns: Promise
- A promise that will be resolved once all the files are required
and wired
context.$waitFor(...keys) ⇒ Promise
$waitFor
wait for the resolution of the dependencies passed as argument and
then it returns the context;
const {logger} = await $waitFor('logger');
Kind: instance method of Context
Returns: Promise
- A promise resolving to the context once all the dependencies
are ready
Param | Type | Description |
---|
...keys | String | A list of dependencies to be awaited |
context.$get(key, [defaultValue]) ⇒ Any
Get a value from context by key or path.
const context = await app.boot();
const port = context.get('config.port');
const info = await request(`http://localhost:${port}/api/info`);
Kind: instance method of Context
Returns: Any
- the value if found or defaultValue
.
Param | Type | Description |
---|
key | String | a single key or a path of the form 'key1.key2.key3'. |
[defaultValue] | Any | a value to be returned if the key is not found. |
bootwire(bootAndWireFn) ⇒ App
Build a new App that will use invoke the boot and $wire procedure passed
as parameter on boot.
Example usage:
const bootwire = require('bootwire');
const app = bootwire(require('./src/boot'));
if (require.main === module) {
app.boot()
.catch((err) => {
console.error(err);
process.exit(1);
});
}
module.exports = app;
Example tests:
const app = require('../..');
describe('app', function() {
it('runs', async function() {
const port = await getRandomPort();
await app.boot({
config: { port }
});
await request('http://localhost:${port}/health');
});
});
Kind: global function
Returns: App
- A bootable App
instance.
Param | Type | Description |
---|
bootAndWireFn | function | The function to be called. |