Many popular projects and companies have greatly implemented Microservices architecture. One of its benefits is that we can create a service with a single responsibility for specific business logic. So whenever the business has a new feature, we can create a new service loosely coupled with other microservices.
Another benefit of microservices is that we can deploy them independently. It means we can use whatever languages or frameworks that a service sees fit. But in practice, companies usually define a default language/framework for their microservices. This strategy enables engineers to build a service quickly, and it is useful to increase productivity and maintainability with limited engineer resources.
When the need for new services is high, engineers will have to write many boilerplate codes of the same framework. Therefore, we can create a code generator to reduce repetition, so engineers can focus only on developing a microservice's business logic. We can also establish a code convention by defining the folder structure and design pattern within its template and workflow with a code generator.
Yeoman is a scaffolding tool that will help you kickstart new projects with any tech stacks. It is a Node.js module, and you can install it from npm:
npm install -g yo
After you’ve installed it, you can run Yeoman by the command yo and it will list all the globally installed generators. For example, you can try a generator I have built to generate a microservice with Express.js and Typescript by cloning the repo here, then run
npm link
in the directory,Now you can run the generator with
yo ts-express
. And the prompts below will be shown to ask for your customization.The process will then continue to write boilerplate files from templates and installing dependencies until the message below is displayed.
A Yeoman generator is simply a Node.js module. To create a generator, you have to create a new node project by running
npm init
and set the name with generator-
prefix (e.g. generator-microservice
).A generator requires Yeoman dependency, which you can install by running
npm install yeoman-generator
. Yeoman will automatically detect the main generator and sub-generators if you follow the folder structures below.├───package.json
└───generators/
├───app/
│ └───index.js
└───route/
└───index.js
package.json
is the NPM manifest file for the generator project.generators/app/index.js
is where the main generator workflow is written. It can be executed by running yo microservice
.generators/route/index.js
is where the sub-generator workflow is written. It can be executed by running yo microservice:route
.In the generator, you should create a class that extends the Yeoman base generator. Then we can define the tasks by creating methods inside the class.
Each task will run in sequence according to the order in which it is written. You can name your methods anything, or you can follow the reserved Yeoman method names.
Yeoman uses these method names to keep task priority in sequence; the names in order are
initializing
, prompting
, configuring
, default
, writing
, conflicts
, install
, and end
. If your method names don’t match the reserved names, they will run under the default
task priority.💡 Pro Tip: Name a method with underscore prefix () to create a helper method which won’t be listed as a task_helper_method
The Yeoman generator we are building is a CLI app, so users should interact or define their customization via terminal. Yeoman provides three approaches for user interaction; they are:
yo microservice [argument]
)this.argument(<name>, <options>)
in the class constructor. Then we can get the data from this.options[name]
.yo microservice --[option]
)this.option(<name>, <options>)
in the constructor. Then it will be accessible from this.options[name]
.this.prompt()
which receives an array of prompts as an argument. It is asynchronous, and we should call it in the prompting
task. When the user has finished answering the prompts, the method will return the answers object.Here is an example of using argument, option, and prompt methods to receive the
name
of the microservice.const Generator = require('yeoman-generator');
class MicroserviceGenerator extends Generator {
constructor(args, opts) {
super(args, opts);
// yo microservice Example
this.argument('name', {
type: String,
description: 'Microservice Name',
required: true,
default: 'Example',
});
console.log('Name as argument', this.options.name);
// yo microservice --another-name=Example
// yo microservice -n=Example
this.option('anotherName', {
alias: 'n',
type: String,
description: 'Microservice Name',
default: 'Example',
});
console.log('Name as option', this.options.anotherName);
}
async prompting() {
// > yo microservice
// ? What is the name of the microservice? (Example)
this.answers = await this.prompt([
{
name: 'name',
type: 'input',
default: 'Example',
message: 'What is the name of the microservice?',
},
]);
console.log('Name as prompt', this.answers.name);
}
}
module.exports = MicroserviceGenerator
💡 Pro Tip: To give better experience for the user, prompts can display multiple types of question, conditional question, and answer validation. You can follow the documentation here.
After we received the user’s customization data, we might need to process and configure them before using them further in other tasks. Common tasks to be handled in the configuration step:
.yo-rc.json
) to be used for future execution or from other generators/sub-generators.We can code these tasks in the
configuring
method like below.const Generator = require('yeoman-generator');
class MicroserviceGenerator extends Generator {
// ... other methods
configuring() {
// creating new variables
const { name } = this.options;
const title = `ms-${name.toLowerCase()}`; // will output as "ms-example"
// combining user data
this.answers = {
...this.config.getAll(), // getting saved config data
...this.answers,
name,
title,
};
// saving to config file
this.config.set(this.answers);
}
}
module.exports = MicroserviceGenerator
With this method, a
.yo-rc.json
file will be generated inside the project, which will look like this.{
"generator-microservice": {
"name": "Example",
"title": "ms-example"
}
}
In our generator project, we can put template files inside a generator or sub-generator templates directory like below.
└───generators/
├───app/
│ ├───templates/
│ │ ├───index.js
│ │ └───package.json
│ └───index.js
A template file can be any file with any extension. Besides boilerplate codes, we can also put project config and manifest files as templates.
Yeoman uses EJS for its templating syntax. With EJS, we can compile a JS code in a template. So we can pass variables or have conditional writing in the template. Here is an example of EJS usage in a
.env
file template, which is a microservice environment variables file.# ------------------------------------------------------------------
# General
# ------------------------------------------------------------------
SERVICE_NAME=<%= name %>
PORT=3000
LOG_LEVEL=debug
<%_ if (hasDb) { _%>
# ------------------------------------------------------------------
# Database
# ------------------------------------------------------------------
DATABASE_HOST=<%= dbHost %>
DATABASE_PORT=<%= dbPort %>
DATABASE_NAME=<%= dbName %>
DATABASE_USERNAME=<%= dbUser %>
DATABASE_PASSWORD=<%= dbPassword %>
<%_ } _%>
In the file above, we can easily write a value from a variable with EJS syntax
<%=
. We can also see that the database config block (line 9–16) will only be written when hasDb
variable resolve to true
.💡 Pro Tip: We can useinstead of<%_ _%>
<%
to clear out whitespace (empty line) around it.%>
For this step, we can finally generate the microservice boilerplate from our template files. We only need to copy the files from the templates directory into the target directory while passing variables that we have received and configured from the user. To do this, we can code inside the
writing
task priority like below.const Generator = require('yeoman-generator');
class MicroserviceGenerator extends Generator {
// ...other tasks
writing() {
const templates = [
'.dockerignore',
'.env',
'.eslintrc.yml',
'Dockerfile',
'package.json',
'tsconfig.json',
'tslint.json',
'config/index.ts',
'src/index.ts',
'src/routes/index.ts',
];
templates.forEach((filePath) => {
this.fs.copyTpl(
this.templatePath(filePath),
this.destinationPath(filePath),
this.answers,
);
});
}
}
module.exports = MicroserviceGenerator
As we can see, we are using Yeoman’s built-in method to copy template files
this.fs.copyTpl
that receive three params: source path, target path, and template variables. Yeoman also provide path resolver functions to get the current generator’s templates path (this.templatePath
) and its target path (this.destinationPath
).After generating the microservice boilerplate project, we can install the dependencies needed to start running in this step. From what I learned, we have two different approaches to manage dependencies of the generated microservice,
package.json
file as a template where all the dependencies are defined. So we will only call this.npmInstall()
in the install
task.this.npmInstall
function as a param.Using the second approach, you can see the example of implementing it in the
install
task below.const Generator = require('yeoman-generator');
class MicroserviceGenerator extends Generator {
// ...other tasks
install() {
const devDependencies = [
'@types/cors',
'@types/express',
'@types/jest',
'@typescript-eslint/eslint-plugin',
'@typescript-eslint/parser',
'eslint',
'eslint-config-airbnb-base',
'eslint-plugin-import',
'jest',
'ts-jest',
'ts-node',
'typescript',
];
const dependencies = [
'body-parser',
'cors',
'dotenv',
'express',
'joi',
];
this.npmInstall(devDependencies, {'save-dev': true});
this.npmInstall(dependencies);
}
end() {
this.log('Your microservice is ready!');
}
}
module.exports = MicroserviceGenerator;
With the example above, we can differentiate dependencies into dev dependencies to be installed separately. We can also code the end task to print a message to the user indicating the generator process has successfully ended.
Now that we have finished creating the main microservice generator, we can create a sub-generator. It is useful to generate an app component in an existing project like routes, data model, service, etc.
We can also create a sub-generator to make the generator more modularized, as it can be composed with the main generator. Here is an example of a sub-generator module for creating a route/endpoint in a microservice.
const Generator = require('yeoman-generator');
class RouteGenerator extends Generator {
async prompting() {
this.log('--- Generate a route ---');
this.answers = await this.prompt([
{
name: 'routeName',
type: 'input',
default: 'healthcheck',
message: 'What is the name of the route?',
},
{
name: 'routeMethod',
type: 'list',
choices: ['get', 'post', 'delete', 'patch', 'put'],
default: 'get',
message: 'What is the method of the route?',
},
]);
}
configuring() {
const { routeMethod, routeName } = this.answers;
const routeTitle = `${routeMethod.toUpperCase()} /${routeName}`;
this.answers = {
...this.answers,
routeTitle,
};
this.config.set(this.answers);
}
_writeCode() {
const { routeMethod, routeName } = this.answers;
this.fs.copyTpl(
this.templatePath('src/routes/name/index.ts'),
this.destinationPath(`src/routes/${routeName}/index.ts`),
this.answers,
);
this.fs.copyTpl(
this.templatePath('src/routes/name/method/index.ts'),
this.destinationPath(`src/routes/${routeName}/${routeMethod}/index.ts`),
this.answers,
);
this.fs.copyTpl(
this.templatePath('src/routes/name/method/handler.ts'),
this.destinationPath(`src/routes/${routeName}/${routeMethod}/handler.ts`),
this.answers,
);
}
_writeTest() {
const { routeMethod, routeName } = this.answers;
this.fs.copyTpl(
this.templatePath('test/routes/name/method/handler.test.ts'),
this.destinationPath(`test/routes/${routeName}/${routeMethod}/handler.test.ts`),
this.answers,
);
}
writing() {
this.log(`Generating route for ${this.answers.routeTitle}`);
this._writeCode();
this._writeTest();
}
end() {
this.log(`Route ${this.answers.routeTitle} has been generated`);
}
}
module.exports = RouteGenerator;
As we can see, the sub-generator module has the same concept as the main generator. It has user interaction, configuring data, and copying template tasks. Therefore we can combine it with our main generator to run their tasks in the same order. To do this, we only need to call
this.composeWith()
function in the initializing
task like below.const Generator = require('yeoman-generator');
class MicroserviceGenerator extends Generator {
// ...constructor
initializing() {
this.composeWith(require.resolve('../route'));
}
// ...other tasks
}
module.exports = MicroserviceGenerator;
💡 Pro Tip: You can use Yeoman config file .yo-rc.json to combine data from both generators.
I hope now you can try to create your own microservice generator with Yeoman. I am sure it worth the extra effort to help increase the velocity of your team.
Feel free to contribute to my example repository. You can also ask me anything there.
Also published at https://iksena.medium.com/generate-microservices-quickly-with-yeoman-bc8b8453ea80?sk=6f0d29d93020a616e98999a76f8c6c57