In a non-trivial application, the architecture is as important as the quality of the code itself. We can have well-written pieces of code, but if we don’t have good organization, we’ll have a hard time as the complexity increases. There’s no need to wait until the project is half-way done to start thinking about the architecture; the best time is before starting, using our goals as beacons for our choices.
Node.js doesn’t have a de facto framework with strong opinions on architecture and code organization in the same way that Ruby has the Rails framework, for example. As such, it can be difficult to get started with building full web applications with Node.
In this tutorial, we’re going to build the basic functionality of a note-taking app using the MVC architecture. To accomplish this, we’re going to employ the Hapi.js framework for Node.js and SQLite as a database, using Sequelize.js, plus other small utilities, to speed up our development. We’re going to build the views using Pug, the templating language.
What is MVC?
Model-View-Controller (or MVC) is probably one of the most popular architectures for applications. As with a lot of other cool things in computer history, the MVC model was conceived at PARC for the Smalltalk language as a solution to the problem of organizing applications with graphical user interfaces. It was created for desktop applications, but since then, the idea has been adapted to other mediums including the Web.
We can describe the MVC architecture in simple terms:
- Model: the part of our application that will deal with the database or any data-related functionality.
- View: everything the user will see — basically, the pages that we’re going to send to the client.
- Controller: the logic of our site, and the glue between models and views. Here we call our models to get the data, then we put that data on our views to be sent to the users.
Our application will allow us to create, view, edit and delete plain-text notes. It won’t have other functionality, but because we’ll have a solid architecture already defined we won’t have a lot of trouble adding things later.
This tutorial assumes you have a recent version of Node installed on your machine. If this isn’t the case, please consult our tutorial on getting up and running with Node.
You can check out the final application in the accompanying GitHub repository, so you get a general overview of the application structure.
Laying out the Foundation
The first step when building any Node.js application is to create a
package.json file, which is going to contain all of our dependencies and scripts. Instead of creating this file manually, npm can do the job for us using the
mkdir notes-board cd notes-board npm init -y
After the process is complete, we’ll have a
package.json file ready to use.
Note: if you’re not familiar with these commands, checkout our Beginner’s Guide to npm.
We’re going to proceed to install Hapi.js — the framework of choice for this tutorial. It provides a good balance between simplicity, stability and features that will work well for our use case (although there are other options that would also work just fine).
npm install @email@example.com
This command will download Hapi.js and add it to our
package.json file as a dependency.
Note: We’ve specified v18.4.0 of Hapi.js, as it’s compatible with Node versions 8, 10, and 12. If you’re using Node 12, you can opt to install the latest version (Hapi v19.1.0).
Now we can create our entry file — the web server that will start everything. Go ahead and create a
server.js file in your application directory and add the following code to it:
"use strict"; const Hapi = require("@hapi/hapi"); const Settings = require("./settings"); const init = async () => const server = new Hapi.Server( port: Settings.port ); server.route( method: "GET", path: "/", handler: (request, h) => return "Hello, world!"; ); await server.start(); console.log(`Server running at: $server.info.uri`); ; process.on("unhandledRejection", err => console.log(err); process.exit(1); ); init();
This is going to be the foundation of our application.
First, we indicate that we’re going to use strict mode, which is a common practice when using the Hapi.js framework.
Next, we include our dependencies and instantiate a new server object where we set the connection port to
3000 (the port can be any number above 1023 and below 65535).
Our first route for our server will work as a test to see if everything is working, so a “Hello, world!” message is enough for us. In each route, we have to define the HTTP method and path (URL) that it will respond to, and a handler, which is a function that will process the HTTP request. The handler function can take two arguments:
h. The first one contains information about the HTTP call, and the second will provide us with methods to handle our response to that call.
Finally, we start our server with the
Storing Our Settings
It’s good practice to store our configuration variables in a dedicated file. This file exports a JSON object containing our data, where each key is assigned from an environment variable — but without forgetting a fallback value.
In this file, we can also have different settings depending on our environment (such as development or production). For example, we can have an in-memory instance of SQLite for development purposes, but a real SQLite database file on production.
Selecting the settings depending on the current environment is quite simple. Since we also have an
env variable in our file which will contain either
production, we can do something like the following to get the database settings:
const dbSettings = Settings[Settings.env].db;
dbSettings will contain the setting of an in-memory database when the
env variable is
development, or will contain the path of a database file when the
env variable is
Also, we can add support for a
.env file, where we can store our environment variables locally for development purposes. This is accomplished using a package like dotenv for Node.js, which will read a
.env file from the root of our project and automatically add the found values to the environment.
Note: if you decide to also use a
.env file, make sure you install the package with
npm install dotenv and add it to
.gitignore so you don’t publish any sensitive information.
settings.js file will look like this:
// This will load our .env file and add the values to process.env, // IMPORTANT: Omit this line if you don't want to use this functionality require("dotenv").config( silent: true ); module.exports = port: process.env.PORT ;
Now we can start our application by executing the following command and navigating to http://localhost:3000 in our web browser:
Note: this project was tested on Node v12.15.0. If you get any errors, ensure you have an updated installation.
Defining the Routes
The definition of routes gives us an overview of the functionality supported by our application. To create our additional routes, we just have to replicate the structure of the route that we already have in our
server.js file, changing the content of each one.
Let’s start by creating a new directory called
lib in our project. Here we’re going to include all the JS components.
lib, let’s create a
routes.js file and add the following content:
"use strict"; const Path = require("path"); module.exports = [ // we’re going to define our routes here ];
In this file, we’ll export an array of objects that contain each route of our application. To define the first route, add the following object to the array:
method: "GET", path: "/", handler: (request, h) => return "All the notes will appear here"; , config: description: "Gets all the notes available" ,
Our first route is for the home page (
/), and since it will only return information, we assign it a
GET method. For now, it will only give us the message “All the notes will appear here”, which we’re going to change later for a controller function. The
description field in the
config section is only for documentation purposes.
Then, we create the four routes for our notes under the
/note/ path. Since we’re building a CRUD application, we’ll need one route for each action with the corresponding HTTP methods.
Add the following definitions next to the previous route:
method: "POST", path: "/note", handler: (request, h) => return "New note"; , config: description: "Adds a new note" , method: "GET", path: "/note/slug", handler: (request, h) => return "This is a note"; , config: description: "Gets the content of a note" , method: "PUT", path: "/note/slug", handler: (request, h) => return "Edit a note"; , config: description: "Updates the selected note" , method: "GET", path: "/note/slug/delete", handler: (request, h) => return "This note no longer exists"; , config: description: "Deletes the selected note"
We’ve done the same as in the previous route definition, but this time we’ve changed the method to match the action we want to execute.
The only exception is the delete route. In this case, we’re going to define it with the
GET method rather than
DELETE and add an extra
/delete in the path. This way, we can call the delete action just by visiting the corresponding URL.
Note: if you plan to implement a strict REST interface, then you would have to use the
DELETE method and remove the
/delete part of the path.
We can name parameters in the path by surrounding the word in curly braces. Since we’re going to identify notes by a slug, we add
slug to each path, with the exception of the
POST route; we don’t need it there because we’re not going to interact with a specific note, but to create one.
You can read more about Hapi.js routes on the official documentation.
Now, we have to add our new routes to the
server.js file. Let’s import the routes file at the top of the file:
const Routes = require("./lib/routes");
Then let’s replace our current test route with the following:
How to Build and Structure a Node.js MVC Application