Unlocking the power of Node.js
Tips to upgrade how you develop powerful backends
Earlier this summer, we hosted a Node.js Backend Masterclass providing an in-depth look at how to build Node.js applications with Platformatic and Fastify, from scratch, and exploring application design approaches and the relevant development philosophies for creating modular, maintainable and effective applications.
In this blog, we will dive into a summary of Node.js best practices with code examples, module management, and packages. We will also delve into the drawbacks of the singleton design pattern for holding state variables and explore dependency injection.
If you're seeking to supercharge your skills and build seamless, scalable applications using Node.js, then you’re in the right place.
Note: For the full content and immersive experience, check out our YouTube video below*
Module Management
A common mistake developers make is using a module singleton to hold state variables. The long-term implication of this is that it renders code refactoring almost impossible due to having no clear boundaries between modules.
As design patterns, singletons allow developers to create an instance of a class that can be accessed globally. However, singletons can make modules throw errors once different versions are loaded in the same process. Moreover, using singletons means leads to a total reliance on package managers to ensure the same versions of modules are loaded every time code is executed.
Let’s look at a simple example of this scenario:
In this example, we have two folders, test-one and test-two. In our test-one folder, we have a.js, b.js, shared.js and all.js. In the shared.js file, we have the code below which generates a random number on execution
export default Math.random();
In the a.js and b.js files, we import the random number from shared.js and simply log the random number generated by the function in the shared.js file.
So the a.js looks like this:
import number from "./shared.js";
console.log(number, "Number in a.js")
And b.js looks like this:
import number from "./shared.js";
console.log(number, "Number in b.js")
Then we import both a.js and b.js in the all.js file:
import("./a.js");
import("./b.js");
If we run the following command in our terminal:
node all.js
We should get the same number generated in both files logged to our console in the terminal:
PS C: \Users\LENOVO\Desktop\Desktop\Node\test-one> node all.js
0.675720195687217 Number in a.js
0.675720195687217 Number in b.js
Let’s take a look at the test-two folder where we will treat the a and b files as singletons.
Here we have three folders named a, b and shared.
In the shared folder, we have a package.json file similar to the package.json file in the test-one folder earlier, and an index.js file.
In the index.js file, we export the same Math.random().
export default Math.random();
In the a and b folders, we also create a package.json file with the dependencies specified. For simplicity, we use the shared folder as a linked dependency.
{
//.....,
"dependencies": {
"shared": "../shared"
}
}
Then we run ‘npm install’ in both the a and b folders which creates a node_modules folder containing the shared folder.
If we run ‘node all.js’ in our test-two folder in the terminal as shown below, we get the same values.
PS C: \Users\LENOVO\Desktop\Desktop\Node\test-two> node all.js
0.7878536635660263 Number in a.js
0.7878536635660263 Number in b.js
However, if we do something slightly different such as mocking up a different version of our shared package, we will get different outputs.
In the b folder, let’s delete the shared subfolder in the node_modules folder. Then copy the shared folder in the parent test-two folder and paste it into the node_modules folder in the b folder.
When we rerun our command, we get different numbers generated.
PS C: \Users\LENOVO\Desktop\Desktop\Node\test-two> node all.js
0.4198896054262986 Number in a.js
0.45573801629657096 Number in b.js
As simple as this illustration is, it gives an insight into the multiple things that could go wrong in larger applications.
For instance, if two singletons use the same package and then you update the package in one singleton without updating the other, your app risks crashing if there are conflicts in the different versions.
Dependency Injection
Dependency injection is a popular technique which makes producing independent and scalable modules easier. In simpler terms, dependency injection is a pattern where instead of requiring or creating dependencies directly inside a module, they are passed as references or parameters. There is the misconception that you need a framework to implement dependency injection, but this is incorrect: you can always use the depedency injection by constructor.
Let’s take a look at a simple example. Say we have a Node project called node-sample with the folder structure below.
In the example.js file, we have:
async function fancyCall() {
console.log("Fancycall")
return 42;
}
export async function buildGetToken() {
let accessToken = null;
const getToken = async () => {
if(!accessToken) accessToken = await fancyCall();
return accessToken;
}
async function stop() {
accessToken = null;
console.log("Access Token Stopped");
}
return {
getToken,
stop
}
}
In the example2.js file we have:
export async function buildDoSomething({ getToken }) {
return {
async something() {
const token = await getToken();
return token * 2;
},
async stop() {
console.log("Something Stopped");
},
};
}
Then in the main.js we consume our dependencies:
import { buildGetToken } from "./example.js";
import { buildDoSomething } from "./example2.js";
const { getToken, stop } = await buildGetToken();
const { something, stop: stop1 } = await buildDoSomething({ getToken });
console.log(await something());
console.log(await something());
console.log(await something());
await stop();
await stop1();
If we declare the buildGetToken function and the buildDoSomething functions in our main.js file, this means we have to modify the entire code every time we want to make changes to it.
Imagine this in a larger application– we’d have to search through our entire codebase to implement our changes. This would go against a common programming rule, “Program to an interface, not an implementation.”
Moreover, dependencies make it easier to attach decorators to code. This forms the basis on which one of Fastify’s major features is built: plugins.
Dependency injection also works well with classes. So if we were using classes, we would have this in the example.js.
async function fancyCall() {
console.log("Fancycall")
return 42;
}
class Example {
constructor() {
this.accessToken = null;
}
async getToken() {
if(!this.accessToken) this.accessToken = await fancyCall();
return this.accessToken
}
async stop() {
this.accessToken = null;
console.log("Access Token Stopped")
}
}
export async function buildGetToken() {
return new Example();
}
Then the example2.js file would look like this:
class DoSomething {
constructor({ example }) {
this.example = example;
}
async something() {
const token = await this.example.getToken();
return token * 2;
}
async stop() {
console.log("Something Stopped");
}
}
export async function buildDoSomething({ example }) {
return new DoSomething({ example });
}
In summary, dependency injection provides a lot of flexibility to our code, allowing us to create and implement multiple modules that are independent of each other.
Split Your Application Packages Into Modules
Most applications are built using the MVC (Model-View-Controller) model, whereby each section of your code has a specific and unique purpose, allowing you to split out the front and backend code into separate components.
As the name would suggest, this model is broken down into three parts: Model code, View code and Control code.
The Model is a central component, working with the database to hold raw-data, logic, and rules of an application.
The View is the user interface section of the app. In backend applications, it contains some HTML, CSS, XML, JS or other languages for the user interfaces. These user interfaces can be used to send basic forms to receive data to complete a process or update your users on a process.
The Controller handles all of the logic of the application. It handles all the processes and methods of the application, sends the response, and handles errors.
With the MVC approach, you only have three places where you can add new features.
When you take this approach to build large-scale applications, you may end up with more files, classes and functions in your application, making your application difficult to debug and refactor.
Although the MVC architecture aims to compartmentalize the various components of your code, sometimes components may end up depending on each other too much, thus reducing the modularity or maintainability of your code.
So, what would be an alternative to the MVC model?
A better approach to building large-scale applications is to adopt modules. Modules can help you scale your application’s complexities, particularly with the One-Module-One-Feature approach.
Under this approach, you would divide your applications into domain logic which spreads across your entire application.
A major advantage of this is that your application scales well with complexity without sacrificing performance. In addition, this approach helps you develop your application into microservices.
By using microservices, you can minimize the logistical complexity. In order to split a monolith into multiple services, you must not be relying on singletons or globals.
Wrapping Up
In this blog, we have seen the benefits of proper module management and appropriate design patterns for large-scale applications to keep our application safe from module conflicts.
We also learned about dependency injection and the flexibility it offers us in the development of our applications.
Most developers appreciate the benefits dependency injection pattern provides for applications.
By following these best practices, you can avoid some of the more common issues experienced.
To continue learning about how to get the most out of Node.js, sign up to our upcoming Node.js Configurations Masterclass:
For any questions you encounter while following this guide, or to simply join our community, find us on Discord.