Something that was not intuitive to me when I was getting into this software development world was how to build the software’s architecture. I did not learn alone how to optimally organize the functions and components I knew how to write.
Some time ago, I had to refactor the NodeJS codebase 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! You can check out a Spanish version of this article here.
Before, we should have a basic knowledge about layered architecture before detailing the example.
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. The 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. This layer may include Hypertext Transfer Protocol or http requests to the server if there is no database connected directly into the app.
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.
The data is transformed or calculated at the service layer. 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 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;
In the create and update methods data transforms by creating a new object with specific key names and values. This includes the creation date and update date timestamps values added at the creating and update methods. Timestamps included to 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 front-end sends the response back 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 methods 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.
Let’s see an example of 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, data validation in the service layer allows multiple models with different validation requirements to use the DAL.
Every method within the service layer calls the DAL injection. The DAL receives the data transformation and sends it to the server.
DAL (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. HTTP requests requiere these variables. An HTTP request, triggered by receiving the service layer’s variables, dispatches a promise which is expected to resolve with an object. The object is defined after the server response is available.
The DAL promise resolves 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!
You can get a new post notification directly to your email by signing up at the following link.
Related articles
The following CTRL-Y articles are related somewhat to this post. You may want to check them out!:
- API Testing with Mocha
- Filtering with GraphQL and Prisma: What NOT to Do
- Night Owl Home Office
- Resources for Newbie Web Coders
- Industrial Engineering and Web Development
- Incorporating Digital Assets into Your Finances
- My First Year Writing Code
By the way – A playlist for you
I wrote a lot of this article listening to the Afro House Spotify playlist. A great playlist for banging your head while writing.
This is great article, thanks for sharing. How do you deal with dependencies among modules?
Great question Andrej!
I would suggest dependency injection when dealing with dependencies among modules. So, instead of importing the dependencies inside the module, pass the dependencies as parameters.
Even though dependency injection is not implemented on this article I would suggest reading this article from The Software House to have an idea on how to implement it. I am definitely considering updating this article to explain and implement dependency injection.
I hope that this information helps. Thanks for taking your time reading the article and posting your question.
Thanks. This blog post was very helpful. Modu based strcture is much cleaner.
Another suggestion would be adding service layer, but from architecture point of view it’s the same as adding controller layer 🙂
Why you leave components/ directory empty?
Hi Pierre,
The components directory is empty for article and example simplification. On a real code base, the components directory has multiple directories or files for components like buttons, text inputs, etc. depending on how the developer organizes their code. Hope this answer clarifies your doubt.
Thanks for taking your time reading the article and posting your question.
Hey there, I love all the points you made on that topic. There is definitely a great deal to know about this subject, and with that said, feel free to visit my blog Article Star to learn more about Cryptocurrency.
Great job site admin! You have made it look so easy talking about that topic, providing your readers some vital information. I would love to see more helpful articles like this, so please keep posting! I also have great posts about Entrepreneurs, check out my weblog at YH9
This was a delight to read. You show an impressive grasp on this subject! I specialize about Airport Transfer and you can see my posts here at my blog YR4 Keep up the incredible work!