Something that was not intuitive to me when I was getting into this software development world was how to build the software's architecture. Sure, I understood how to write functions, and components but optimally organizing them was not something I learned alone.
Some time ago, I was assigned the task to refactor the NodeJS codebase architecture into layered architecture. I didn't have any idea what layered architecture was or how it looked like. So I DuckDuckGoed and Googled search and quickly noticed a couple of blog posts about layered architecture but not actual code examples. So I'm providing a before and after layered architecture example based on what I learned!
Before going into this software architecture journey, let's understand what layered architecture is.
What is Layered Architecture?
A pattern used in software development where roles and responsibilities within the application (app) are separated into layers. Per Chapter 1: Layered Architecture from Software Architecture Patterns by Mark Richards: "Each layer in the architecture forms an abstraction around the work that needs to be done to satisfy a particular business request."
So, one of layered architecture's goals is to separate concerns among components. Another goal is to organize layers so they can perform a specific role within the app.
A small app consists of three (3) layers: Router Layer, Service Layer, and Data Access Layer (DAL). The number of layers will depend on how complex your app turns out.
Router Layer contains the app programming interface (API) routes of the app. Its only job is to return a response from the server.
Service Layer handles the business logic of the app. This means that data is transformed or calculated to meet the database model's requirements before being sent to the server.
Data Access Layer (DAL) has access to the database to create, delete, or edit data. It is where all the request and response from server logic is handled. If there is no database connected directly into the app, this layer may include Hypertext Transfer Protocol or http requests to the server.
A key concept of the architecture layer is how data move between layers. To understand this movement, let's look at the diagram below for reference.
Moving between Layers
The data journey starts at the presentation layer once the user clicks a button. The click triggers a function that sends the API's data request, located at the router layer. The router layer method calls a component located at the service layer, and its concern is to wait for the service layer's response to return it.
At the service layer, the data is transformed or calculated. Hypothetically, if a user must reset their password every 90 days, it's here at the service layer, where the calculations are done before passing the results to the server. After transformation, the service layer component calls an injected DAL component, and the data is passed into the DAL.
Finally, the data request is made to the database at the DAL. The DAL is structured as a request inside a promise, the promise being resolved with the database's response.
When the DAL promise resolves with the database's response, the response returns to the service layer, which then the service layer itself returns to the router layer. When the response reaches the router layer, the data reaches the user back at the presentation layer.
It is crucial to understand that the data moves from one layer to another layer without skipping layers in between. The data request moves from the router layer to the service layer and then to the DAL.
Later, the response is returned from the DAL to the service layer, and finally, to the router layer. Neither the request nor the response goes from the router layer to the DAL layer or from the DAL layer to the router layer.
Now that we understand what layered architecture software is, let's learn how layered architecture was implemented. Let's use, as a reference, the action of updating a profile to illustrate software before and after layered architecture.
Implementing Layered Architecture
Before Layered Architecture Implementation
Let's begin with the file structure before implementing the layered architecture.
my-project/
├── node_modules/
├── config/
│ ├── utils.js
├── components/
├── pages/
│ ├── profile.js
│ ├── index.js
├── public/
│ ├── styles.css
├── routes/
│ ├── alerts.js
│ ├── notifications.js
│ ├── profile.js
│ ├── index.js
├── app.js
├── routes.js
├── package.json
├── package-lock.json
└── README.md
The pages / profile.js directory contains the front-end code for the user profile. It's here where the user interaction triggers the data trajectory to the server and back. Even though this directory doesn't contain NodeJs code, it's important to understand when NodeJs interacts with the app's front end side.
For this example, the front-end is written with the ReactJs framework.
const Profile = ({ user }) => {
// User prop is destructured
const { id, name, lastname, email } = user;
// Form states are initialized with user prop's information
const [nameState, handleName] = useState(`${name}`);
const [lNameState, handleLName] = useState(`${lastname}`);
const [emailState, handleEmail] = useState(`${email}`);
// Url that sends request to api
const url = `profile/update/${id}`;
return (
<form
action={url}
method="post"
style={{ display: 'flex', flexDirection: 'column' }}
>
<input
placedholder="Name"
value={nameState}
onChange={handleName}
type="text"
name="name"
/>
<input
placedholder="Last Name"
value={lNameState}
onChange={handleLName}
type="text"
name="lastname"
/>
<div style={{ display: 'flex', flexDirection: 'row' }}>
<input
placedholder="Email"
value={emailState}
onChange={handleEmail}
required
type="email"
name="email"
/>
<button type="submit">
Save
</button>
</div>
</form>
);
};
export default Profile;
The code above is the entry point where the end-user interacts with the app. It's a form that includes text inputs for the name, last name, and email and a "save" button.
The user types inside the text input the information described by the placeholder. Later, the user saves her/his/their information for future reference by clicking the "save" button. When the "save" button is clicked, it triggers a POST routing method that sends the user data to the Uniform Resource Locator, or URL, passed into the method.
Before implementing the layered architecture, the codebase I encountered included all the app routing methods inside the directory my-project / routes.js. It looked similar to:
module.exports = (app, routes) => {
// Profile
app.get('/profile/:id/:message?', routes.profile.details);
app.post('/profile/new/:page?, routes.profile.create);
app.post('/profile/update/:id/:page?', routes.profile.update);
app.post('/profile/delete/:id', routes.profile.delete);
// Notifications
app.get('/notifications', routes.notifications.add);
app.post('/notifications/send/:message?', routes.notifications.send);
// Alerts
app.get('/alerts/breaking', routes.alerts.read);
app.post('/alerts/breaking', routes.alerts.send);
};
By keeping all the routing methods in the same directory, this codebase may introduce compiling errors or software bugs between components that, normally, wouldn't interact between themselves.
Each routing method requires three parameters: 1) route, 2) authentication and, 3) request/response method. The request/response method sends and receives the data request to the server.
Another detail worth highlighting about the codebase before implementing layered architecture is that the request/response methods for the profile component were defined within the routes / profile.js directory:
const moment = require('moment');
const apiUrl = require('../config/constants').API_URL;
const baseApiUrl = `${apiUrl}`;
const profile = {
details: (req, res) => {
const { id } = req.params;
request({
uri: `${baseApiUrl}/${id}`,
method: 'GET',
json: true,
}, (err, r, body) => {
const { id, name, lastname, email } = body;
const info = {
id,
name,
lastname,
email,
};
if (err || r.statusCode !== 200) {
res.status(400).json({
error: err || r.statusCode
});
return null;
}
res.json({
status: 'success',
post: info,
});
});
},
create: (req, res) => {
const { id, name, lastname, email } = req.body;
const createDate = moment().format();
const info = {
id,
name,
lastname,
email,
createDate,
};
request({
uri: `${baseApiUrl}`,
method: 'POST',
body: info,
json: true,
}, (err, r, body) => {
if (err || r.statusCode !== 201) {
res.status(400).json({
error: err || r.statusCode
});
return null;
}
res.json({
status: 'success',
post: body,
});
});
},
update: (req, res) => {
const { id, name, lastname, email } = req.body;
const updateDate = moment().format();
const info = {
name,
lastname,
email,
updateDate,
};
request({
uri: `${baseApiUrl}/${id}`,
method: 'PUT',
body: info,
json: true,
}, (err, r, body) => {
if (err || r.statusCode !== 200) {
res.status(400).json({
error: err || r.statusCode,
statusText: err || body.message,
});
return null;
}
res.json({
status: 'success',
post: body,
})
});
},
delete: (req, res) => {
const { id } = req.params;
request({
uri: `${baseApiUrl}/${id}`,
method: 'DELETE',
json: true,
}, (err, r, body) => {
if (err || r.statusCode !== 200) {
res.status(400).json({
error: err || r.statusCode
});
return null;
}
res.json({
success: 'OK',
});
});
},
}
module.exports = profile;
Notice how, in the create and update methods, data is being transformed by creating a new object with specific key names and values. This includes the creation date and update date timestamps values added at the create and update methods. Timestamps are included, so they comply with the server's data models.
Right after data transformation, there is an http request to the server. Whatever the server response is, the response is sent back to the front-end in JSON format. So, this codebase handles the business logic and server access at the same layer.
Overall, the before mentioned code base mixed too many concerns between layers of work. At the routing layer, components that do not interact between themselves throughout the app are handled together. While business logic and server requests are also handled together.
Layered Architecture Implementation
Recalling the objectives for layered architecture, it's important to separate concerns among components. Also, layers must perform a specific role within the app.
To separate concerns, I created a module for profile, notification, and for alerts. Inside each module, I created the three layers: 1) Router layer that includes all the routing method for the specific module, 2) Service layer that includes business logic components, and 3) DAL that includes the server request and response method.
Below is an example of the file structure considering layered architecture:
my-project/
├── node_modules/
├── config/
│ ├── utils.js
├── components/
├── modules/
│ │ ├── profile/
│ │ │ ├── routesProfile.js
│ │ │ ├── serviceProfile.js
│ │ │ ├── dalProfile.js
│ │ │ ├── index.js
│ │ ├── notification/
│ │ │ ├── routesNotification.js
│ │ │ ├── serviceNotification.js
│ │ │ ├── dalNotification.js
│ │ │ ├── index.js
│ │ ├── alerts/
│ │ │ ├── routesAlert.js
│ │ │ ├── serviceAlert.js
│ │ │ ├── dalAlert.js
│ │ │ ├── index.js
├── pages/
│ ├── profile.js
│ ├── index.js
├── public/
│ ├── styles.css
├── app.js
├── routes.js
├── package.json
├── package-lock.json
└── README.md
Same as before implementation, the front-end side triggers the routing method.
Instead of having all the routing methods from the app in my-project/routes.js, I:
1) Imported all modules indexes in my-project/routes.js. An example of modules/ profile / index.js below.
// Inside modules/profile/index.js
const profileService = require('./profileService');
const profileRoutes = require('./profileRoutes');
module.exports = {
profileService,
profileRoutes,
};
2) Called routing layer.
3) Pass each module into its routing layer. Example below.
// Inside my-projects/routes.js
const profile = require('./modules/profile/index');
const alert = require('./modules/alert/index');
const notification = require('./modules/notification/index');
module.exports = (
app,
) => {
profile.profileRoutes(app, profile);
alert.alertasRoutes(app, alert);
notification.notificationRoutes(app, notification);
};
Look how clean the my-project/routes.js is! Instead of handling all the app's routing methods, we call the module's routing layer. In this case, the profile module.
The front-end triggers a call to profile.profileRoutes(app, profile) to access all the routing methods regarding the profile component.
Routing Layer
Here is an example of how I wrote the routing layer for the profile module.
// Inside modules/profile/routingProfile.js
module.exports = (app, routes) => {
// Route for get profile details
app.get('/profile/:id/:message?',
async (req, res) => {
const { params} = req;
const { id } = params;
try {
const details = await
profile.profileService.getProfileDetails(id);
res.json(details);
} catch (error) {
res.json({ status: 'error', message: error.message });
}
});
// Route for post create profile
app.post('/profile/new/:page?',
async (req, res) => {
const { body} = req;
try {
const new = await
profile.profileService.postCreateProfile(body);
res.json(new);
} catch (error) {
res.json({ status: 'error', message: error.message });
}
});
// Route for post update profile
app.post('/profile/update/:id/:page?', async (req, res) => {
const { body, params} = req;
const { id } = params;
try {
const update = await
profile.profileService.postUpdateProfile(id, body);
res.json(update);
} catch (error) {
res.json({ status: 'error', message: error });
}
});
// Route for post delete profile
app.post('/profile/delete/:id',
async (req, res) => {
const { params } = req;
const { id } = params;
try {
const delete = await
profile.profileService.postDeleteProfile(id);
res.json(delete);
} catch (e) {
res.json({ status: 'error', error: e });
}
});
}
Notice how the routing method calls the corresponding service layer method and waits for its response. Also, notice how that's the routing layer's only job.
Let's recall that the URL valued triggered from the front-end when the user's clicked the "update" button is "/profile/update/:id/." The routing layer will have to wait for postUpdateProfile() method's response at the service layer to finish its work.
Now that the service layer is called let's see how I wrote the profile module's service layer.
Service Layer
An example of the service layer I wrote below:
const moment = require('moment');
const { API_URL } = require('../../config/constants');
const baseApiUrl = `${API_URL}`;
const profileDal = require('./profileDal')();
const profileService = {
/**
* Gets profile detail
* @param {String} id - profile identification number
*/
getDetailProfile: (id) => profileDal.getDetailProfile(id, token),
/**
* Creates profile
* @param {Object} body - profile information
*/
postCreateProfile: (body) => {
const { name, lastname, email } = body;
const createDate = moment().format();
const profile = {
name,
lastname,
email,
createDate,
};
return profileDal.postCreateProfile(profile);
},
/**
* Updates profile
* @param {String} id - profile identification number
* @param {Object} body - profile information
*/
postUpdateProfile: (id, body) => {
const { name, lastname, email } = body;
const updateDate = moment().format();
const data = {
name,
lastname,
email,
updateDate,
};
return profileDal.postUpdateProfile(id, data);
},
/**
* Deletes the selected profile
* @param {String} id - profile identification number
*/
postDeleteProfile: (id) => profileDal.postDeleteProfile(id),
};
module.exports = profileService;
This layer is specific to business logic for the profile module. It focuses on transforming the data, so it complies with the request method's data models.
So, if the data model requires a timestamp to create and update data, it's here where you may want to include that data. See postUpdateProfile() above for example.
You can also validate data in the service layer. Validating data in this layer guarantees that the DAL will receive the data as needed and that its only job will be to send data to the middleware or server. Furthermore, validating data in the service layer allows the DAL to be used by multiple modules with different validation requirements.
The DAL is injected in this layer to be called within every method in this layer. The results of the data transformation are passed into the DAL to be sent to the server.
Data Access Layer
The DAL I wrote for the profile module is something like:
const request = require('request');
const { API_URL } = require('../../config/constants');
const baseApiUrl = `${API_URL}`;
module.exports = () => ({
/**
* Gets profile details
* @param {String} id - profile id
*/
getDetailProfile: (id) => new Promise((resolve, reject) => {
request({
uri: `${baseApiUrl}/${id}`,
method: 'GET',
json: true,
}, (err, r, body) => {
const { id, name, lastname, email } = body;
const profile = {
id,
name,
lastname,
email,
};
if (err || r.statusCode !== 200) {
return reject(err);
}
return resolve({
status: 'success',
profile,
});
});
}),
/**
* Creates new profile
* @param {Object} body - profile information
*/
postCreateProfile: (body) => new Promise((resolve, reject) => {
request({
uri: baseApiUrl,
method: 'POST',
body,
json: true,
}, (err, r, b) => {
if (err || r.statusCode !== 201) {
return reject(err);
}
return resolve(b);
});
}),
/**
* Updates profile
* @param {String} id - profile id
* @param {Object} body - profile information
*/
postUpdateProfile: (id, body) => new Promise((resolve, reject) => {
request({
uri: `${baseApiUrl}/${id}`,
method: 'PUT',
body,
json: true,
}, (err, r, b) => {
if (err || r.statusCode !== 200) {
return reject(err);
}
return resolve({
status: 'success',
post: b,
});
});
}),
/**
* Deletes profile
* @param {String} id - profile id
*/
postDeleteProfile: (id, token) => new Promise((resolve, reject) => {
request({
uri: `${baseApiUrl}/${id}`,
method: 'DELETE',
json: true,
}, (err, r) => {
if (err || r.statusCode !== 200) {
return reject(err);
}
return resolve({ status: 'OK' });
});
}),
});
The DAL methods receive variables from the service layer. These variables are required for http requests. When the http request is triggered by receiving the service layer's variables, it dispatches a promise that is expected to resolve with an object. The object is defined after the server response is available.
If the request is successful, the DAL promise is resolved with an object that returns to the service layer, which itself returns to the routing layer. When the routing layer receives the object returned by the service layer, the routing layer sends the object in JSON format to the front-end.
And that, my friends, is how I implemented layered architecture for a NodeJs code base. I know that it looks like a lot of work, but I did learn so much about this codebase after this project that I feel completely comfortable implementing or fixing things.
Thank you so much for reading this far!
By the Way
I wrote a lot of this article listening to the Afro House Spotify playlist. A great playlist for banging your head while writing.
This article was originally posted on ctrl-y blog. Also, you can find a Spanish version of this article in ctrl-y blog/es.
Top comments (2)
Nice article, thanks a lot!
Hi, what about controllers, Is not necessary to separate that logic from routers?
Regards