Node js

My Journey with Node.js: Building Scalable Backend Systems

Introduction

When I first ventured into the world of backend development, I was overwhelmed by the sheer number of technologies and frameworks available. As a frontend developer with a background in JavaScript, I wanted to leverage my existing skills while expanding my horizons. That’s when I discovered Node.js, a runtime environment that allows you to execute JavaScript on the server side. My journey with Node.js has been filled with challenges, learning experiences, and ultimately, a sense of accomplishment as I built scalable and efficient backend systems.

Why Node.js?

Before diving into Node.js, I spent some time understanding why it was so popular among developers. Node.js is built on Chrome’s V8 JavaScript engine and is designed to build scalable and high-performance applications. Its non-blocking, event-driven architecture makes it ideal for handling I/O-intensive operations, such as reading and writing to the file system, interacting with databases, and handling multiple concurrent connections.

One of the key features that attracted me to Node.js was its vibrant and supportive community. The Node.js ecosystem is vast, with numerous libraries, tools, and resources available to help developers build robust and scalable applications. The community’s willingness to share knowledge and help each other was a significant factor in my decision to learn Node.js.

Additionally, Node.js allows for full-stack JavaScript development, meaning you can use JavaScript for both the frontend and backend of your applications. This was particularly appealing to me, as it meant I could leverage my existing JavaScript skills and knowledge while expanding into backend development.

Setting Up the Environment

The first step in my journey was setting up my development environment. I followed the official Node.js documentation to install Node.js and npm (Node Package Manager) on my machine. The installation process was straightforward, and I was up and running in no time.

Once I had Node.js installed, I created my first “Hello World” application to ensure everything was working correctly. I created a new file called app.js and added the following code:

const http = require('http');

const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('Hello World\n');
});

server.listen(3000, () => {
console.log('Server running at http://localhost:3000/');
});

I then ran the application using the command node app.js and opened my browser to http://localhost:3000. Seeing the “Hello World” message displayed in my browser was a thrilling moment. It felt like I had taken the first step into a new world of possibilities.

Understanding Asynchronous JavaScript

One of the fundamental concepts in Node.js is asynchronous programming. Asynchronous programming allows you to perform non-blocking operations, meaning your application can continue executing other tasks while waiting for I/O operations to complete. This is crucial for building scalable and high-performance applications.

At first, I struggled with understanding callbacks and the event loop. Callbacks are functions that are passed as arguments to other functions and are executed once an operation is completed. The event loop is a mechanism that allows Node.js to perform non-blocking I/O operations by offloading operations to the system kernel whenever possible.

To better understand callbacks, I created a simple example that reads the contents of a file asynchronously:

const fs = require('fs');

fs.readFile('example.txt', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
console.log(data);
});

In this example, the fs.readFile function reads the contents of example.txt asynchronously. Once the operation is completed, the callback function is executed, and the contents of the file are logged to the console.

While callbacks are a fundamental part of asynchronous programming in Node.js, they can lead to “callback hell,” where callbacks are nested within callbacks, making the code difficult to read and maintain. To address this issue, I learned about Promises and async/await.

Promises

Promises are a more elegant way to handle asynchronous operations. A Promise represents the eventual completion (or failure) of an asynchronous operation and allows you to chain multiple asynchronous operations together.

I refactored my file reading example to use Promises:

const fs = require('fs').promises;

fs.readFile('example.txt', 'utf8')
.then(data => {
console.log(data);
})
.catch(err => {
console.error(err);
});

In this example, the fs.readFile function returns a Promise that resolves with the contents of the file or rejects with an error. The then method is used to handle the resolved value, and the catch method is used to handle any errors.

Async/Await

Async/await is a syntactic sugar on top of Promises that makes asynchronous code easier to read and write. The async keyword is used to define an asynchronous function, and the await keyword is used to wait for a Promise to resolve.

I refactored my file reading example once again to use async/await:

const fs = require('fs').promises;

async function readFile() {
try {
const data = await fs.readFile('example.txt', 'utf8');
console.log(data);
} catch (err) {
console.error(err);
}
}

readFile();

In this example, the readFile function is defined as an asynchronous function using the async keyword. The await keyword is used to wait for the fs.readFile Promise to resolve, and the result is logged to the console. Any errors are caught and logged using a try/catch block.

Building a RESTful API

With a solid understanding of asynchronous programming, I was ready to tackle a more complex project: building a RESTful API. A RESTful API is an application programming interface that adheres to the principles of REST (Representational State Transfer). It allows clients to interact with server-side resources using standard HTTP methods, such as GET, POST, PUT, and DELETE.

I decided to use Express.js, a minimal and flexible Node.js web application framework that provides a robust set of features for building web and mobile applications. Express.js simplifies the process of building RESTful APIs by providing a layer of fundamental web application features, such as routing, middleware, and request/response handling.

Setting Up Express.js

The first step in building my RESTful API was setting up Express.js. I created a new Node.js project and installed Express.js using npm:

npm init -y
npm install express

I then created a new file called server.js and added the following code to set up a basic Express.js server:

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => {
res.send('Hello World!');
});

app.listen(port, () => {
console.log(`Server running at http://localhost:${port}/`);
});

I ran the server using the command node server.js and opened my browser to http://localhost:3000. Seeing the “Hello World!” message displayed in my browser was a thrilling moment. It felt like I had taken the first step in building a RESTful API.

Creating API Endpoints

The next step was creating API endpoints for my RESTful API. I decided to build a simple API for managing a list of tasks. I created the following endpoints:

  • GET /tasks: Retrieve a list of all tasks.
  • GET /tasks/:id: Retrieve a specific task by ID.
  • POST /tasks: Create a new task.
  • PUT /tasks/:id: Update a specific task by ID.
  • DELETE /tasks/:id: Delete a specific task by ID.

I started by creating an in-memory array to store the tasks:

let tasks = [
{ id: 1, title: 'Task 1', completed: false },
{ id: 2, title: 'Task 2', completed: true },
];

I then created the necessary endpoints to handle the CRUD (Create, Read, Update, Delete) operations for the tasks:

app.use(express.json());

// Retrieve a list of all tasks
app.get('/tasks', (req, res) => {
res.json(tasks);
});

// Retrieve a specific task by ID
app.get('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
res.status(404).send('Task not found');
} else {
res.json(task);
}
});

// Create a new task
app.post('/tasks', (req, res) => {
const task = {
id: tasks.length + 1,
title: req.body.title,
completed: req.body.completed || false,
};
tasks.push(task);
res.status(201).json(task);
});

// Update a specific task by ID
app.put('/tasks/:id', (req, res) => {
const task = tasks.find(t => t.id === parseInt(req.params.id));
if (!task) {
res.status(404).send('Task not found');
} else {
task.title = req.body.title || task.title;
task.completed = req.body.completed || task.completed;
res.json(task);
}
});

// Delete a specific task by ID
app.delete('/tasks/:id', (req, res) => {
const taskIndex = tasks.findIndex(t => t.id === parseInt(req.params.id));
if (taskIndex === -1) {
res.status(404).send('Task not found');
} else {
tasks.splice(taskIndex, 1);
res.status(204).send();
}
});

In this example, the app.use(express.json()) middleware is used to parse incoming JSON requests. The GET /tasks endpoint retrieves a list of all tasks, and the GET /tasks/:id endpoint retrieves a specific task by ID. The POST /tasks endpoint creates a new task, the PUT /tasks/:id endpoint updates a specific task by ID, and the DELETE /tasks/:id endpoint deletes a specific task by ID.

Testing the API

With the API endpoints in place, I was ready to test my RESTful API. I used Postman, a popular API development and testing tool, to send requests to my API endpoints and verify that they were working correctly.

I started by sending a GET request to http://localhost:3000/tasks to retrieve a list of all tasks. The response was a JSON array containing the two tasks I had created:

[
{ "id": 1, "title": "Task 1", "completed": false },
{ "id": 2, "title": "Task 2", "completed": true }
]

I then sent a GET request to http://localhost:3000/tasks/1 to retrieve the task with an ID of 1. The response was a JSON object containing the task:

{ "id": 1, "title": "Task 1", "completed": false }

Next, I sent a POST request to http://localhost:3000/tasks with a JSON body containing the title of a new task:

{ "title": "Task 3" }

The response was a JSON object containing the newly created task, with an ID of 3 and a default completed value of false:

{ "id": 3, "title": "Task 3", "completed": false }

I then sent a PUT request to http://localhost:3000/tasks/3 with a JSON body containing an updated title and completed value for the task:

{ "title": "Updated Task 3", "completed": true }

The response was a JSON object containing the updated task:

{ "id": 3, "title": "Updated Task 3", "completed": true }

Finally, I sent a DELETE request to http://localhost:3000/tasks/3 to delete the task with an ID of 3. The response was an empty body with a status code of 204, indicating that the task had been successfully deleted.

Working with Databases

While building a RESTful API with in-memory data was a great learning experience, I knew that real-world applications required persistent data storage. That’s when I decided to explore databases and how to integrate them with Node.js.

MongoDB and Mongoose

I chose to use MongoDB, a popular NoSQL database, for my Node.js applications. MongoDB is known for its flexibility, scalability, and ease of use. It stores data in JSON-like documents, which makes it a natural fit for Node.js applications.

To interact with MongoDB in Node.js, I used Mongoose, an Object Data Modeling (ODM) library for MongoDB and Node.js. Mongoose provides a schema-based solution to model your application data and includes built-in type casting, validation, query building, and business logic hooks.

In this example, the generateToken function generates a JWT token for a given user, and the verifyToken function verifies the authenticity of a JWT token. The authenticate middleware function checks for the presence of a JWT token in the Authorization header of the incoming request, verifies the token, and attaches the decoded user information to the request object.

I then imported the authenticate middleware function in my server.js file and used it to protect my API endpoints:

Conclusion

My journey with Node.js has been an incredible learning experience. From setting up my first Node.js project to building scalable and secure backend systems, I have gained a deep appreciation for this powerful runtime environment. Node.js has changed the way I think about backend development, and it has given me the tools and confidence to tackle more ambitious projects.

As I continue to learn and grow as a developer, I am excited to explore more advanced Node.js concepts and build even more complex and scalable applications. For anyone starting their journey with Node.js, my advice is to embrace the challenges, keep building, and never stop learning.

Building my first backend systems with Node.js was just the beginning. I am excited to see where this journey takes me next and to continue pushing the boundaries of what I can create with this amazing technology. Whether you are a seasoned developer or just starting out, Node.js offers a world of possibilities and a community of support to help you along the way. So, dive in, start building, and see where your journey with Node.js takes you.

Leave a Reply