Building a Nodejs API using the Twelve-Factor App Principles
I recently started learning to write APIs following the best practices and design. This research led me to The Twelve-Factors, which was introduced by Heroku co-founder Adam Wiggins in 2011. The concept focuses on the best practices for writing SaaS applications, which involve building application systems that are easy to set up, portable across environments, consistent from development to production, scalable, and cloud-ready. Knowing this concept makes it easier for developers to plan how to build their applications using a tested blueprint. It can also be applied to applications written in any programming language. The Twelve-Factors Codebase Dependencies Config Backing Services Build, release, run Processes Port binding Concurrency Disposability Dev/prod parity Logs Admin processes To explain all these concepts, I'll be using a student-api project I created using Nodejs and express.js to explain all these concepts. Codebase: Version control systems, such as Git, should always be used to track applications. This idea only clarifies that a version control system should be used to maintain a single codebase for every application. For this part, set up a new repository for your application and, if you don't already have one, register a GitHub account. Dependencies: This concept entails declaring all packages and dependencies in a packaging system, specifically the Node Package Manager (NPM). It reduces the need for system-wide installations and makes it easier for new developers to set up the application.. ### Create a new directory for your project and initialize the project mkdir student-api cd student-api npm init -y ### Install the required packages npm install express sqlite3 knex dotenv joi morgan npm install --save-dev jest supertest nodemon The npm init—y command simply creates a package.json file, where all our dependencies and their specific versions will be declared. An example of how your package.json file should look Config: Using configuration files such as API keys, database URLs, and other sensitive data as constants in your code is against the Twelve-Factor rules. Instead, keep them as environment variables and use them in your code. In this case, I utilized the dotenv library to access the '.env' file. touch index.js const express = require('express'); require('dotenv').config(); const morgan = require('morgan'); const studentRouter = require('./routes/students'); const app = express(); app.use(morgan('dev')); app.use(express.json()); app.use('/api/v1/students', studentRouter); app.get ('/api/v1/healthcheck', (req, res) => { res.json({ status: 'ok' }); }); module.exports = app; if (require.main === module) { const PORT = process.env.PORT || 3000; app.listen(PORT, () => { console.log(`Server is running on port ${PORT}`); }); } Backing Services: These services are treated as attached resources configured to be loosely coupled. They include databases, messaging/queuing, caching, and SMTP services. For my student-api, I utilized SQLite as a database and [Knex](https://knexjs.org/guide/migrations.html) for database migrations. ### create the database configuration file touch knexfile.js ### create a database connection touch db.js #### defines CRUD operations for the students table mkdir models cd models touch students.js #### defines the REST API endpoints with validation mkdir routes cd routes touch students.js #### create a migration to set up the students' table npx knex migrate:make create_students_table #### run the migration npx knex migrate:latest Knex is a database query builder and migration tool in Node.js. knex.js require('dotenv').config(); module.exports = { development: { client: 'sqlite3', connection: { filename: process.env.DB_FILE || './students.db' }, useNullAsDefault: true } }; db.js const knex = require('knex'); const config = require('./knexfile'); const env = process.env.NODE_ENV || 'development'; const db = knex(config[env]); module.exports = db; models/students.js const db = require('../db'); const Student = { create: async (data) => db('students').insert(data).returning('*'), findAll: async () => db('students').select('*'), findById: async (id) => db('students').where({ id }).first(), update: async (id, data) => db('students').where({ id }).update(data).returning('*'), delete: async (id) => db('students').where({ id }).del() }; module.exports = Student; routes/students.js const express = require('express'); const router = express.Router(); const Student = require('../model/students'); const Joi = require('joi'); const studentSchema = Joi.object({ name: Joi.string().required(), age: Joi.number().integer().required(), grade: Joi.string().required() }); // Create a Student router.post('/', async (req, res) => { try { console.log('Request body:', req.body); // Debug log const { error

I recently started learning to write APIs
following the best practices and design. This research led me to The Twelve-Factors
, which was introduced by Heroku co-founder Adam Wiggins in 2011. The concept focuses on the best practices for writing SaaS applications, which involve building application systems that are easy to set up, portable across environments, consistent from development to production, scalable, and cloud-ready.
Knowing this concept makes it easier for developers to plan how to build their applications using a tested blueprint. It can also be applied to applications written in any programming language.
The Twelve-Factors
- Codebase
- Dependencies
- Config
- Backing Services
- Build, release, run
- Processes
- Port binding
- Concurrency
- Disposability
- Dev/prod parity
- Logs
- Admin processes
To explain all these concepts, I'll be using a student-api
project I created using Nodejs and express.js to explain all these concepts.
Codebase: Version control systems, such as
Git
, should always be used to track applications. This idea only clarifies that a version control system should be used to maintain a single codebase for every application. For this part, set up a new repository for your application and, if you don't already have one, register a GitHub account.Dependencies: This concept entails declaring all packages and dependencies in a packaging system, specifically the Node Package Manager (NPM). It reduces the need for system-wide installations and makes it easier for new developers to set up the application..
### Create a new directory for your project and initialize the project
mkdir student-api
cd student-api
npm init -y
### Install the required packages
npm install express sqlite3 knex dotenv joi morgan
npm install --save-dev jest supertest nodemon
The npm init—y
command simply creates a package.json
file, where all our dependencies and their specific versions will be declared.
An example of how your
package.json
file should look
-
Config: Using configuration files such as API keys, database URLs, and other sensitive data as constants in your code is against the Twelve-Factor rules. Instead, keep them as environment variables and use them in your code. In this case, I utilized the dotenv library to access the '.env' file.
touch index.js
const express = require('express');
require('dotenv').config();
const morgan = require('morgan');
const studentRouter = require('./routes/students');
const app = express();
app.use(morgan('dev'));
app.use(express.json());
app.use('/api/v1/students', studentRouter);
app.get ('/api/v1/healthcheck', (req, res) => {
res.json({
status: 'ok'
});
});
module.exports = app;
if (require.main === module) {
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server is running on port ${PORT}`);
});
}
-
Backing Services: These services are treated as attached resources configured to be loosely coupled. They include databases, messaging/queuing, caching, and SMTP services.
For my student-api, I utilized SQLite as a database and
[Knex](https://knexjs.org/guide/migrations.html)
for database migrations.
### create the database configuration file
touch knexfile.js
### create a database connection
touch db.js
#### defines CRUD operations for the students table
mkdir models
cd models
touch students.js
#### defines the REST API endpoints with validation
mkdir routes
cd routes
touch students.js
#### create a migration to set up the students' table
npx knex migrate:make create_students_table
#### run the migration
npx knex migrate:latest
Knex is a database query builder and migration tool in Node.js.
knex.js
require('dotenv').config();
module.exports = {
development: {
client: 'sqlite3',
connection: {
filename: process.env.DB_FILE || './students.db'
},
useNullAsDefault: true
}
};
db.js
const knex = require('knex');
const config = require('./knexfile');
const env = process.env.NODE_ENV || 'development';
const db = knex(config[env]);
module.exports = db;
models/students.js
const db = require('../db');
const Student = {
create: async (data) => db('students').insert(data).returning('*'),
findAll: async () => db('students').select('*'),
findById: async (id) => db('students').where({ id }).first(),
update: async (id, data) => db('students').where({ id }).update(data).returning('*'),
delete: async (id) => db('students').where({ id }).del()
};
module.exports = Student;
routes/students.js
const express = require('express');
const router = express.Router();
const Student = require('../model/students');
const Joi = require('joi');
const studentSchema = Joi.object({
name: Joi.string().required(),
age: Joi.number().integer().required(),
grade: Joi.string().required()
});
// Create a Student
router.post('/', async (req, res) => {
try {
console.log('Request body:', req.body); // Debug log
const { error } = studentSchema.validate(req.body);
if (error) {
console.log('Validation error:', error.details); // Debug validation
return res.status(400).json({ error: error.details[0].message });
}
const student = await Student.create(req.body);
res.status(201).json(student[0]);
} catch (error) {
console.error('Error in POST /students:', error); // Detailed error log
res.status(500).json({ error: error.message });
}
});
// Get all Students
router.get('/', async (req, res) => {
try {
const student = await Student.findAll();
res.json(student);
} catch (error) {
console.error(error); // log the error
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Get a student by ID
router.get('/:id', async (req, res) => {
try {
const student = await Student.findById(req.params.id);
if (student) res.json(student);
else res.status(404).json({ error: 'Student not found' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Update a student
router.put('/:id', async (req, res) => {
try {
if (!req.params.id) return res.status(400).json({ error: 'Missing ID parameter' });
const { error } = studentSchema.validate(req.body, { allowUnknown: true });
if (error) return res.status(400).json({ error: error.details[0].message });
const student = await Student.update(req.params.id, req.body);
if (student.length > 0) res.json(student[0]);
else res.status(404).json({ error: 'Student not found' });
} catch (error) {
console.error(error); // log the error
res.status(500).json({ error: 'Internal Server Error' });
}
});
// Delete a student
router.delete('/:id', async (req, res) => {
try {
if (!req.params.id) return res.status(400).json({ error: 'Missing ID parameter' });
const student = await Student.delete(req.params.id);
if (student > 0) res.status(204).end();
else res.status(404).json({ error: 'Student not found' });
} catch (error) {
console.error(error); // log the error
res.status(500).json({ error: 'Internal Server Error' });
}
});
module.exports = router;
Joi is a JavaScript library that is used to validate and sanitize data input from clients.
migrations/_create_students_table.js
, paste the code below in that file to create the student table in the database
exports.up = function(knex) {
return knex.schema.createTable('students', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.integer('age').notNullable();
table.string('grade').notNullable();
});
};
exports.down = function(knex) {
return knex.schema.dropTable('students');
};
migrated the studentsdb using knex
-
Build, release, run: This concept describes how programs should keep the build, release, and run stages separate. In the
student-api
, I used 'Make' for the build/run phases; however, for the release, you can create a CI/CD pipeline.
### install make using npm
npm install make
### create a makefile
touch makefile
install:
npm install
migrate:
npx knex migrate:latest
run:
npm start
test:
npm test
Processes: Any data that must be persistent must be kept in a stateful backing service, such as a database; application processes should be stateless and not share anything. Our application stores data in a SQLite database, so our application can safely be restored without losing data.
Port binding: Expose applications on a dedicated port, ensuring that it is self-contained and easy to deploy. In our case, our app is exposed on port
3000
using Express.Concurrency: This process talks about how applications should be able to handle traffic by running multiple instances of the application. This can be done using tools like Kubernetes or PM2.
Disposability: Twelve-factor applications are architected to handle unexpected, non-graceful terminations. This means they can be started or stopped at a moment’s notice.
Dev/prod parity: Keep development, staging, and production as similar as possible. This means that applications used in different environments should be the same or very similar to avoid bugs.
Logs: Treat logs as event streams and output them to stdout. Use log aggregation and monitoring tools to analyze logs and detect issues. For the API application, I used Morgan to log HTTP requests to the console, which can be captured and analyzed separately.
Morgan outputting the application's Logs
-
Admin processes: Admin processes should be run in an identical environment as the regular long-running processes of the application. This includes processes like database migration, running one-time scripts, and all. For the API application, database migrations are handled via
[knex](https://knexjs.org/guide/migrations.html)
, separate from the main application process.