Node.js API Project Architecture (with Docker, Tests, and CI/CD)
by Nicklas EnvallIn this article, we'll set up a REST API by using the unopinionated web application framework Express. The theme of our API will be artists.
It's a very practical guide intended to expose you to a great number of technologies and keywords, in a concise manner. It's not meant to give you the exact path to a successful architecture, it's about giving you perspective and some of the keys to a successful architecture for your application.
The nodejs-api-project-architecture repository contains the code for each part, so fork it if you get stuck and continue from there.
Part 1. Setting the foundation:
- Setting the base
- Setting up Docker
- Transpilation with Babel
- Static testing with ESLint
- Testing Framework Jest
- Environment variables
- Continuous integration (CI)
- Continuous delivery (CD)
Part 2. Building the app:
- Data Access Object (DAO)
- Data Transfer Object (DTO)
- Services & Business logic
- Controller & Mocking
- Middleware
- Express Routes
Part 3. Persisting data:
- Persisting data with MongoDB
- Your team uses PostgreSQL
- Starting the server
- Review
We'll also cover static testing, unit testing, integration testing, HTTP testing (E2E). So, the project will be well-tested, and we'll be using a TDD approach for the most part.
Part 1. Setting the foundation
Now we'll start by setting up everything before we start coding our application.
1.1 Setting the base
Start with creating a new directory, and set up a project:
mkdir my-express-app cd my-express-app npm init -y
As a result, we should now have a manifest file called package.json
.
So, continue by installing express
:
npm install express
Create /src/server.js
, which will act as the project's entry point.
Then, paste the following code into it.
const express = require('express') const app = express() const port = 3000 app.get('/', (req, res) => { res.send('Hello World!') }) app.listen(port, () => { console.log(`Listening at http://localhost:${port}`) })
Ensure everything works by running node src/server.js
.
1.2 Setting up Docker
Docker helps us create, deploy, and manage containers. If you want to brush up on your Docker knowledge, then you can read about the Docker Basics here. You can also install docker here. But here's an extract of key concepts:
- Containers: are isolated instances on a host where we can run our applications.
- Images: are templates that create containers.
- Volumes: allows us to persist and share data between containers.
Dockerfile
We'll start by creating a Dockerfile
, which contains instructions on how to build our image. It uses the following format:
# Comment INSTRUCTION arguments
Create Dockerfile
and add the following:
FROM node:17-alpine WORKDIR /node COPY package*.json ./ RUN npm install && npm cache clean --force WORKDIR /node/app
These instructions (image) help us create a new container with the right dependencies installed. Let's inspect each instruction:
FROM <image>
: sets the base image. In this case,node:17-alpine
is our base image.WORKDIR </path/to/workdir>
: creates (if non-existent) and sets the working directory. In this case, we create and set/node
.COPY <src> <dest>
: copies files or folders from<src>
with regards to the build context of your container's path at<dest>
. In this case, we're takingpackage*.json
, so we can access our dependencies inside the container.RUN <command>
: will create a new layer and execute the provided commands. In this case, we're installing our dependencies.WORKDIR </path/to/workdir>
: same as step 2, but now we set the working directory/node/app
for our app.
Docker Compose
Docker Compose is a tool used for defining, running, and managing a multi-container application. It allows us to run multiple containers with ease. In this article, we'll use it for development, testing, and our CI workflow.
The tool allows us to define services in a YAML-file called docker-compose.yml
. Then, we can manage all those defined services with the docker-compose
CLI.
Now, create docker-compose.yml
:
version: '3.7' services: server: build: . ports: - "3000:3000" volumes: - "./:/node/app" - "exclude:/node/app/node_modules" # prevent host from overwriting node_modules command: "npm start" volumes: exclude:
Continue by updating your scripts
object in package.json
so it matches the following:
"scripts": { "start": "node src/server.js" }
Then run:
docker-compose up server
In summary, with our Dockerfile
we are first creating a directory into which we copy our manifest file and install our dependencies. Then we create yet another directory which we later in our docker-compose.yml
file we use to bind mount our app's source code into - so our changes are reflected in the container as well during development. We install our dependencies one step above our source code so that our local npm_modules
do not overwrite the one inside our container, we also use an empty volume "/node/app/node_modules"
to ensure it always goes one step above, i.e. from /node/app
to /node
.
1.3 Transpilation with Babel
Node.js does support ES modules via the .mjs
file extension. But, I presume you still want to use the latest JavaScript, so we'll use babel.
Babel doesn't do anything by default, so we'll need to use plugins. Plugins are just small programs that let Babel know how to transform your code. But keeping track of multiple plugins is a hassle so we can use presets. Presets are a set of carefully picked out plugins.
Now, we'll add three packages to our project:
@babel/core
contains the core functionality.@babel/cli
allows initiation of transpilation via the terminal.@babel/preset-env
is a preset for using the latest JavaScript.
Install them by running the following:
npm install --save-dev @babel/cli @babel/core @babel/preset-env
To tell Babel what plugins and presets to use, we need a configuration file.
So, we create a .babelrc
file and add the following:
{ "presets": [ [ "@babel/preset-env", { "targets": { "node": "current" } } ] ], "plugins": [] }
With the targets object, we inform babel what environment our project supports. In this case, we notify it to compile against the node version process.versions.node
by using "node": "current"
.
Next, let us stop using Node's module loader function (require
) in server.js
and update it to:
import express from 'express' const app = express() const port = 3000 app.get('/', (req, res) => { res.send('Hello World!') }); app.listen(port, () => { console.log(`Listening at http://localhost:${port}`) });
Update your scripts
object in package.json
so it matches the following:
"scripts": { "build": "babel src -d dist", "start": "npm run build && node dist/server.js" }
Finally, you can run docker-compose build server
(our container needs the babel packages) and docker-compose up server
to make sure it all works.
1.4 Static testing with ESLint
With static testing, we analyse our code without running it. The benefits are that we may find possible syntax errors, bugs, code styling errors, and optimization possibilities.
Linters are tools used for static analysis, and ESLint is the linter that we'll use. So, install it by running:
npm install --save-dev eslint
We'll also need a configuration file for ESLint. So, run:
npx eslint --init
As a result, you need to answer some questions. I answered them in the following manner:
√ How would you like to use ESLint? · style √ What type of modules does your project use? · esm √ Which framework does your project use? · none √ Does your project use TypeScript? · No / Yes √ Where does your code run? · node √ How would you like to define a style for your project? · guide √ Which style guide do you want to follow? · airbnb √ What format do you want your config file to be in? · JSON Checking peerDependencies of eslint-config-airbnb-base@latest The config that you've selected requires the following dependencies: eslint-config-airbnb-base@latest eslint@^5.16.0 || ^6.8.0 || ^7.2.0 eslint-plugin-import@^2.22.1 √ Would you like to install them now with npm? · No / Yes
You should now have a generated ESLint config file (mine's called .eslintrc.json
).
Continue by adding "root": true
so the file looks like:
{ "env": { "es2021": true, "node": true }, "extends": [ "airbnb-base" ], "parserOptions": { "ecmaVersion": 12, "sourceType": "module" }, "rules": { }, "root": true }
Then, update your scripts
object in package.json
so it matches the following:
"scripts": { "test": "eslint src/**/*.js", "build": "babel src -d dist", "start": "npm run build && node dist/server.js" },
Finally, run docker-compose build server
(our container needs the eslint
package), and then docker-compose run server npm test
, and you should see something like:
/node/app/src/server.js 1:30 error Missing semicolon semi 3:22 error Missing semicolon semi 4:18 error Missing semicolon semi 7:27 error Missing semicolon semi 11:3 warning Unexpected console statement no-console 11:67 error Missing semicolon semi ✖ 6 problems (5 errors, 1 warning) 5 errors and 0 warnings potentially fixable with the `--fix` option.
I use semicolons, so to correct the errors, we'll add semicolons.
Some do prefer semicolons and some don't. If you don't then you can read how to configure eslint here.
Update your server.js
so it matches the following:
import express from 'express'; const app = express(); const port = 3000; app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(port, () => { console.log(`Listening at http://localhost:${port}`); });
If you run docker-compose run server npm test
again, all the errors should be gone.
1.5 Testing Framework Jest
Previously your developing process might've involved using nodemon and continuously refreshing the browser. But, we'll be using a TDD approach, so we won't be using nodemon.
Start with installing the testing framework Jest.
npm install --save-dev jest
Then update your package.json
:
"scripts": { "test": "eslint src/**/*.js & jest --passWithNoTests", "test:watch": "jest --watch", "build": "babel src -d dist --ignore src/**/*.test.js", "start": "npm run build && node dist/server.js" },
Now, with Jest we don't need to worry about transpilation. Because, it comes with babel-jest out of the box, which loads our Babel configuration and transforms files like .js
, .jsx
, ts
, and .tsx
.
Create a test file /src/hello.test.js
:
const getSum = (arr) => arr.reduce((sum, curr) => sum + curr); describe('#getSum', () => { it('should return the sum of the array', () => { expect(getSum([1, 2, 3])).toBe(6); }); });
Then rebuild your docker image and run docker-compose run server npm test
, and it should work.
Note: Have a look at https://github.com/facebook/jest/issues/3126 if you run into an error looking like, "ReferenceError: regeneratorRuntime is not defined."
But, your eslint will tell you something like this:
/node/app/src/hello.test.js 3:1 error 'describe' is not defined no-undef 4:3 error 'it' is not defined no-undef 5:5 error 'expect' is not defined no-undef
So add "jest": true
to "env": { ... }
in eslintrc.json
so that it knows we're using Jest. Run your tests again, and when everything looks fine, you can go ahead and delete /src/hello.test.js
, because we now know it works.
1.6 Environment variables
Environment variables are variables that are set outside the program while also being accessible to the program. Thus we may have different configurations for different environments.
Inside our Node.js process, we can access the variables via the process.env
object, by doing process.env.{name}
. There are different ways to set environment variables, and we'll start by creating a .env
file (ensure that you add it to .gitignore
):
PORT=3000
By installing the dotenv package we'll be able to load the variables from the .env
file into process.env:
npm install dotenv
Now, all our environment variables (PORT
) are available in the .env
. But, for maintainability reasons let us create a /src/config.js
file that will map all the process.env.{name}
variables to a module:
import { config } from 'dotenv'; config(); export default Object.freeze({ port: process.env.PORT, });
It'll work, but an .env
file is not the answer to everything:
- What happens when we want to deploy our app to the cloud?
.env
might accidentally be committed and pushed to the repo.- Might promote developers to send the
.env
file unencrypted to coworkers.
Also, don't forget to update your server.js
:
import express from 'express'; import config from './config'; const app = express(); app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(config.port, () => { console.log(`Listening at http://localhost:${config.port}`); });
Preparing for the cloud
We must also think about how to start our app without the .env
file, especially since we've coupled our code to the dotenv
library:
import { config } from 'dotenv'; config();
So, we remove the dotenv
specific code and library.
dotenv also has an option like "node -r dotenv/config dist/server.js" have a look if applicable for you.
Then, add the following to our server
service in docker-compose.yml
:
environment: - PORT=${PORT}
Then update config.js
:
const config = Object.freeze({ port: process.env.PORT, }); export default config;
1.7 Continuous integration (CI)
Continuous integration (CI) is a practice where automated builds and tests are used in order to enable regular code changes in a central repository. Every time a developer pushes code changes up to a shared version control repository it'll automatically be built and tested. Amongst many other things, it helps avoid difficult and time-consuming merge conflicts, while also making it easier to identify bugs earlier and quicker.
There are many Continuous Integration tools available, some being:
- Jenkins
- TeamCity
- TravisCI
- Github Actions
- Azure-pipelines
In this article, we'll use Travis. Travis acts as a continuous integration platform by cloning our repository into a virtual environment, where our application is tested and built. With a .travis.yml
file we define our build process. A build consists of stages, and within a stage jobs are executed in parallel, while the stages themselves are run sequentially. A job entails our repository being cloned and put into a virtual environment where we can then carry out tasks, like installing packages, compiling code, or running our tests.
Make sure you connect your GitHub repo (https://docs.travis-ci.com/user/tutorial/), and then create .travis.yml
and place it in the root of our project:
script: - docker-compose run server npm test - docker-compose run server npm run build services: - docker
You choose the language, and the version of node, by just having node
you inform Travis to use the latest version. You can customize much more if you want, in our example we use Docker. Read more here, https://docs.travis-ci.com/user/languages/javascript-with-nodejs/.
1.8 Continuous Delivery (CD)
Continuous Delivery (CD) is a superset of Continuous integration that automates the release process to a specified environment. After the build stage, the automated release process starts and you only have to push a button to deploy the changes. We also have Continuous deployment which does the same, but bypasses the button and deploys automatically as well.
We'll use Heroku to host our application and Travis to automate the release process. Make sure you've connected Travis to your GitHub repo before continuing, and then go to https://www.heroku.com/free and set up an account. Now do these steps:
- New > Create new app > name "my-express-app" > Create app
- Deployment method: Connect to GitHub
- App connected to GitHub: Choose repo > Connect
- Automatic deploys: choose "wait for CI to pass" > Enable Automatic Deploys
Then go to the settings of your Heroku app and set the environment variables needed at "Config Vars." Also, set NPM_CONFIG_PRODUCTION
to false
because else our build will fail, look at https://devcenter.heroku.com/articles/nodejs-support#skip-pruning as to why that is.
Install Heroku CLI and Travis CLIs and encrypt your keys. Read more here https://docs.travis-ci.com/user/deployment/heroku/. Then, your travis.yml
should look like this:
deploy: api_key: secure: { your encrypted key } script: - "docker-compose run server npm test" - "docker-compose run server npm run build" services: - docker
Now do:
git commit -am "your message" git push
And, Travis will automatically deploy our code to Heroku if the build is successful.
2. Building the app
Hasty decisions when coding often makes it more expensive and slower to update our code as time goes by. So, in this part, we'll acquaint ourselves with commonly used patterns that intend to mitigate that issue while giving us some fundamental structure to our project.
Of course, there are disadvantages and advantages with pretty much everything. I encourage you to try to see both when going through this part. Ask yourself, does it make sense for your project?
2.1 Data Access Object (DAO)
Data access objects (DAO) abstract and encapsulate code for accessing data from a persistence mechanism. As a result, our business logic does not get coupled with our data access logic. A generic implementation of a DAO would provide CRUD operations.
In return, our business objects gets a more straightforward interface to work with. We may also update the underlying approach to persisting data (like MongoDB, MySQL, or the file system) in our DAO without affecting the business logic.
Therefore, create a /src/daos/
folder and add a createArtistDAO.test.js
file inside it. Because we'll start by writing some test cases:
import createArtistDAO from './createArtistDAO'; const artists = new Map(); const artist = createArtistDAO(artists); afterEach(() => artists.clear()); beforeEach(() => { artists.set(1, { id: 1, name: 'Jason Mraz' }); artists.set(2, { id: 2, name: 'Veronica Maggio' }); }); describe('#getAll', () => { it('should return all artists', async () => { const fetchedArtists = await artist.getAll(); expect(fetchedArtists).toMatchObject([ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, ]); }); }); describe('#getById', () => { it('should return correct artist - Jason Mraz', async () => { const fetchedArtist = await artist.getById(1); expect(fetchedArtist).toMatchObject({ id: 1, name: 'Jason Mraz', }); }); it('should return correct artist - Veronica Maggio', async () => { const fetchedArtist = await artist.getById(2); expect(fetchedArtist).toMatchObject({ id: 2, name: 'Veronica Maggio', }); }); it('should throw an error when artist not found', async () => { await expect(artist.getById(3)) .rejects .toThrow('Artist not found!'); }); }); describe('#create', () => { it('should create a new artist', async () => { await artist.create({ id: 3, name: 'John Mayer', }); const allArtists = await artist.getAll(); expect(allArtists).toMatchObject([ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, { id: 3, name: 'John Mayer', }, ]); }); it('should throw an error when artist name is lower than 3', async () => { await expect(artist.getById(3)) .rejects .toThrow('Artist not found!'); }); });
Run your tests, and you'll see them fail, which makes sense because we haven't implemented anything. But the tests are now set up, so let us proceed by implementing our DAO for artists.
Create /src/daos/createArtistDAO.js
:
const createArtistDAO = (artists) => Object.freeze({ getAll: async () => [...artists.values()], getById: async (id) => { const artist = artists.get(id); if (artist) { return artist; } throw new Error('Artist not found!'); }, create: async ({ name, team }) => { if (name.length < 2) { throw new Error('Name requires more than 2 characters!'); } const id = artists.size + 1; artists.set(id, { id, name, team }); }, }); export default createArtistDAO;
In this example, artists
is assumed to be an in-memory implementation with Map
. So, we've succeeded with creating an abstraction layer, which will allow us to easily swap approaches to persisting data, which we'll do with a database later.
You might wonder why I'm using Dependency injection and just not importing modules, like:
import { artists } from './db';
The reason is that I don't want hardcoded dependencies (within reason). Because we are creating a relationship when we load another module via import
. So things like mocking can quickly become a struggle when the "database" used by the module is the same stateful instance for all tests. Instead, our tests may now their own artists
instance as long as they abide by the interface of Map
. Also, if we used a shared artists
, we would be prone to race conditions because Jest runs the test in parallel. So, you'll be using dependency injection extensively throughout this guide.
Finally, run your tests, and they should all pass now.
2.2 Data Transfer Object (DTO)
Data Transfer Objects (DTO) filters out any undesirable properties. We'll use a DTO when passing our artist data around. Because:
- We want our DAO to always return data according to the
artistDTO
interface. - We want our service to be able to assume it's always getting an
artistDTO
from the controller. - We want our controllers able to filter out unwanted data before sending it to the service.
So, create /src/dtos/createArtistDTO.js
:
export default (data) => ({ name: data.name, id: data.id, team: data.team, });
A note if you're using a TypeScript interface, is that it's not sufficient. Because TypeScript offers no runtime type checking, instead, we have the createArtistDTO
function to mitigate that issue. But that is not to say that TypeScript would be a good addition in itself!
2.3 Services & Business logic
Services encapsulate our business logic and provide a simple interface for our controllers, middleware, or other services to call.
We'll create a service that gives us a place to define the business logic related to artists. So, create a /src/services/
folder and add createArtistService.js
to it:
const createArtistService = ({ artistDAO, teamService }) => { const getAll = async () => artistDAO.getAll(); const getById = async (id) => artistDAO.getById(id); const createArtist = async (artistDTO) => artistDAO.create({ name: artistDTO.name, team: await teamService.getTeam(), }); return Object.freeze({ getAll, getById, createArtist, }); }; export default createArtistService;
According to the fictional marketing team that I just made up, we must generate a team for the artist. They have an external API that we must call.
So we create a new /src/services/teamService.js
module that we'll later inject into our artistService
.
const getTeam = async () => { const fetchedTeam = 'team name'; // fictional api call return fetchedTeam; }; export default Object.freeze({ getTeam, });
2.4 Controller & Mocking
The controller is the link between a user and your application. It deals with the HTTP request and response while delegating most of the work to services.
Create a /src/controllers
folder, and a createArtistControllers.test.js
file:
import createArtistDAO from '../daos/createArtistDAO'; import teamService from '../services/teamService'; import createArtistService from '../services/createArtistService'; import createArtistControllers from './createArtistControllers'; const artists = new Map(); const artistControllers = createArtistControllers({ artistService: createArtistService({ artistDAO: createArtistDAO(artists), teamService, }), }); afterEach(() => artists.clear()); beforeEach(() => { artists.set(1, { id: 1, name: 'Jason Mraz' }); artists.set(2, { id: 2, name: 'Veronica Maggio' }); }); describe('#getArtists', () => { it('should return all artists', async () => { const req = {}; const res = { json: jest.fn() }; await artistControllers.getArtists(req, res); expect(res.json.mock.calls[0][0]).toMatchObject([ { id: 1, name: 'Jason Mraz' }, { id: 2, name: 'Veronica Maggio' }, ]); }); });
As we see, our controller(s) will be taking a req
and res
object. We've also separated our controllers from the express routes so we are mocking both the req
and res
objects.
We also mock the properties json
and status
with jest.fn
(only mock what you need). The jest.fn
function creates another function that keeps track of how many times it's called. Additionally, we can go deeper by using mockFn.mock.calls, which is an array that contains the arguments of the call made to the mock function.
This is a so-called spy that records the usage of a function.
Run your tests, and they'll fail. So create createArtistControllers.js
and implement the following:
import createArtistDTO from '../dtos/createArtistDTO'; const createArtistsControllers = ({ artistService }) => { const getArtists = async (req, res) => { const artists = await artistService.getAll(); return res.json(artists); }; const createArtist = async (req, res) => { const artistDTO = createArtistDTO(req.body); try { await artistService.createArtist(artistDTO); return res.status(200).send(); } catch { return res.status(500).send(); } }; return Object.freeze({ getArtists, createArtist, }); }; export default createArtistsControllers;
The attentive reader will notice that we do not send the entire request object to our services. Instead, by using our createArtistDTO
function, we filter out any undesirable properties, and the reason for that is so that our services do not get coupled with the Express framework.
Finally, run the tests, and they should pass.
2.5 Middleware
Middleware in Express is a function that takes three arguments, request
, response
, and next
. A request flows through a pipeline of middlewares, one at a time, before reaching your controller.
Important to note is that:
- Middleware can modify the request and pass it along to the next middleware by calling
next
. - The pipeline will terminate the request if you don't call
next
. - You should eventually send a response to the client with something like
res.send
orres.json
.
Now we'll create a middleware that ensures request.body
contains the required fields for createArtist
before calling it. With that middleware, we will block artists from being added when the data is invalid.
Create the folder /src/middleware
and add validateArtistMiddleware.test.js
:
import validateArtistMiddleware, { isValidArtist } from './validateArtistMiddleware'; describe('#isValidArtist', () => { it('should return true when body has name property', () => { const body = { name: 'Céline Dion' }; expect(isValidArtist(body)).toEqual(true); }); it('should return false when body is missing name property', () => { const body = { age: 35 }; expect(isValidArtist(body)).toEqual(false); }); }); describe('#validateArtistMiddleware', () => { it('should call res.status(400) when missing name', () => { const req = { body: { age: 35, }, }; const res = { status: jest.fn(() => res), send: jest.fn() }; const next = jest.fn(); validateArtistMiddleware(req, res, next); expect(res.status.mock.calls[0][0]).toEqual(400); expect(res.send.mock.calls[0][0]).toEqual('Bad Request - parameters are missing'); expect(next.mock.calls.length).toEqual(0); }); it('should call next() when name property is set', () => { const req = { body: { name: 'Jason Mraz', }, }; const res = { }; const next = jest.fn(); validateArtistMiddleware(req, res, next); expect(next.mock.calls.length).toEqual(1); }); });
By having separate tests for middleware and not solely relying on E2E tests, we shorten the feedback loop when developing.
Now implement it in validateArtistMiddleware.js
:
export const isValidArtist = (body) => { if (!body.name) { return false; } return true; }; const validateArtistMiddleware = (req, res, next) => { if (isValidArtist(req.body)) { return next(); } return res.status(400).send('Bad Request - parameters are missing'); }; export default validateArtistMiddleware;
2.6 Express Routes
Our controllers are coupled to the Express framework since they're using the req
and res
objects - so why bother separating them from the routes?
The reasoning, in this case, is that we want to be able to test our middlewares and controllers separately. But admittedly, we still want to ensure everything works together, and that's why we'll be setting up HTTP API testing (E2E) for our routes in this section.
We'll use SuperTest for HTTP API testing. So install it along side body-parser
(for req.body
):
npm install --save-dev supertest npm install body-parser
Continue by creating the folder /src/routes
and adding artistRoutes.test.js
to it with the following code:
import express from 'express'; import request from 'supertest'; import bodyParser from 'body-parser'; import artistRouter from './artistRoutes'; const app = express(); app.use(bodyParser.json()); app.use(artistRouter); describe('POST /artists/new', () => { it('should return 200', async () => { const res = await request(app) .post('/artists/new') .set('Accept', 'application/json') .send({ name: 'John' }); expect(res.status).toBe(200); }); it('should return 400 code - because name is missing', async () => { const res = await request(app) .post('/artists/new') .set('Accept', 'application/json') .send({ name: undefined, age: '40' }); expect(res.status).toBe(400); }); });
Then, create a folder /src/routes
and add artistRoutes.js
to it:
import express from 'express'; import createArtistDAO from '../daos/createArtistDAO'; import teamService from '../services/teamService'; import createArtistService from '../services/createArtistService'; import createArtistControllers from '../controllers/createArtistControllers'; import validateArtistMiddleware from '../middleware/validateArtistMiddleware'; const router = express.Router(); const artistControllers = createArtistControllers({ artistService: createArtistService({ artistDAO: createArtistDAO(new Map()), teamService, }), }); router.post('/artists/new', validateArtistMiddleware, artistControllers.createArtist); export default router;
In this section, we are testing routes separately. However, this approach does come with disadvantages. Because perhaps your server.js
app contains middleware or configuration that you might want to include in your test cases, so bear that in mind.
Part 3. Persisting data
A database is structured data that most likely gets controlled via a database management system (DBMS), more colloquially put a database system. A database system provides an API to access the database. They essentially:
- Store data (disk, sometimes even RAM).
- Make their data accessible via something like SQL or MQL.
DBMS has different models. A database model decides the structure of the database, how data is stored, organized, and accessed. The two most famous models are:
- Relational model: requires a defined schema. It sorts data into tables (referred to as relations), each consisting of rows and columns. A single data object can spread across several tables.
- Document model: does not require a defined schema. It sorts data into collections (equivalent to tables). And a collection contains documents that do not enforce data types.
Examples of relational database management systems are MySQL, PostgreSQL, Microsoft SQL Server, and Oracle Database. Furthermore, examples of nonrelational database management systems include MongoDB and Couchbase.
Object Mapping
Object-relational mapping (ORM) entails at its core converting tables into classes, rows into objects, and columns into properties. As you can imagine, this comes with benefits and disadvantages, which we won't cover. Instead, we'll:
- Use Mongoose, which is an ODM.
- Use Sequelize, which is an ORM.
ORMs are for SQL databases, while so-called object document mapping (ODM) is for document-oriented databases.
3.1 Persisting data with MongoDB
MongoDB is a document database, and it provides a driver for Node called mongodb. We'll be using it, but via the ODM mongoose.
So, start by installing mongoose:
npm install mongoose
Create /src/common/db/db.js
and paste the following code into it:
import mongoose from 'mongoose'; const mongooseOptions = { useNewUrlParser: true, useUnifiedTopology: true, }; const connect = async (mongoUri) => mongoose.connect(mongoUri, mongooseOptions); export default { connect, };
Then, import mongoose and define the schema directly in the DAO. From my viewpoint, there's no point in creating an artist.schema.js
file because that logic gets coupled anyway. However, we still use DI so we can mock the input.
Update the /src/daos/createArtistDAO.js
to:
import mongoose from 'mongoose'; import createArtistDTO from '../dtos/createArtistDTO'; const artistSchema = new mongoose.Schema({ name: { type: String, required: true }, id: { type: Number, required: true }, team: { type: String }, }); export const artistModel = mongoose.model('artist', artistSchema); export default (ArtistModel) => { const getAll = async () => (await ArtistModel.find()).map(createArtistDTO); const getById = async (id) => { const artist = await ArtistModel.findOne({ id }); if (artist) { return createArtistDTO(artist); } throw new Error('Artist not found!'); }; const create = async ({ name, team }) => { if (name.length < 2) { throw new Error('Name requires more than 2 characters!'); } const id = (await ArtistModel.countDocuments()) + 1; const artistDoc = new ArtistModel({ id, name, team }); await artistDoc.save(); }; return Object.freeze({ getAll, getById, create, }); };
Run your tests, and they'll fail. They fail because we're missing a MongoDB server. Luckily, the mongodb-memory-server package starts a mongod process that stores data in-memory. Which, in return, allows us to avoid having to mock our own objects.
So continue by installing @shelf/jest-mongodb
and mongodb-memory-server
:
npm install --save-dev @shelf/jest-mongodb mongodb-memory-server
Then, add the following to jest.config.json
:
{ "preset": "@shelf/jest-mongodb" }
It also doesn't work with alpine read more here, so I'll update the top of our Dockerfile to:
FROM node:17-buster
Now, the package gives you two important global variables, global.__MONGO_URI__
and global.__MONGO_DB_NAME__
. So, we create /src/common/db/dbTestHelper.js
where we'll set up functions that make our tests cleaner.
/* eslint no-underscore-dangle: 0 */ import mongoose from 'mongoose'; import db from './db'; const close = async () => { await mongoose.connection.dropDatabase(); await mongoose.connection.close(); }; const connect = async () => { await db.connect(global.__MONGO_URI__); }; const clearCollection = async (colName) => { await mongoose.connection.collection(colName).deleteMany(); }; const addToCollection = async (colName, data) => { await mongoose.connection.db.collection(colName).insertMany(data); }; export default { close, connect, clearCollection, addToCollection, };
Then update your test:
import createArtistDAO, { artistModel } from './createArtistDAO'; import dbTestHelper from '../common/db/dbTestHelper'; const artist = createArtistDAO(artistModel); afterAll(dbTestHelper.close); beforeAll(dbTestHelper.connect); afterEach(() => dbTestHelper.clearCollection('artists')); beforeEach(() => dbTestHelper.addToCollection('artists', [ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, ])); describe('#getAll', () => { it('should return all artists', async () => { const fetchedArtists = await artist.getAll(); expect(fetchedArtists).toMatchObject([ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, ]); }); }); describe('#getById', () => { it('should return correct artist - Jason Mraz', async () => { const fetchedArtist = await artist.getById(1); expect(fetchedArtist).toMatchObject({ id: 1, name: 'Jason Mraz', }); }); it('should return correct artist - Veronica Maggio', async () => { const fetchedArtist = await artist.getById(2); expect(fetchedArtist).toMatchObject({ id: 2, name: 'Veronica Maggio', }); }); it('should throw an error when artist not found', async () => { await expect(artist.getById(3)) .rejects .toThrow('Artist not found!'); }); }); describe('#create', () => { it('should create a new artist', async () => { await artist.create({ id: 3, name: 'John Mayer', }); const fetchedArtists = await artist.getAll(); expect(fetchedArtists).toMatchObject([ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, { id: 3, name: 'John Mayer', }, ]); }); it('should throw an error when artist name is lower than 3', async () => { await expect(artist.getById(3)) .rejects .toThrow('Artist not found!'); }); });
But the problem with this approach is that you'll likely run into race conditions when you add more tests and run all your tests in parallel. So, have a look at https://github.com/shelfio/jest-mongodb/issues/236 before you resort to jest --runInBand
. I did so, and with the acquired information, I now ensure each test file gets a unique database. For example, inside jest-mongodb-config.js
:
module.exports = { mongodbMemoryServerOptions: { binary: { version: '5.0.3', // note: it caused issues when 4.0.3 skipMD5: true, }, instance: {}, autoStart: false, }, };
And inside /src/common/db/dbTestHelper.js
:
const connect = async () => { const mongoUri = global.__MONGO_DB_NAME__ ? `${global.__MONGO_URI__.split('/').slice(0, -1).join('/')}/${global.__MONGO_DB_NAME__}` : global.__MONGO_URI__; await db.connect(mongoUri); };
Lastly, I'll leave it as an exercise to update your createArtistControllers.test.js
and artistRoutes.test.js
files.
3.2 Your team uses PostgreSQL
The organization that you work for now wants to replace MongoDB with PostgreSQL.
Your heart stops for a moment.
But you realize that a refactor might not be that terrible after all. You only have to update /src/daos/createArtistDAO.js
since you never coupled the database details to the controllers, services, and so on.
But what is PostgreSQL?
PostgreSQL is a relational database management system (RDBMS). We use it to manage data stored in relations which we call tables.
- A table is a database object that contains rows of data.
- A row consists of columns.
- A column has a data type.
PostgreSQL is open-source and derived from a package called POSTGRES. The project got developed at the University of California at Berkeley Computer Science Department. The project started in 1986 and got led by esteemed Professor Michael Stonebraker. Then, in 1995, two students from Berkely (Andrew Yu and Jolly Chen) replaced the QUEL with SQL, and named that version Postgres95. Later, Postgres95 got a more appropriate name PostgreSQL to reflect that it uses SQL.
Refactoring with sequelize
We'll replace Mongoose with sequelize. Sequelize is an ORM commonly used for Postgres, MySQL, MariaDB, SQLite, and Microsoft SQL Server.
Install the following packages:
npm install --save sequelize pg pg-hstore
Create /src/db.js
:
import { Sequelize } from 'sequelize'; const db = new Sequelize('postgres://user:[email protected]:5432/dbname'); export default db;
How do we synchronize the database with sequelize? Well, with sequelize we note the following:
- With
db.define
, you define a model which informs sequelize about the table. Artist.sync({ force: true })
creates a table, but beforehand drops it, if it already exists.Artist.sync()
creates a table, if it does not exist.- You can use migrations instead of
sync
, https://sequelize.org/master/manual/migrations.html.
Continue by updating the createArtistDAO.js
file:
import { DataTypes } from 'sequelize'; import db from '../db'; export const Artist = db.define('Artist', { id: { type: DataTypes.INTEGER, allowNull: false, primaryKey: true, }, name: { type: DataTypes.STRING, // VARCHAR(255) allowNull: false, }, team: { type: DataTypes.STRING, }, }); export default (ArtistModel) => { const getAll = async () => ArtistModel.findAll(); const getById = async (id) => { const artist = await ArtistModel.findOne({ where: { id } }); if (artist) { return artist; } throw new Error('Artist not found!'); }; const create = async ({ name, team }) => { if (name.length < 2) { throw new Error('Name requires more than 2 characters!'); } const id = (await ArtistModel.count()) + 1; await ArtistModel.create({ id, name, team }); }; return Object.freeze({ getAll, getById, create, }); };
Also, update your config.js
file:
const config = Object.freeze({ port: process.env.PORT, dbName: process.env.DB_NAME, dbUser: process.env.DB_USER, dbPassword: process.env.DB_PASSWORD, dbHost: process.env.DB_HOST, dbPort: process.env.DB_PORT, }); export default config;
Then, update /src/db.js
to:
import { Sequelize } from 'sequelize'; import config from './config'; const db = new Sequelize(config.dbName, config.dbUser, config.dbPassword, { host: config.dbHost, port: config.dbPort, dialect: 'postgres', logging: false, }); export default db;
Now, we also have to update our tests, and there are many alternatives. Each approach comes with its advantages and disadvantages:
- Use mocks.
- Use an in-memory database.
- Spin up a temporary database (probably with Docker).
- Test against a database specific for test or dev.
I reflected on using sequelize-mock, but it seems not to be maintained anymore. Thus, I'll set up my own PostgreSQL server instead, made for testing with Docker.
Start by adding two new services, where one of them spins up a temporary DB with the postgres docker image:
test: build: . environment: DB_NAME: ${DB_NAME} DB_USER: ${DB_USER} DB_PASSWORD: ${DB_PASSWORD} DB_HOST: ${DB_HOST} DB_PORT: ${DB_PORT} volumes: - "./:/node/app" - "exclude:/node/app/node_modules" depends_on: - db-test db-test: image: postgres:latest container_name: db-test environment: POSTGRES_PASSWORD: ${DB_PASSWORD} POSTGRES_USER: ${DB_USER} POSTGRES_DB: ${DB_NAME} POSTGRES_HOST_AUTH_METHOD: trust ports: - ${DB_PORT}:${DB_PORT}
Update your .env
to:
# Server PORT=3000 # Database DB_NAME=db-name DB_USER=username DB_PASSWORD=password DB_HOST=db-test DB_PORT=5432
Update the top of your createArtistDAO.test.js
file to:
import createArtistDOA, { Artist } from './createArtistDAO'; import db from '../db'; const artist = createArtistDOA(Artist); afterAll(async () => { await db.close(); }); beforeEach(async () => { await db.sync({ force: true }); await Artist.bulkCreate([ { id: 1, name: 'Jason Mraz', }, { id: 2, name: 'Veronica Maggio', }, ]); });
Lastly, update your package.json
to use --runInBand
and run docker-compose run test npm test
(sadly, this approach requires the tests to be sequentially executed). Then, you can update the other tests accordingly.
Update Travis.yml
Add your environment variables to Travis. These aren't secret (only used for testing), so I'll add them without encryption - but you should encrypt yours if they need to be secret.
deploy: api_key: secure: { your encrypted key } env: global: - DB_NAME=db-name - DB_USER=username - DB_PASSWORD=password - DB_HOST=db-test - DB_PORT=5432 script: - "docker-compose run test npm test" services: - docker
Note that the global variables get used by our docker-compose.yml
.
3.3 Starting the server
We haven't started our app since part 1. We have only been using the feedback of tests, which was more enjoyable than continuously refreshing the browser. Do you agree?
Anyway, let's finally start our server. Go back and use the MongoDB set up from this branch. Then, we'll use the free cloud-based database that MongoDB Atlas provides. Read more about getting started with MongoDB Atlas here if you're interested.
Here's the updated server.js
file:
import express from 'express'; import bodyParser from 'body-parser'; import artistRouter from './routes/artistRoutes'; import db from './common/db/db'; import config from './config'; db.connect(config.mongoUri); const app = express(); app.use(bodyParser.json()); app.use(artistRouter); app.get('/', (req, res) => { res.send('Hello World!'); }); app.listen(config.port, () => { console.log(`Listening at http://localhost:${config.port}`); });
Then update the configuration parts to make it work. You can inspect this commit if you get stuck.
3.4 Review
As stated in the beginning, this article's intent was to give you perspective. With that said, here are some of my thoughts on the project:
- TypeScript would have been a great addition, though I wanted to keep the article more accessible.
- Having to rebuild your image every time you add a new package takes time - but should/will you really install packages that often?
- Security is important, yet we did not cover it here. So, I encourage anyone who will use this structure to allocate more focus on it.
- When using a TDD approach, you probably won't create as many test cases as we did all at once - however, I liked to structure the article that way.
- You probably won't be changing your database that often, and Mongoose is in a way acting as a DAO, so you could skip the DAO and use Mongoose directly in your services.
- The routes could have used dependency injection as well.
I also would like to give you some recommendations for further reading that I found useful when setting up the project environment:
- https://github.com/BretFisher/node-docker-good-defaults
- https://medium.com/the-node-js-collection/making-your-node-js-work-everywhere-with-environment-variables-2da8cdf6e786