# Services
Complex controllers that contain a lot of domain logic is a classic anti-pattern that can lead to duplicated code and difficult-to-read controllers, among other issues. However, Nodewood's Models lack database access, meaning the tradition ActiveRecord pattern of performing all your saving/loading on them won't work. This is where Services come into play.
A Service contains a collection of actions commonly taken in that domain. In the case of the "users" feature, users can be created, logged in, updated, etc. These are actions in the UsersService
that are called from the UsersController
and return one or many UserModels
.
Multiple services can be used per Controller, as necessary. A Controller may need to deal with both Users and Comments, and might call a CommentsService
action to save a comment and a UsersService
action to update the total number of comments made by a user.
TIP
All services for all enabled features are automatically injected into your Controllers and other Services. This means that you can use those services from a Controller without needing to manually create it, and you can use any other service from the service you are in.
To use an automatically-injected service, use the camelCase version of that service's name. For the UsersService
service:
this.usersService.login(data);
# Database
Nodewood makes use of the MassiveJS (opens new window) database library, as it fits the service pattern perfectly - you build queries on tables that return objects which are parsed into Nodewood models when they are passed into the constructor. However Nodewood services include a few convenience methods and members to make working with MassiveJs a bit easier.
# db
This is the MassiveJs instance that you would use to execute queries against: const users = await this.db.users.findOne(1);
.
# model
This is the model class this service returns from its database functions.
# table()
This is a shortcut to the correct table to use for the current Service. This allows you to start all database calls the same: const users = await this.table().findOne(1);
. Further, if you are in a transaction, you can pass the current transaction to table(tx)
to ensure that the call happens inside the transaction.
This abstraction helps power the other service helpers.
# find()
Finds one model by its id
field.
# findBy()
Finds a collection of models by a simple query. Accepts pagination parameters page
and per
, as well as any other options values supported by MassiveJS (opens new window).
# count()
Counts the total collection of models in the database for a query. Used in conjunction with findBy()
to identify the total number of pages when listing.
# insert()
Inserts a collection of values into the database as a new row. An array of keys can optionally be passed as well. This will restrict the inserted data from the values to only the entries defined by the keys, so that a form's body can safely be used for the values
parameter. This allows you to write simple code that also prevents attackers from attempting to insert fields they're not allowed to (like account_type
, etc)
const { USER_CREATE_FORM_FIELDS, } = require('#features/users/lib/validators/UserValidator');
await this.usersService.insert(tx, req.body, USER_CREATE_FORM_FIELDS);
In the above code, only the fields defined by USER_CREATE_FORM_FIELDS
are picked from the request body and inserted into the users table.
# update()
Updates a database row as defined by an ID with a collection of values. Similar to insert()
, an array of keys can optionally be passed as well to limit the values that are updated.
const { USER_EDIT_FORM_FIELDS, } = require('#features/users/lib/validators/UserValidator');
await this.usersService.update(tx, req.params.id, req.body, USER_EDIT_FORM_FIELDS);
In the above code, only the fields defined by USER_EDIT_FORM_FIELDS
are picked from the request body and used to update the user.
# delete()
Deletes an instance by ID.
# Transactions
If you examine the source code for the main Service class (located at wood/api/Service.js
), you'll notice the first parameter for all these function is tx
, which means you can wrap these calls in a transaction in case you need to call multiple service actions to achieve your controller's ends - especially useful when getting the count for a pagination query:
await this.withTransaction(async (tx) => {
const {
page = DEFAULT_PAGE,
per = DEFAULT_PER,
search = '',
} = req.query;
const pages = Math.ceil((await this.usersService.count(tx, {})) / per);
const users = await this.usersService.getSearchPage(tx, {
search,
page: Math.min(page, pages),
per,
});
});
Any errors that are thrown inside a withTranaction()
call will be caught and handled as normal, but will cause any database calls that have been initiated with the provided tx
object to be rolled back safely.
If you don't wish to have a service action happen inside a transaction (for example, to update a "failed" count for a user before throwing an error that will roll back all transaction-based queries), you can pass null
for the tx
parameter, and the query will be executed outside the transaction.
Nodewood uses the Nodemailer (opens new window) library to send transactional emails. You will need a mailer service (like Amazon SES (opens new window) or Mailgun (opens new window)) to connect to, but Nodemailer is very versatile and easy to configure for whatever mailer service you choose. The library comes installed by default, but you will need to add configuration values to app/config/email.js
so it knows what mailer to use and what credentials to provide it.
TIP
It is not recommended to use Nodewood to send campaign (i.e. mailing list) emails. A great amount of work and care go into ensuring deliverability for campaign emails, and you are far, far better off engaging the services of a dedicated campaign mailer than trying to replicate them on your own. In addition, you do not want to mix the IP addresses you use for sending transactional mail and campaign mail, in case a over-zealous ISP adds your campaign mail IP address to a blacklist. You would be unable to send transactional mail to those customers as well, which could include payment alerts or other critical and time-sensitive notifications.
# Configuration
Nodemailer does not send email itself, it instead proxies your emails to a third-party email service. These services ensure reliability, deliverability, and trackability for your send email. In order to use one of these services, you will first need to sign up for an account:
Consult Nodemailer's documentation (opens new window) for how to set up other mail transports.
# Sending
By default, Nodewood sends a variety of emails (account confirmation, password reset, etc). If you have completed the above configuration step, those emails should now be sending correctly. However, you will likely wish to send application-specific emails yourself.
#lib/Email
exports a sendMail()
function that allows you to send mail. The first parameter to this function is the mailer
object configured above and passed into services via the constructor. The second parameter is an options object used to specify who to send to, who to send from, add a message subject, body, etc.
You can either pass an html
key to this options object and use that HTML directly, or you can pass a template
key containing the full path to an EJS (opens new window) template to use and a data
key containing data to substitute into the template. Typically templates are stored in a feature's api/emails
folder, but if you have a collection of templates that are common to your application, you could, for example, create an app/emails
folder and store them there.
You can always pass a text
key to this options object to define a text-only email body to display to email readers that don't display HTML.
const { sendMail } = require('#lib/Email');
await sendMail(this.mailer, {
from: `${appName} <${appEmail}>`,
to: user.email,
subject: `${appName}: Your weekly results`,
template: resolve(__dirname, '../emails/WeeklyResults.ejs'),
data: {
total: results.total,
userName: user.name,
appName,
appUrl,
},
});
# Adding a new service
To add a new service with some sample code to get you rolling, run:
nodewood add:service FEATURE NAME
Replace FEATURE
with the name of the feature you wish to add a service for, and NAME
with the name of the service you wish to create. (e.g.: nodewood add:service scheduling calendar
to add CalendarsService
to the scheduling
feature.)
# Service example: Communicating with a third-party API
No one is an island, and these days, it's pretty rare to find an app that doesn't rely on external services to get things done. You might need file storage on S3, email delivery with Mailgun, or any other number of capabilities that external services provide. But where's the best place to put the code that does this communication? Not in a Controller, since we want to keep those as thin as possible. But if we place this access in a Service, we keep our Controllers clean, and we can create compose-able functions that can be used safely in multiple places in our app.
# An example: A Blog Post Importer
Let's assume we're importing blog posts from an external service for use in our app. (Maybe our app does spelling/grammar checking, or offers blog post backups.) These posts are available from https://justsomeblog.com/posts, which returns JSON in the following format.
{
"posts": [{
"title": "A post title",
"author": "The post author",
"date": "A timestamp",
"contents": "The post contents"
}, ...]
}
We'll want to start with defining a Model to work with. Assume we already a have a feature called imports
that we're working in:
$ nodewood add:model imports post
This will create a model in app/features/imports/lib/models/PostModel.js
, which we can update to take into account the fields we know of:
const moment = require('moment');
const { Model } = require('#lib/Model');
const { FieldNumber, FieldString, FieldDate } = require('#lib/Fields');
/**
* @type {Object} Field configuration.
*/
const POST_MODEL_FIELDS = {
id: new FieldNumber({ label: 'ID' }),
title: new FieldString({ label: 'Title' }),
author: new FieldString({ label: 'Author' }),
date: new FieldDate({ label: 'Date' }),
contents: new FieldString({ label: 'Contents' }),
};
class PostModel extends Model {
/**
* Constructor.
*
* @param {Number} id - The internal ID of this post.
* @param {String} title - The title of this post.
* @param {String} author - The author of this post.
* @param {Date} date - The date this post was published.
* @param {String} contents - The contents of this post.
*/
constructor({ id, title, author, date, contents } = {}) {
super(POST_MODEL_FIELDS);
this.id = id;
this.title = title;
this.author = author;
this.date = moment(date);
this.contents = contents;
}
/**
* Convert model to JSON.
*
* @return {Object}
*/
toJSON() {
return {
id: this.id,
title: this.title,
author: this.author,
date: this.date.format(),
contents: this.contents,
};
}
}
module.exports = {
PostModel,
POST_MODEL_FIELDS,
};
Then, we can create a Service to import these blog posts:
$ nodewood add:service imports post
This creates a service at app/features/imports/api/services/PostsService.js
, which we can add our importing code to:
const bent = require('bent');
const { Service } = require('#api/Service');
const { PostModel } = require('#features/imports/lib/models/PostModel');
const getJSON = bent('json');
class PostsService extends Service {
/**
* The constructor.
*
* @param {MassiveJS} db - The database to use to create the post.
* @param {Nodemailer} mailer - The mailer to use to send mail.
*/
constructor({ db, mailer }) {
super({ db, mailer });
this.model = PostModel;
}
/**
* Imports post from third-party service and returns them as models.
*
* @return {Array<PostModel>}
*/
async import() {
const postsJson = await getJSON('https://justsomeblog.com/posts');
return postsJson.map((instance) => new PostModel(instance));
}
}
module.exports = {
PostsService,
};
(This code uses the Bent HTTP client (opens new window), which you'll need to add to your project by running yarn add bent
from your app
folder.)
All we're doing in this service is returning the imported posts as PostModels. Alternatively, you could save the imported models to your database or perform calculations on them or anything you choose!
Finally, you can use this service in a Controller that potentially exposes these imported posts out to be used in your UI, or even another third-party service that uses your app!
const { PrivateController } = require('#api/Controllers/PrivateController');
module.exports = class ImportController extends PrivateController {
/**
* Constructor.
*
* @param {MassiveJS} db - The MassiveJS db connection.
* @param {Mailer} mailer - The mailer.
*/
constructor({ db, mailer } = {}) {
super({ db, mailer });
this.router.get('/import/posts', this.import.bind(this));
}
/**
* @api {get} /import/posts Get a list of all imported posts
* @apiGroup Import
* @apiName List
*/
async import(req, res) {
res.json({
posts: await this.postsService.import(),
});
}
};
Many third-party services will, of course, offer NPM packages you can use instead of making calls directly to their REST services, but this is a simplified example that can illustrate the general case, and can also be useful if you have other services of your own that you're looking to communicate with.