Development

Mastering Microservices: A Hands-On Tutorial with Node.js, RabbitMQ, Nginx, and Docker

In this article, Co.Lab Alum, David, shares a tutorial on how to setup a Microservice

David Chibueze Ndubuisi
February 12, 2024

Microservices architecture is a smart way to design applications by breaking them into smaller, independent components—microservices—each focusing on a specific task. These microservices operate autonomously, allowing for independent development, deployment, and scaling. This flexibility enables developers to tweak or update specific components without overhauling the entire application. This is in stark contrast to traditional monolithic architecture, where the whole application is treated as one indivisible unit, often leading to inflexibility and scalability issues.

Before diving into this tutorial, if you find microservices mysterious, check out my previous article for a detailed explanation. In this hands-on tutorial, we'll build a real-time chat server using Node.js, Socket.io, RabbitMQ, and Docker. Get ready for a practical journey into the world of microservices! Let's begin.

Join an Upcoming Cohort!

Get real-world experience to land your dream role in tech. Join us as a Product Manager, Designer or Developer, and put your skills into practice by shipping a real MVP! 🚀

Prerequisites

Before getting into the development process, ensure you have the following prerequisites installed on your machine and ready:

1. Node.js and npm

Make sure you have Node.js and npm (Node Package Manager) installed. You can download them from the Node.js official website.

# Check Node.js and npm are installed correctly
node -v
npm -v

2. TypeScript

As we'll be using TypeScript for enhanced development, install it globally using npm.

# Install TypeScript globally
npm install -g typescript

# Check TypeScript version
tsc -v

3. Docker

Docker will be used for containerization. Download and install Docker from Docker's official website.

4. MongoDB

Ensure you have MongoDB installed for data storage. You can download MongoDB Community Server from MongoDB's official website or use the cloud cluster.

5. Nginx

For setting up Nginx as a reverse proxy, you can follow the installation instructions on Nginx's official documentation.

6. Postman (Optional)

To test the API endpoints, you can use Postman. Download and install Postman from Postman's official website.

Once you have these prerequisites in place, you'll be ready to proceed with setting up and developing our microservices-based chat server. Let's move on to the next steps!

Setting Up the Microservices

Before we start coding, let's understand the project requirements and goals. We aim to build a real-time chat server using a microservices architecture. The key requirements include:

  • Real-Time Communication: The chat server should facilitate instant messaging between users, ensuring a seamless real-time experience.
  • Scalability: The architecture should support easy scaling of individual components to accommodate a growing user base without affecting the entire system.
  • Modularity: We want our system to be modular, allowing us to update, deploy, and scale each microservice independently.
Chat server structure

To achieve our goals, we'll leverage the following technologies and tools:

Node.js for Backend Services

Node.js is a powerful and efficient JavaScript runtime known for its non-blocking, event-driven architecture. It's an excellent choice for building scalable and responsive backend services.

TypeScript for Enhanced Development

We'll be using TypeScript to enhance the development process by introducing static typing, making our code more robust and maintainable.

Socket.io for Real-Time Communication

Socket.io is a library that enables real-time, bidirectional communication between clients and servers. It's a crucial component for building the real-time chat features of our microservices.

Docker for Containerization

Docker simplifies the deployment process by encapsulating each microservice into a container. Containers ensure consistency across different environments, making it easy to deploy and scale our services.

MongoDB for Data Storage

MongoDB, a NoSQL database, provides a flexible and scalable solution for storing our chat data. Its document-oriented structure aligns well with the dynamic nature of chat messages.

Nginx as a Reverse Proxy

Nginx acts as a reverse proxy to handle incoming requests and distribute them to the appropriate microservices. This enhances security and load balancing and simplifies the overall architecture.

By combining these technologies, we create a robust foundation for our microservices-based chat server. In the next sections, we'll delve into the implementation details. Let's get started!

Parent Folder Structure

Before we proceed, let's ensure our project structure is organized. Create a housing folder named chat-server that will contain all our microservices. Inside this folder, add the .gitignore file to exclude unnecessary files from version control. Additionally, include a docker-compose.yml file for managing the Docker configurations of our microservices.

Navigate to your preferred directory and use the following commands to achieve this:

mkdir chat-server && cd chat-server && touch .gitignore && touch docker-compose.yml

Now, open the .gitignore file and add the following entries:

**/node_modules
**/.env
**/build

These entries ensure that the node_modules, .env, and build folders are excluded from being pushed to GitHub. This organized structure lays the foundation for a streamlined development process as we progress with building our microservices. Now, let’s proceed with our User Service.

User Service

In this section, we will dive into the development of the User Service, a fundamental component of our microservices architecture. The User Service is responsible for handling user registration, authentication, and the storage of user-related data in MongoDB.

Designing the User Service

We will start by designing the structure of our User Service, creating essential folders and files that will form the backbone of our microservice. This includes configuration settings, controllers for user authentication, a MongoDB user model, Express routes, utility functions for JWT and password handling, and the main server setup.

Here’s our User service’s folder structure:

User service structure

Now, use the following commands to set up your User Service project structure, ensure you’re in your project’s root directory:

Create and navigate to your user-service folder:

mkdir user-service && cd user-service

Next, create the src folder and navigate to it:

mkdir -p src/config src/controllers src/services src/database/models src/middleware src/routes src/utils
cd src

Create individual files:

touch config/config.ts controllers/AuthController.ts services/RabbitMQService.ts database/models/UserModel.ts database/connection.ts database/index.ts middleware/index.ts routes/authRoutes.ts utils/index.ts server.ts

Create Dockerfile, .dockerignore, and .env files:

cd ..
touch Dockerfile .dockerignore .env

Here is the description for each file and folder's role:

user-service folder:

Description: The main folder for the User Service, houses all related files and folders.

src folder:

- Description: Contains the source code for the User Service, organized into subfolders for different functionalities.

config folder:

- Description: Stores configuration settings for the User Service.

controllers folder:

- Description: Holds the AuthController file and handles registration and login logic.

services folder:

- Description: Holds our RabbitMQService file and is responsible for handling RabbitMQ interactions.

database folder:

models folder:

- Description: Contains the UserModel file, defining the Mongoose user model.

connection.ts file:

- Description: Manages the connection to the MongoDB database.

index.ts file:

- Description: Acts as the entry point for the database-related functionality.

middleware folder:

index.ts file:

- Description: Serves as the entry point for middleware-related functionality.

routes folder:

authRoutes.ts file:

- Description: Defines Express routes for user authentication.

utils folder:

index.ts file:

- Description: Contains utility functions, such as custom error handler and password-related functions.

server.ts file:

- Description: Serves as the entry point for the Express app setup.

Dockerfile:

- Description: Specifies the Docker instructions for containerizing the User Service.

.dockerignore file:

- Description: Lists files and directories to be excluded from the Docker build context.

.env file:

- Description: Stores environment variables for the User Service.

Next, let’s set up our environment and install the necessary dependencies for the User Service. Use the following command, ensuring you’re in the user-service root directory:

Initialize Node.js project

npm init -y

Install dependencies

npm install amqplib bcryptjs dotenv express jsonwebtoken mongoose nodemon ts-node typescript validator @types/express @types/validator @types/amqplib @types/bcryptjs @types/jsonwebtoken

The above command sequence will create a package.json file and install the required Node.js packages for the User Service. Now, let's proceed with setting up TypeScript:

Install TypeScript globally if you don't have it already

npm install -g typescript

Initialize TypeScript configuration

tsc --init

Open the generated tsconfig.json file and update it with the following configuration:

{
 "compilerOptions": {
   "target": "es6",
   "module": "commonjs",
   "outDir": "./build",
   "strict": true,
   "esModuleInterop": true,
   "skipLibCheck": true
 },
 "include": [
   "src/**/*"
 ],
 "exclude": [
   "node_modules",
   "tests"
 ]
}

Now, let's set up the .env file by adding the following content:

NODE_ENV="development"
PORT=8081
JWT_SECRET="{{YOUR_SECRET_KEY}}"
MONGO_URI="{{YOUR_MONGODB_URI}}"
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"

Replace YOUR_SECRET_KEY with a strong secret key, YOUR_MONGODB_URI with your MongoDB URI, and YOUR_MESSAGE_BROKER_URL with your RabbitMQ message broker URI. You can obtain it from their official website, by following these steps:

  1. Visit CloudAMQP’s website, log in or register an account.
  2. Create a new instance with a chosen name, select a region, and create the instance.
  3. Copy the message broker URL from your instance details.
RabbitMQ website

Finally, let’s set up our config.ts file—located in the src/config folder. Copy and paste the following code:

import { config } from "dotenv";

const configFile = `./.env`;
config({ path: configFile });

const { MONGO_URI, PORT, JWT_SECRET, NODE_ENV, MESSAGE_BROKER_URL } =
   process.env;

export default {
   MONGO_URI,
   PORT,
   JWT_SECRET,
   env: NODE_ENV,
   msgBrokerURL: MESSAGE_BROKER_URL,
};

With this setup, we can start writing our User Service code in TypeScript. Let's get down to business!

User Model

To set up our User model, open your UserModel.ts file, located in the src/database/models folder, then copy and paste the following code:

import mongoose, { Schema, Document } from "mongoose";
import validator from "validator";

export interface IUser extends Document {
   name: string;
   email: string;
   password: string;
   createdAt: Date;
   updatedAt: Date;
}

const UserSchema: Schema = new Schema(
   {
       name: {
           type: String,
           trim: true,
           required: [true, "Name must be provided"],
           minlength: 3,
       },
       email: {
           type: String,
           required: true,
           unique: true,
           lowercase: true,
           trim: true,
           validate: [validator.isEmail, "Please provide a valid email."],
       },
       password: {
           type: String,
           trim: false,
           required: [true, "Password must be provided"],
           minlength: 8,
       },
   },
   {
       timestamps: true,
   }
);

const User = mongoose.model<IUser>("User", UserSchema);
export default User;

Here’s a breakdown of what’s going on in the above file:

  • IUser interface outlines the structure of the User document, including fields like name, email, password, createdAt, and updatedAt.
  • UserSchema utilizes Mongoose's Schema class, specifying types and constraints for each User document field.
  • name field requires a minimum length of 3 characters and must be trimmed.
  • email field is mandatory, unique, and validated as a valid email using validator.isEmail.
  • password field is mandatory with a minimum length of 8 characters.
  • timestamps: true in the schema adds automatic createdAt and updatedAt fields to track creation and update times.
  • User model is created using Mongoose's model function, associating it with IUser interface and UserSchema.
  • The final step exports the User model for use in other parts of the application.

Our User Model defines the structure and validation rules for user documents in MongoDB, providing a foundation for handling user data in the User Service.

Database Connection

Open the connection.ts located in the src/database folder and add the following code:

import mongoose from "mongoose";
import config from "../config/config";

export const connectDB = async () => {
   try {
       console.info("Connecting to database..." + config.MONGO_URI);
       await mongoose.connect(config.MONGO_URI!);
       console.info("Database connected");
   } catch (error) {
       console.error(error);
       process.exit(1);
   }
};

In the above file, we defined an asynchronous connectDB function that establishes a connection to the MongoDB database. It uses the mongoose.connect method to connect to the database using the MongoDB URI specified in the config file.

Next, open the index.ts file located in the src/database folder and export everything by adding the following code:

import User, { IUser } from "./models/UserModel";
import { connectDB } from "./connection";

export { User, IUser, connectDB };

AuthController

With our database set up, the next focus is on defining authentication methods within the AuthController.ts file, located in the src/controllers folder. Copy and paste the following code:

import express, { Request, Response } from "express";
import jwt from "jsonwebtoken";
import { User } from "../database";
import { ApiError, encryptPassword, isPasswordMatch } from "../utils";
import config from "../config/config";
import { IUser } from "../database";

const jwtSecret = config.JWT_SECRET as string;
const COOKIE_EXPIRATION_DAYS = 90; // cookie expiration in days
const expirationDate = new Date(
   Date.now() + COOKIE_EXPIRATION_DAYS * 24 * 60 * 60 * 1000
);
const cookieOptions = {
   expires: expirationDate,
   secure: false,
   httpOnly: true,
};

const register = async (req: Request, res: Response) => {
   try {
       const { name, email, password } = req.body;
       const userExists = await User.findOne({ email });
       if (userExists) {
           throw new ApiError(400, "User already exists!");
       }

       const user = await User.create({
           name,
           email,
           password: await encryptPassword(password),
       });

       const userData = {
           id: user._id,
           name: user.name,
           email: user.email,
       };

       return res.json({
           status: 200,
           message: "User registered successfully!",
           data: userData,
       });
   } catch (error: any) {
       return res.json({
           status: 500,
           message: error.message,
       });
   }
};

const createSendToken = async (user: IUser, res: Response) => {
   const { name, email, id } = user;
   const token = jwt.sign({ name, email, id }, jwtSecret, {
       expiresIn: "1d",
   });
   if (config.env === "production") cookieOptions.secure = true;
   res.cookie("jwt", token, cookieOptions);

   return token;
};

const login = async (req: Request, res: Response) => {
   try {
       const { email, password } = req.body;
       const user = await User.findOne({ email }).select("+password");
       if (
           !user ||
           !(await isPasswordMatch(password, user.password as string))
       ) {
           throw new ApiError(400, "Incorrect email or password");
       }

       const token = await createSendToken(user!, res);

       return res.json({
           status: 200,
           message: "User logged in successfully!",
           token,
       });
   } catch (error: any) {
       return res.json({
           status: 500,
           message: error.message,
       });
   }
};

export default {
   register,
   login,
};

The above file contains functions for user registration and login. The register function checks if the user already exists, creates a new user, and responds with success or error messages. The login function verifies user credentials, creates a JWT token, and sends it as a cookie upon successful login. The code ensures a secure and efficient authentication process, with a clear separation of responsibilities.

Routes

Now, let’s define the routes that utilize the controller methods. In the authRoutes.ts file located in the src/routes folder, add the following code:

import { Router } from "express";
import AuthController from "../controllers/AuthController";

const userRouter = Router();

userRouter.post("/register", AuthController.register);
userRouter.post("/login", AuthController.login);

export default userRouter;

This file sets up routes for user registration (/register) and user login (/login). These routes are associated with the corresponding methods defined in the AuthController. The Router from Express is used to create and manage these routes. With this setup, our Express app can now handle incoming HTTP requests to these routes and invoke the appropriate controller methods for user registration and login.

Utils

With our AuthController and routes all set, let’s work on our utils. Open the index.ts file located in the src/utils directory and add the following code:

import bcrypt from "bcryptjs";

class ApiError extends Error {
   statusCode: number;
   isOperational: boolean;

   constructor(
       statusCode: number,
       message: string | undefined,
       isOperational = true,
       stack = ""
   ) {
       super(message);
       this.statusCode = statusCode;
       this.isOperational = isOperational;
       if (stack) {
           this.stack = stack;
       } else {
           Error.captureStackTrace(this, this.constructor);
       }
   }
}

const encryptPassword = async (password: string) => {
   const encryptedPassword = await bcrypt.hash(password, 12);
   return encryptedPassword;
};

const isPasswordMatch = async (password: string, userPassword: string) => {
   const result = await bcrypt.compare(password, userPassword);
   return result;
};

export { ApiError, encryptPassword, isPasswordMatch };

In this utility module, we've defined three key elements:

  1. ApiError Class: This custom error class is designed to handle API-related errors. It takes parameters such as the status code, message, operational status, and stack trace.
  2. encryptPassword Function: This asynchronous function uses bcrypt to hash passwords. It takes a plain text password as input and returns the hashed password.
  3. isPasswordMatch Function: Another asynchronous function that checks if a provided password matches the stored hash. It takes a plain text password and the stored hashed password as input, returning a boolean result.

These utility functions play a crucial role in enhancing the security and error-handling capabilities of our User Service. With these utilities, our application gains robust password encryption and consistent error management.

Middleware

In the index.ts file located in the src/middleware folder, add the following code:

import { ErrorRequestHandler } from "express";
import { ApiError } from "../utils";

export const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
   let error = err;
   if (!(error instanceof ApiError)) {
       const statusCode =
           error.statusCode ||
           (error instanceof Error
               ? 400 // Bad Request
               : 500); // Internal Server Error
       const message =
           error.message ||
           (statusCode === 400 ? "Bad Request" : "Internal Server Error");
       error = new ApiError(statusCode, message, false, err.stack.toString());
   }
   next(error);
};

export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
   let { statusCode, message } = err;
   if (process.env.NODE_ENV === "production" && !err.isOperational) {
       statusCode = 500; // Internal Server Error
       message = "Internal Server Error";
   }

   res.locals.errorMessage = err.message;

   const response = {
       code: statusCode,
       message,
       ...(process.env.NODE_ENV === "development" && { stack: err.stack }),
   };

   if (process.env.NODE_ENV === "development") {
       console.error(err);
   }

   res.status(statusCode).json(response);
   next();
};

In these middleware functions:

  1. errorConverter: converts non-API errors to API errors, ensuring consistency in error handling. It checks if the error is an instance of ApiError and, if not, creates a new ApiError instance with relevant details.
  2. errorHandler: handles API errors. It sets the appropriate status code and message for non-operational errors in production. The response payload includes the error code, message, and, in development mode, the stack trace. The error is logged in the console in development mode. This middleware ensures a standardized and informative response for API errors.

RabbitMQ Services

Now, let’s set up our RabbitMQ services. Open your RabbitMQService.ts file located in the src/services folder and add the following code:

import amqp, { Channel, Connection } from "amqplib";
import config from "../config/config";
import { User } from "../database";
import { ApiError } from "../utils";

class RabbitMQService {
   private requestQueue = "USER_DETAILS_REQUEST";
   private responseQueue = "USER_DETAILS_RESPONSE";
   private connection!: Connection;
   private channel!: Channel;

   constructor() {
       this.init();
   }

   async init() {
       // Establish connection to RabbitMQ server
       this.connection = await amqp.connect(config.msgBrokerURL!);
       this.channel = await this.connection.createChannel();

       // Asserting queues ensures they exist
       await this.channel.assertQueue(this.requestQueue);
       await this.channel.assertQueue(this.responseQueue);

       // Start listening for messages on the request queue
       this.listenForRequests();
   }

   private async listenForRequests() {
       this.channel.consume(this.requestQueue, async (msg) => {
           if (msg && msg.content) {
               const { userId } = JSON.parse(msg.content.toString());
               const userDetails = await getUserDetails(userId);

               // Send the user details response
               this.channel.sendToQueue(
                   this.responseQueue,
                   Buffer.from(JSON.stringify(userDetails)),
                   { correlationId: msg.properties.correlationId }
               );

               // Acknowledge the processed message
               this.channel.ack(msg);
           }
       });
   }
}

const getUserDetails = async (userId: string) => {
   const userDetails = await User.findById(userId).select("-password");
   if (!userDetails) {
       throw new ApiError(404, "User not found");
   }

   return userDetails;
};
export const rabbitMQService = new RabbitMQService();

In our RabbitMQ service:

  • The RabbitMQService class initializes the RabbitMQ connection and channels. It establishes a connection to the RabbitMQ server, creates channels, and asserts the existence of the request and response queues.
  • The listenForRequests method listens for incoming messages on the request queue, processes the user details request, sends a response to the response queue, and acknowledges the processed message.
  • The getUserDetails function fetches user details from MongoDB based on the provided user ID.
  • Finally, an instance of RabbitMQService is created to initialize the RabbitMQ connection when the file is imported.

How RabbitMQ Works

Imagine RabbitMQ as a post office for your applications. It helps different parts of your software communicate by passing messages between them.

  1. The Post Office (Message Broker): RabbitMQ is like the central post office. It sits in the middle, ready to receive, store, and deliver messages. In our code, the RabbitMQService class represents this central post office. It initializes the connection to RabbitMQ and sets up the necessary queues.
  2. The Sender (Producer): The sender is like the part of your application that wants to send a message. In our code, the sender is not explicitly shown but is represented by the concept of producing messages. Messages are sent to the RabbitMQ exchange.
  3. The Message (Letter): Messages are the information we want to share. In the code, messages are JSON objects containing a userId field. These messages are sent to the RabbitMQ request queue.
  4. The Exchange (Mailbox): Before sending the letter, you put it in a mailbox. In RabbitMQ, this mailbox is called an exchange. There can be different types of mailboxes for different purposes. In our code, the requestQueue serves as the mailbox (exchange) where messages are sent for processing.
  5. Routing (Delivery Instructions): Sometimes, you want your letter to go to a specific department or person. In RabbitMQ, routing specifies where a message should go. In our code, routing is not explicitly shown, but the requestQueue serves the purpose of directing messages to the proper destination.
  6. The Receiver (Consumer): On the other end, someone is waiting to receive the letter. The receiver is like the part of your application waiting to receive and process the message. In our code, the listenForRequests function acts as the consumer, waiting for messages in the requestQueue.
  7. Queues (Mail Sorting): Before reaching the receiver, letters are sorted in queues. Each type of letter (message) might have its queue. Here, messages are placed in the requestQueue before being processed.
  8. Delivery to Consumers: Once sorted, the letters are delivered to the respective receivers (consumers) who can then process the information. The listenForRequests function processes messages from the requestQueue and delivers the user details response to the responseQueue.
  9. Acknowledgment (Received Confirmation): In RabbitMQ, acknowledgments confirm that a message has been successfully processed. Our code uses this.channel.ack(msg) to acknowledge that the processed message has been received.

In summary, the RabbitMQService.ts code simulates a scenario where messages (user details requests) are sent to a central post office (RabbitMQ), sorted in a mailbox (queue), and then processed by a receiver (consumer). The acknowledgment mechanism ensures that the processing status is communicated back to RabbitMQ. This setup enables efficient communication between different parts of your application using RabbitMQ.

Server Setup and Initialization

With our RabbitMQService.ts in place, the final piece of the puzzle is the server.ts file, which orchestrates the setup of our Express server, connects to the database and initializes the RabbitMQ client. Here's the code:

import express, { Express } from "express";
import { Server } from "http";
import userRouter from "./routes/authRoutes";
import { errorConverter, errorHandler } from "./middleware";
import { connectDB } from "./database";
import config from "./config/config";
import { rabbitMQService } from "./services/RabbitMQService";

const app: Express = express();
let server: Server;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(userRouter);
app.use(errorConverter);
app.use(errorHandler);

connectDB();

server = app.listen(config.PORT, () => {
   console.log(`Server is running on port ${config.PORT}`);
});

const initializeRabbitMQClient = async () => {
   try {
       await rabbitMQService.init();
       console.log("RabbitMQ client initialized and listening for messages.");
   } catch (err) {
       console.error("Failed to initialize RabbitMQ client:", err);
   }
};

initializeRabbitMQClient();

const exitHandler = () => {
   if (server) {
       server.close(() => {
           console.info("Server closed");
           process.exit(1);
       });
   } else {
       process.exit(1);
   }
};

const unexpectedErrorHandler = (error: unknown) => {
   console.error(error);
   exitHandler();
};

process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);

This server.ts file does the following:

  1. Express Server Setup: Initializes an Express app, sets up middleware for JSON and form data parsing, adds routes, and includes error handling middleware.
  2. Database Connection: Connects to the MongoDB database using the connectDB function.
  3. Server Start: Starts the Express server, listening on the specified port from the config file.
  4. RabbitMQ Client Initialization: Calls the initializeRabbitMQClient function to initialize the RabbitMQ client using our RabbitMQService.
  5. Graceful Shutdown: Implements a graceful shutdown mechanism. When the server is closed (either intentionally or due to an error), it logs the closure and exits the process.
  6. Error Handling: Registers event listeners for uncaught exceptions and unhandled rejections, ensuring proper error handling and graceful shutdown in case of unexpected errors.

With this file, our microservice architecture for the User Service is complete and ready to handle user registration, login, and RabbitMQ communication.

Script Update

After setting up our server, it's important to update the package.json file with scripts that facilitate development, building, and starting the server. Open your package.json file and add the following code:

"scripts": {
   "dev": "NODE_ENV=development nodemon src/server.ts",
   "build": "rm -rf build/ && tsc -p .",
   "start": "NODE_ENV=production nodemon build/server.js"
},

Then, ensure that the "main" tag is set to "src/server.ts". These scripts provide convenient commands for different stages of the development process:

  • "dev": Starts the server in development mode using nodemon.
  • "build": Clears the existing build folder and compiles TypeScript files using the TypeScript compiler (tsc).
  • "start": Starts the server in production mode using the compiled JavaScript files.

This setup enhances the development workflow and allows for easy deployment in production.

Now, let’s see our User service in action. Open your terminal and make sure you’re in the user-service root directory. Run the following command to start the development server based on your package manager:

npm run dev
or
yarn dev

This command initiates the server in development mode, enabling live reloading with nodemon. Once the server is running, you can use Postman or any API tool to test the API endpoints and ensure that everything is functioning as expected. Test the /register and /login endpoints to verify user registration and login functionality.

Chat Service

The Chat Service enables real-time communication between users, fostering a dynamic and interactive user experience within our application. We'll structure the project, set up dependencies, and create the necessary files and folders to establish a robust foundation for building the Chat Service. Follow the step-by-step guide to integrate this service into our microservices ecosystem seamlessly. Let's get started!

Here’s our Chat service’s structure:

Chat service structure

Now, use the following commands to set up your Chat Service project structure, ensure you’re in your project’s root directory:

Create and navigate to your chat-service folder:

mkdir chat-service && cd chat-service

Create the src folder and navigate to it:

mkdir -p src/config src/controllers src/services src/database/models src/middleware src/routes src/utils
cd src

Create individual files:

touch config/config.ts controllers/MessageController.ts services/RabbitMQService.ts database/models/MessageModel.ts database/connection.ts database/index.ts middleware/index.ts routes/messageRoutes.ts utils/apiError.ts utils/messageHandler.ts utils/userStatusStore.ts utils/index.ts app.ts server.ts

Create Dockerfile, .dockerignore, and .env files:

cd ..
touch Dockerfile .dockerignore .env

Here is the description for each file and folder:

  • config: contains the configuration files for our Chat Service. The config.ts file holds environment variables and configurations.
  • controllers: Here, we manage the business logic for handling messages in the MessageController.ts file. This is where operations on messages are defined.
  • services: The RabbitMQService.ts file in this folder handles interactions with the RabbitMQ message broker, facilitating communication between microservices.
  • database/models: In this folder, we define the data model for messages using MessageModel.ts.
  • database: The connection.ts and index.ts files handle database connections and export models.
  • middleware: The index.ts file in this folder consolidates the middleware functions we will use later on.
  • routes: The messageRoutes.ts file in this folder defines the routes and their corresponding handlers for message-related operations.
  • utils: This folder contains utility files such as apiError.ts for handling API errors, messageHandler.ts for managing incoming messages, and userStatusStore.ts for tracking user statuses. The index.ts file consolidates exports from the utils folder.
  • app.ts: This file serves as the entry point for our Chat Service application.
  • server.ts: Here, we set up the Express server, configure middleware, and establish routes.

These files and folders collectively form the foundational structure for our Chat Service, allowing for the organized development of features and functionalities.

Next, let’s set up our environment and install the necessary dependencies. Use the following command, ensuring you’re in the chat-service root directory:

Initialize Node.js project:

npm init -y

Install dependencies

npm install amqplib dotenv express jsonwebtoken mongoose nodemon ts-node typescript socket.io uuid @types/express @types/amqplib @types/jsonwebtoken @types/uuid

This command sequence will create a package.json file and install the required Node.js packages for the Chat Service.

Let's proceed with the TypeScript configuration. Run the following command:

tsc --init
Open the generated tsconfig.json file and update it with the following configuration:
{
 "compilerOptions": {
   "target": "es6",
   "module": "commonjs",
   "outDir": "./build",
   "strict": true,
   "esModuleInterop": true,
   "skipLibCheck": true
 },
 "include": [
   "src/**/*"
 ],
 "exclude": [
   "node_modules",
   "tests"
 ]
}

Add the following code to your .env file:

NODE_ENV="development"
PORT=8082
JWT_SECRET="{{YOUR_SECRET_KEY}}"
MONGO_URI="{{YOUR_MONGODB_URI}}"
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"

Replace YOUR_SECRET_KEY with a strong secret key, YOUR_MONGODB_URI with your MongoDB URI, and YOUR_MESSAGE_BROKER_URL with the same RabbitMQ message broker URL you used in the User Service.

Now, let’s set up our config.ts file located in the src/config folder. Open the file and add the following code:

import { config } from "dotenv";

const configFile = `./.env`;
config({ path: configFile });

const { MONGO_URI, PORT, JWT_SECRET, NODE_ENV, MESSAGE_BROKER_URL } =
   process.env;

const queue = { notifications: "NOTIFICATIONS" };

export default {
   MONGO_URI,
   PORT,
   JWT_SECRET,
   env: NODE_ENV,
   msgBrokerURL: MESSAGE_BROKER_URL,
   queue,
};

In this config.ts file, we're configuring the environment variables needed for the Chat Service. We start by loading these variables from a specified file using the dotenv library. Once loaded, we extract key information such as the MongoDB URI, port, JWT secret, environment type, and the message broker URL. Additionally, we define a queue object, specifically a notifications queue, which will be utilized in our message broker. This is similar to what we did in the User service.

This centralized configuration approach allows us to easily manage and modify settings as required. It ensures that sensitive information, like the MongoDB URI or JWT secret, is kept secure by loading it from environment variables. The queue object represents specific queues used for communication within the message broker, adding clarity to our messaging setup.

Message Model

Everything is set, let’s set up our Message model. Open the MessageModel.ts file located in the src/database/models folder and add the following code:

import mongoose, { Schema, Document } from "mongoose";

enum Status {
   NotDelivered = "NotDelivered",
   Delivered = "Delivered",
   Seen = "Seen",
}

export interface IMessage extends Document {
   senderId: string;
   receiverId: string;
   message: string;
   status: Status;
   createdAt: Date;
   updatedAt: Date;
}

const MessageSchema: Schema = new Schema(
   {
       senderId: {
           type: String,
           required: true,
       },
       receiverId: {
           type: String,
           required: true,
       },
       message: {
           type: String,
           required: true,
       },
       status: {
           type: String,
           enum: Object.values(Status),
           default: Status.NotDelivered,
       },
   },
   {
       timestamps: true,
   }
);

const Message = mongoose.model<IMessage>("Message", MessageSchema);
export default Message;

This MessageModel.ts file defines the schema for our messages. Each message has a sender ID, receiver ID, message content, status (which can be "NotDelivered," "Delivered," or "Seen"), and timestamps for creation and last update. The use of TypeScript interfaces ensures that our document adheres to a specific structure. The enum for status provides clear options and a default value, enhancing code readability. This schema will be used to interact with the MongoDB database for storing and retrieving messages.

Database Connection

Open the connection.ts in the src/database folder and add the following code just like we did in User service:

import mongoose from "mongoose";
import config from "../config/config";

export const connectDB = async () => {
   try {
       console.info("Connecting to database..." + config.MONGO_URI);
       await mongoose.connect(config.MONGO_URI!);
       console.info("Database connected");
   } catch (error) {
       console.error(error);
       process.exit(1);
   }
};

Then, export by adding the following code to the index.ts file located in the src/database folder:

import Message from "./models/MessageModel";
import { connectDB } from "./connection";

export { Message, connectDB };

Message Controller

Now that we have the database all set. Let’s work on the Message controller, open the MessageController.ts file and add the following code:

import { Request, Response } from "express";
import { AuthRequest } from "../middleware";
import { Message } from "../database";
import { ApiError, handleMessageReceived } from "../utils";

const send = async (req: AuthRequest, res: Response) => {
   try {
       const { receiverId, message } = req.body;
       const { _id, email, name } = req.user;

       validateReceiver(_id, receiverId);

       const newMessage = await Message.create({
           senderId: _id,
           receiverId,
           message,
       });

       await handleMessageReceived(name, email, receiverId, message);

       return res.json({
           status: 200,
           message: "Message sent successfully!",
           data: newMessage,
       });
   } catch (error: any) {
       return res.json({
           status: 500,
           message: error.message,
       });
   }
};

const validateReceiver = (senderId: string, receiverId: string) => {
   if (!receiverId) {
       throw new ApiError(404, "Receiver ID is required.");
   }

   if (senderId == receiverId) {
       throw new ApiError(400, "Sender and receiver cannot be the same.");
   }
};

const getConversation = async (req: AuthRequest, res: Response) => {
   try {
       const { receiverId } = req.params;
       const senderId = req.user._id;

       const messages = await Message.find({
           $or: [
               { senderId, receiverId },
               { senderId: receiverId, receiverId: senderId },
           ],
       });

       return res.json({
           status: 200,
           message: "Messages retrieved successfully!",
           data: messages,
       });
   } catch (error: any) {
       return res.json({
           status: 500,
           message: error.message,
       });
   }
};

export default {
   send,
   getConversation,
};

This MessageController.ts file defines methods for handling messages. The send method allows users to send messages to a specified receiver, and it performs validation to ensure the receiver ID is provided and not the same as the sender's ID. Additionally, there’s a handleMessageReceived function that handles and notifies the receiver when he/she receives a new message using our RabbitMQService which we’ll define later.

The getConversation method retrieves messages between two users based on their IDs. Error handling is included to manage potential issues during these operations.

Message Routes

Add the following code to the messageRoutes.ts file located in the src/routes folder:

import { Router } from "express";
import MessageController from "../controllers/MessageController";
import { authMiddleware } from "../middleware";

const messageRoutes = Router();

// @ts-ignore
messageRoutes.post("/send", authMiddleware, MessageController.send);
messageRoutes.get(
   "/get/:receiverId",
   // @ts-ignore
   authMiddleware,
   MessageController.getConversation
);

export default messageRoutes;

This messageRoutes.ts file sets up routes for sending messages (/send) and retrieving conversations (/get/:receiverId). These routes are associated with the corresponding methods defined in the MessageController. The authMiddleware ensures that only authenticated users can access these routes. With these routes, users can send messages and retrieve their conversations.

RabbitMQ Services

In the src/services folder, open the RabbitMQService.ts file and add the following code:

import amqp, { Channel } from "amqplib";
import { v4 as uuidv4 } from "uuid";
import config from "../config/config";

class RabbitMQService {
   private requestQueue = "USER_DETAILS_REQUEST";
   private responseQueue = "USER_DETAILS_RESPONSE";
   private correlationMap = new Map();
   private channel!: Channel;

   constructor() {
       this.init();
   }

   async init() {
       const connection = await amqp.connect(config.msgBrokerURL!);
       this.channel = await connection.createChannel();
       await this.channel.assertQueue(this.requestQueue);
       await this.channel.assertQueue(this.responseQueue);

       this.channel.consume(
           this.responseQueue,
           (msg) => {
               if (msg) {
                   const correlationId = msg.properties.correlationId;
                   const user = JSON.parse(msg.content.toString());

                   const callback = this.correlationMap.get(correlationId);
                   if (callback) {
                       callback(user);
                       this.correlationMap.delete(correlationId);
                   }
               }
           },
           { noAck: true }
       );
   }

   async requestUserDetails(userId: string, callback: Function) {
       const correlationId = uuidv4();
       this.correlationMap.set(correlationId, callback);
       this.channel.sendToQueue(
           this.requestQueue,
           Buffer.from(JSON.stringify({ userId })),
           { correlationId }
       );
   }

   async notifyReceiver(
       receiverId: string,
       messageContent: string,
       senderEmail: string,
       senderName: string
   ) {
       await this.requestUserDetails(receiverId, async (user: any) => {
           const notificationPayload = {
               type: "MESSAGE_RECEIVED",
               userId: receiverId,
               userEmail: user.email,
               message: messageContent,
               from: senderEmail,
               fromName: senderName,
           };

           try {
               await this.channel.assertQueue(config.queue.notifications);
               this.channel.sendToQueue(
                   config.queue.notifications,
                   Buffer.from(JSON.stringify(notificationPayload))
               );
           } catch (error) {
               console.error(error);
           }
       });
   }
}

export const rabbitMQService = new RabbitMQService();

This RabbitMQService.ts file sets up a RabbitMQ service for handling communication between different microservices. It uses the amqplib library to interact with RabbitMQ. The service initializes by connecting to the RabbitMQ server and asserting the necessary queues. The methods requestUserDetails and notifyReceiver demonstrate the use of RabbitMQ for requesting user details and notifying receivers about incoming messages, respectively. The service utilizes correlation IDs to match requests with responses, ensuring effective communication between services.

Utils

With our services and controller all set, let’s work on our utils. In the src/utils, add the following code to the apiError.ts file:

class ApiError extends Error {
   statusCode: number;
   isOperational: boolean;

   constructor(
       statusCode: number,
       message: string | undefined,
       isOperational = true,
       stack = ""
   ) {
       super(message);
       this.statusCode = statusCode;
       this.isOperational = isOperational;
       if (stack) {
           this.stack = stack;
       } else {
           Error.captureStackTrace(this, this.constructor);
       }
   }
}

export { ApiError };

This ApiError class is similar to the one we have in our User service. They perform the same function.

Next, in the userStatusStore.ts file, the following code defines the UserStatusStore class:

export class UserStatusStore {
   private static instance: UserStatusStore;
   private userStatuses: Record<string, boolean>;

   private constructor() {
       this.userStatuses = {};
   }

   public static getInstance(): UserStatusStore {
       if (!UserStatusStore.instance) {
           UserStatusStore.instance = new UserStatusStore();
       }
       return UserStatusStore.instance;
   }

   setUserOnline(userId: string) {
       this.userStatuses[userId] = true;
   }

   setUserOffline(userId: string) {
       this.userStatuses[userId] = false;
   }

   isUserOnline(userId: string): boolean {
       return !!this.userStatuses[userId];
   }
}

The UserStatusStore class is a singleton class responsible for managing the online/offline status of users. It provides methods to set a user as online or offline and check whether a user is currently online.

Lastly, in the messageHandler.ts file, the following code:

import { UserStatusStore } from "./userStatusStore";
import { rabbitMQService } from "../services/RabbitMQService";

const userStatusStore = UserStatusStore.getInstance();

export const handleMessageReceived = async (
   senderName: string,
   senderEmail: string,
   receiverId: string,
   messageContent: string
) => {
   const receiverIsOffline = !userStatusStore.isUserOnline(receiverId);

   if (receiverIsOffline) {
       await rabbitMQService.notifyReceiver(
           receiverId,
           messageContent,
           senderEmail,
           senderName
       );
   }
};

The handleMessageReceived function checks whether the receiver is online using the UserStatusStore. If the receiver is offline, it utilizes the RabbitMQService to notify the receiver about the incoming message. This ensures that users receive notifications even when they are not actively using the chat service.

Now, export the utils by adding the code below to the index.ts file:

import { ApiError } from "./apiError";
import { UserStatusStore } from "./userStatusStore";
import { handleMessageReceived } from "./messageHandler";

export { ApiError, UserStatusStore, handleMessageReceived };

This code exports the ApiError, UserStatusStore, and handleMessageReceived from the utils, making them available for use in other parts of the application. With our utils in place, let’s define our middleware.

Middleware

Open the index.ts file located in the src/middleware folder and add the following code:

import { Request, Response, NextFunction, ErrorRequestHandler } from "express";
import jwt from "jsonwebtoken";
import { ApiError } from "../utils";
import config from "../config/config";

interface TokenPayload {
   id: string;
   name: string;
   email: string;
   iat: number;
   exp: number;
}

interface IUser {
   _id: string;
   name: string;
   email: string;
   password: string;
   createdAt: Date;
   updatedAt: Date;
}

export interface AuthRequest extends Request {
   user: IUser;
}

const jwtSecret = config.JWT_SECRET as string;

const authMiddleware = async (
   req: AuthRequest,
   res: Response,
   next: NextFunction
) => {
   const authHeader = req.headers.authorization;
   if (!authHeader) {
       return next(new ApiError(401, "Missing authorization header"));
   }

   const [, token] = authHeader.split(" ");
   try {
       const decoded = jwt.verify(token, jwtSecret) as TokenPayload;

       req.user = {
           _id: decoded.id,
           email: decoded.email,
           createdAt: new Date(decoded.iat * 1000),
           updatedAt: new Date(decoded.exp * 1000),
           name: decoded.name,
           password: "",
       };
       return next();
   } catch (error) {
       console.error(error);
       return next(new ApiError(401, "Invalid token"));
   }
};

const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
   let error = err;
   if (!(error instanceof ApiError)) {
       const statusCode =
           error.statusCode ||
           (error instanceof Error
               ? 400 // Bad Request
               : 500); // Internal Server Error
       const message =
           error.message ||
           (statusCode === 400 ? "Bad Request" : "Internal Server Error");
       error = new ApiError(statusCode, message, false, err.stack.toString());
   }
   next(error);
};

const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
   let { statusCode, message } = err;
   if (process.env.NODE_ENV === "production" && !err.isOperational) {
       statusCode = 500; // Internal Server Error
       message = "Internal Server Error";
   }

   res.locals.errorMessage = err.message;

   const response = {
       code: statusCode,
       message,
       ...(process.env.NODE_ENV === "development" && { stack: err.stack }),
   };

   if (process.env.NODE_ENV === "development") {
       console.error(err);
   }

   res.status(statusCode).json(response);
   next();
};

export { authMiddleware, errorConverter, errorHandler };

This code defines three middleware in the src/middleware folder: authMiddleware, errorConverter, and errorHandler. The authMiddleware handles user authentication, errorConverter converts errors to a standardized format, and errorHandler sends appropriate error responses. This middleware will be used in the Express application to handle authentication and errors.

Server Setup and Initialization

It’s time to set up our server. Firstly, open the app.ts file in the src folder and add the following code:

import express, { Express } from "express";
import userRouter from "./routes/messageRoutes";
import { errorConverter, errorHandler } from "./middleware";

const app: Express = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(userRouter);
app.use(errorConverter);
app.use(errorHandler);

export default app;

This code sets up an Express application with middleware to handle JSON and URL-encoded data. It also includes the messageRouter for routing related to messages, as well as error-handling middleware.

Then, add the following code to the server.ts file in the same folder:

import { Server } from "http";
import { Socket, Server as SocketIOServer } from "socket.io";
import app from "./app";
import { Message, connectDB } from "./database";
import config from "./config/config";

let server: Server;
connectDB();

server = app.listen(config.PORT, () => {
   console.log(`Server is running on port ${config.PORT}`);
});
const io = new SocketIOServer(server);
io.on("connection", (socket: Socket) => {
   console.log("Client connected");
   socket.on("disconnect", () => {
       console.log("Client disconnected", socket.id);
   });

   socket.on("sendMessage", (message) => {
       io.emit("receiveMessage", message);
   });

   socket.on("sendMessage", async (data) => {
       const { senderId, receiverId, message } = data;
       const msg = new Message({ senderId, receiverId, message });
       await msg.save();

       io.to(receiverId).emit("receiveMessage", msg); // Assuming receiverId is socket ID of the receiver
   });
});

const exitHandler = () => {
   if (server) {
       server.close(() => {
           console.info("Server closed");
           process.exit(1);
       });
   } else {
       process.exit(1);
   }
};

const unexpectedErrorHandler = (error: unknown) => {
   console.error(error);
   exitHandler();
};

process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);

This code initializes the server, establishes a Socket.IO connection, and sets up event listeners for handling client connections, disconnections, and message sending. Additionally, it includes error handling for server closure and unexpected errors.

Script Update

Don’t forget to update the "scripts" tag for our Chat service, open the package.json file, and update it with the following—just like our User service:

"scripts": {
   "dev": "NODE_ENV=development nodemon src/server.ts",
   "build": "rm -rf build/ && tsc -p .",
   "start": "NODE_ENV=production nodemon build/server.js"
},

Also, update the "main" tag by setting it to "src/server.ts". With these, our chat service is ready. Start the development server by running:

npm run dev

This will initiate the development server for your Chat service. Additionally, you can test the Chat service endpoints using Postman or any API tool of your choice.

Notification Service

Congratulations on completing the setup for our Chat Server microservice! With the Chat Service up and running smoothly, it's time to enhance the user experience by implementing a Notification Service.

In this section, we'll focus on creating a Notification Service that will handle real-time notifications for our chat application. Notifications play a crucial role in keeping users informed about new messages, ensuring timely communication, and providing a seamless chatting experience.

By the end of this section, we'll have a fully functional Notification Service integrated seamlessly with our existing Chat Server microservice, enhancing the overall user experience with real-time notifications. Let's dive in and get started!

Here’s our Notification service’s file and folder structure:

Notification structure

Now, use the following commands to set up your Notification Service project structure, ensure you’re in your project’s root directory:

Create and navigate to your notification-service folder:

mkdir notification-service && cd notification-service

Next, create the src folder and navigate to it:

mkdir -p src/config src/services src/middleware src/utils
cd src

Now, create individual files:

touch config/config.ts services/RabbitMQService.ts services/EmailService.ts services/FCMService.ts services/index.ts middleware/index.ts utils/apiError.ts utils/userStatusStore.ts utils/index.ts server.ts

Finally, create Dockerfile, .dockerignore, and .env files:

cd ..
touch Dockerfile .dockerignore .env

Here's a brief description of each file and folder created for the Notification Service:

config/config.ts:

- This file contains configurations related to the Notification Service, such as RabbitMQ connection details and other environment variables.

services/RabbitMQService.ts:

- This file implements the RabbitMQ service, which handles message queueing and communication with other microservices via RabbitMQ.

services/EmailService.ts:

- This file defines the Email service, responsible for sending email notifications to users based on certain events or triggers within the application.

services/FCMService.ts:

- This file contains the Firebase Cloud Messaging (FCM) service implementation, allowing the Notification Service to send push notifications to mobile devices.

services/index.ts:

- This file exports all service modules, facilitating easy import and access to service functionalities from other parts of the application.

middleware/index.ts:

- This file houses and exports middleware functions used within the Notification Service, such as authentication middleware or error handling middleware.

utils/apiError.ts:

- This file defines the ApiError class, which represents custom error objects used throughout the application to handle API-related errors.

utils/userStatusStore.ts:

- This file contains the UserStatusStore class, which manages the online/offline status of users, facilitating real-time communication and notification delivery.

utils/index.ts:

- This file exports utility functions and classes for easy access and use within the Notification Service.

server.ts:

This file initializes and starts the Express server for the Notification Service, defining routes, middleware, and server setup logic.

Next, let’s set up our environment and install the necessary dependencies. Use the following command, ensuring you’re in the notification-service root directory:

Initialize Node.js project:

npm init -y

Install dependencies

npm install amqplib dotenv express firebase-admin nodemon ts-node typescript nodemailer sib-api-v3-typescript @types/express @types/amqplib @types/nodemailer

This command sequence will create a package.json file and install the required Node.js packages for the Notification Service.

Now, let's proceed with the TypeScript configuration. Run the following command:

tsc --init

Open the generated tsconfig.json file and update it with the following configuration:

{
 "compilerOptions": {
   "target": "es6",
   "module": "commonjs",
   "outDir": "./build",
   "strict": true,
   "esModuleInterop": true,
   "skipLibCheck": true
 },
 "include": [
   "src/**/*"
 ],
 "exclude": [
   "node_modules",
   "tests"
 ]
}

Next, add the following code to your .env file:

# Environment variables for Notification Service

# Set the environment to development
NODE_ENV="development"

# Port for the Notification Service
PORT=8083

# Replace 'YOUR_MESSAGE_BROKER_URL' with the RabbitMQ message broker URL used in the User and Chat Service
MESSAGE_BROKER_URL="{{YOUR_MESSAGE_BROKER_URL}}"

# SMTP configuration for sending emails using SendInBlue (Brevo)
SMTP_HOST="smtp-relay.brevo.com"
SMTP_PORT=587

# Replace placeholders with your SendInBlue (Brevo) account details
SMTP_USER="{{YOUR_SENDINBLUE_ACCOUNT_EMAIL}}"
SMTP_PASS="{{YOUR_SENDINBLUE_MASTER_PASSWORD}}"

# Replace placeholders with your SendInBlue (Brevo) API key and email source
SENDINBLUE_APIKEY="{{YOUR_SENDINBLUE_APIKEY}}"
EMAIL_FROM="{{YOUR_EMAIL_SOURCE}}"

To set up the .env file correctly:

  1. Replace {{YOUR_MESSAGE_BROKER_URL}} with the RabbitMQ message broker URL you used in the User and Chat Service.
  2. Visit Brevo's website and log in or create an account if you don't have one.
  3. Navigate to the SMTP & API key page and create an SMTP key. Copy the value and replace {{YOUR_SENDINBLUE_MASTER_PASSWORD}} with this value.
  4. Click on the API KEYS tab, create an API key, and copy the value. Replace {{YOUR_SENDINBLUE_APIKEY}} with this value.
  5. Replace {{YOUR_SENDINBLUE_ACCOUNT_EMAIL}} with your Brevo account's email.
  6. Replace {{YOUR_EMAIL_SOURCE}} with the email address you want the emails to be sent from.

Now, let’s set up the config.ts file in the src/config folder. It manages the configuration variables used throughout the Notification Service. Let's dive into the code:

import { config } from "dotenv";

const configFile = `./.env`;
config({ path: configFile });

const {
   PORT,
   JWT_SECRET,
   NODE_ENV,
   MESSAGE_BROKER_URL,
   SENDINBLUE_APIKEY,
   EMAIL_FROM,
   SMTP_HOST,
   SMTP_PORT = 587,
   SMTP_USER,
   SMTP_PASS,
} = process.env;

const queue = { notifications: "NOTIFICATIONS" };

export default {
   PORT,
   JWT_SECRET,
   env: NODE_ENV,
   msgBrokerURL: MESSAGE_BROKER_URL,
   SENDINBLUE_APIKEY,
   EMAIL_FROM,
   queue,
   smtp: {
       host: SMTP_HOST,
       port: SMTP_PORT as number,
       user: SMTP_USER,
       pass: SMTP_PASS,
   },
};

In our config.ts file, we handle the configuration settings for our Notification Service. Using the dotenv library, we load environment variables from the .env file, which contains crucial settings like the server port, JWT secret key, message broker URL, SMTP settings, and more. These variables are then deconstructed for convenient access within the configuration object. Within this object, we define the notification queue and SMTP settings, ensuring seamless integration with external services such as RabbitMQ and SendInBlue (Brevo) for email delivery. Ultimately, we export this configuration object, making it available for use throughout the Notification Service, ensuring consistent and reliable behavior across the application.

Utils

With our configurations in place, let’s work on our utils. Add the following code to the apiError.ts in the src/utils file:

class ApiError extends Error {
   statusCode: number;
   isOperational: boolean;

   constructor(
       statusCode: number,
       message: string | undefined,
       isOperational = true,
       stack = ""
   ) {
       super(message);
       this.statusCode = statusCode;
       this.isOperational = isOperational;
       if (stack) {
           this.stack = stack;
       } else {
           Error.captureStackTrace(this, this.constructor);
       }
   }
}

export { ApiError };

This ApiError class performs the same function as in our User and Chat services.

Next, add the following code to the userStatusStore.ts file:

export class UserStatusStore {
   private static instance: UserStatusStore;
   private userStatuses: Record<string, boolean>;

   constructor() {
       this.userStatuses = {};
   }

   public static getInstance(): UserStatusStore {
       if (!UserStatusStore.instance) {
           UserStatusStore.instance = new UserStatusStore();
       }
       return UserStatusStore.instance;
   }

   setUserOnline(userId: string) {
       this.userStatuses[userId] = true;
   }

   setUserOffline(userId: string) {
       this.userStatuses[userId] = false;
   }

   isUserOnline(userId: string): boolean {
       return !!this.userStatuses[userId];
   }
}

This performs the same function as it does in our Chat Service.

Lastly, export the utils by adding the code below to the index.ts file:

import { UserStatusStore } from "./userStatusStore";
import { ApiError } from "./apiError";

export { UserStatusStore, ApiError };

This code exports the ApiError and UserStatusStore from the utils, making them available for use in other parts of the application. With our utils in place, let’s define our middleware.

Email Service

Add the following code to the EmailService.ts file located in the src/services folder:

import nodemailer from "nodemailer";
import config from "../config/config";

export class EmailService {
   private transporter;

   constructor() {
       this.transporter = nodemailer.createTransport({
           host: config.smtp.host,
           port: config.smtp.port,
           secure: false,
           auth: {
               user: config.smtp.user,
               pass: config.smtp.pass,
           },
       });
   }

   async sendEmail(to: string, subject: string, content: string) {
       const mailOptions = {
           from: config.EMAIL_FROM,
           to: to,
           subject: subject,
           html: content,
       };

       try {
           const info = await this.transporter.sendMail(mailOptions);
           console.log("Email sent: %s", info.messageId);
       } catch (error) {
           console.error("Error sending email:", error);
       }
   }
}

In the EmailService.ts file, we define the EmailService class responsible for handling email delivery. Upon instantiation, the constructor initializes a Nodemailer transporter instance using SMTP settings retrieved from the configuration file. The sendEmail method accepts the recipient's email address, subject, and content as parameters and constructs the email message options accordingly. Finally, it attempts to send the email using the transporter instance, logging success or error messages appropriately. This service ensures efficient and reliable email communication within our application.

Firebase Cloud Messaging (FCM) Services

In our Notification Service, we're integrating Firebase Cloud Messaging (FCM) Services to enable push notification delivery to mobile devices. The FCMService.ts file contains the code responsible for sending push notifications using the Firebase Admin SDK.

import admin from "firebase-admin";

admin.initializeApp({
   credential: admin.credential.applicationDefault(),
});

export class FCMService {
   async sendPushNotification(token: string, message: string) {
       const payload = {
           notification: {
               title: "New Message",
               body: message,
           },
           token: token,
       };

       try {
           await admin.messaging().send(payload);
           console.log("Notification sent successfully");
       } catch (error) {
           console.error("Error sending notification", error);
       }
   }
}

The FCMService class initializes the Firebase Admin SDK with default application credentials upon instantiation. It provides a sendPushNotification method, which accepts a device token and message as parameters to construct the notification payload. The method then sends the notification using the Firebase Admin SDK's messaging functionality. Any errors encountered during the process are logged for further investigation. This service ensures seamless push notification delivery to mobile devices, enhancing user engagement and real-time interaction within our application.

RabbitMQ Services

The RabbitMQService.ts file orchestrates the communication between the Notification Service and RabbitMQ message broker, enabling efficient message exchange for real-time notifications. This service integrates our Email and FCM (Firebase Cloud Messaging) services to deliver notifications via email or push notifications based on the user's online status.

import amqp, { Channel } from "amqplib";
import config from "../config/config";
import { FCMService } from "./FCMService";
import { EmailService } from "./EmailService";
import { UserStatusStore } from "../utils";

class RabbitMQService {
   private channel!: Channel;
   private fcmService = new FCMService();
   private emailService = new EmailService();
   private userStatusStore = new UserStatusStore();

   constructor() {
       this.init();
   }

   async init() {
       const connection = await amqp.connect(config.msgBrokerURL!);
       this.channel = await connection.createChannel();
       await this.consumeNotification();
   }

   async consumeNotification() {
       await this.channel.assertQueue(config.queue.notifications);
       this.channel.consume(config.queue.notifications, async (msg) => {
           if (msg) {
               const {
                   type,
                   userId,
                   message,
                   userEmail,
                   userToken,
                   fromName,
               } = JSON.parse(msg.content.toString());

               if (type === "MESSAGE_RECEIVED") {
                   // Check if the user is online
                   const isUserOnline =
                       this.userStatusStore.isUserOnline(userId);

                   if (isUserOnline && userToken) {
                       // User is online, send a push notification
                       await this.fcmService.sendPushNotification(
                           userToken,
                           message
                       );
                   } else if (userEmail) {
                       // User is offline, send an email
                       await this.emailService.sendEmail(
                           userEmail,
                           `New Message from ${fromName}`,
                           message
                       );
                   }
               }

               this.channel.ack(msg); // Acknowledge the message after processing
           }
       });
   }
}

export const rabbitMQService = new RabbitMQService();

The RabbitMQService class establishes a connection with the RabbitMQ message broker and consumes notifications from a designated queue. Upon receiving a notification, it parses the message content and determines the appropriate notification delivery method based on the user's online status. If the user is online and has a valid FCM token, the service sends a push notification using the FCMService. Otherwise, if the user is offline and an email address is provided, it dispatches an email notification using the EmailService. This integration ensures seamless and reliable delivery of notifications to users across different channels, enhancing the overall user experience.

Next, export the services:

import { FCMService } from "./FCMService";
import { EmailService } from "./EmailService";

export { FCMService, EmailService };

Middleware

With our services ready, open the index.ts file in the src/middleware folder and add the following code:

import { ErrorRequestHandler } from "express";
import { ApiError } from "../utils";

export const errorConverter: ErrorRequestHandler = (err, req, res, next) => {
   let error = err;
   if (!(error instanceof ApiError)) {
       const statusCode =
           error.statusCode ||
           (error instanceof Error
               ? 400 // Bad Request
               : 500); // Internal Server Error
       const message =
           error.message ||
           (statusCode === 400 ? "Bad Request" : "Internal Server Error");
       error = new ApiError(statusCode, message, false, err.stack.toString());
   }
   next(error);
};

export const errorHandler: ErrorRequestHandler = (err, req, res, next) => {
   let { statusCode, message } = err;
   if (process.env.NODE_ENV === "production" && !err.isOperational) {
       statusCode = 500; // Internal Server Error
       message = "Internal Server Error";
   }

   res.locals.errorMessage = err.message;

   const response = {
       code: statusCode,
       message,
       ...(process.env.NODE_ENV === "development" && { stack: err.stack }),
   };

   if (process.env.NODE_ENV === "development") {
       console.error(err);
   }

   res.status(statusCode).json(response);
   next();
};

The errorConverter and errorHandler functions perform the same functions as in our User and Chat Services.

Server Setup and Initialization

Well done so far. It’s time to set up our server. Open the server.ts file located in the src folder and add the following code:

import express, { Express } from "express";
import { Server } from "http";
import { errorConverter, errorHandler } from "./middleware";
import config from "./config/config";
import { rabbitMQService } from "./services/RabbitMQService";

const app: Express = express();
let server: Server;
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(errorConverter);
app.use(errorHandler);

server = app.listen(config.PORT, () => {
   console.log(`Server is running on port ${config.PORT}`);
});

const initializeRabbitMQClient = async () => {
   try {
       await rabbitMQService.init();
       console.log("RabbitMQ client initialized and listening for messages.");
   } catch (err) {
       console.error("Failed to initialize RabbitMQ client:", err);
   }
};

initializeRabbitMQClient();

const exitHandler = () => {
   if (server) {
       server.close(() => {
           console.info("Server closed");
           process.exit(1);
       });
   } else {
       process.exit(1);
   }
};

const unexpectedErrorHandler = (error: unknown) => {
   console.error(error);
   exitHandler();
};

process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);

This code initializes the Express application, sets up middleware for error handling, and starts the server to listen on the specified port. Additionally, it initializes the RabbitMQ client to handle messaging functionality and sets up handlers for process exits and unexpected errors to ensure graceful shutdown and error handling within the application.

Script Update

To ensure proper execution of the Notification service, update the "scripts" tag in the package.json file as follows:

"scripts": {
   "dev": "NODE_ENV=development nodemon src/server.ts",
   "build": "rm -rf build/ && tsc -p .",
   "start": "NODE_ENV=production nodemon build/server.js"
},

Additionally, update the "main" tag by setting it to "src/server.ts". This change ensures that the main entry point of the service is correctly identified.

To initiate the development server for your Notification service, run the following command:

npm run dev

Executing this command will start the development server, allowing it to listen to incoming messages and handle notifications effectively.

API Gateway

Congratulations on completing the setup of the Notification service! Now that our microservices are ready, let’s create our API Gateway. This will connect all our microservices ports to one gateway.

1. Navigate to the chat-server root directory

2. Create and navigate to the gateway folder

mkdir gateway && cd gateway

3. Set up the development server:

npm init -y

4. Install dependencies

npm install nodemon express ts-node express-http-proxy @types/express @types/express-http-proxy

5. Create the index.ts file:

touch index.ts

6. Add the following code to the index.ts file:

import express from "express";
import proxy from "express-http-proxy";

const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

const auth = proxy("http://localhost:8081");
const messages = proxy("http://localhost:8082");
const notifications = proxy("http://localhost:8083");

app.use("/api/auth", auth);
app.use("/api/messages", messages);
app.use("/api/notifications", notifications);

const server = app.listen(8080, () => {
   console.log("Gateway is Listening to Port 8080");
});

const exitHandler = () => {
   if (server) {
       server.close(() => {
           console.info("Server closed");
           process.exit(1);
       });
   } else {
       process.exit(1);
   }
};

const unexpectedErrorHandler = (error: unknown) => {
   console.error(error);
   exitHandler();
};

process.on("uncaughtException", unexpectedErrorHandler);
process.on("unhandledRejection", unexpectedErrorHandler);

In the above file, we create an Express application to serve as our API Gateway. This application is responsible for routing incoming HTTP requests to the appropriate microservices based on the request path. We begin by importing the necessary modules: express for creating the server and express-http-proxy for proxying requests to other services.

Next, we instantiate an Express application and configure it to parse JSON and URL-encoded data from incoming requests using express.json() and express.urlencoded() middleware.

Then, we create proxy middleware instances for each of our microservices: auth, messages, and notifications. These proxy middleware are configured to forward incoming requests to the corresponding microservice running on different ports: 8081 for user, 8082 for chat, and 8083 for notification service.

After defining the proxy middleware, we mount them on specific routes using app.use(). For example, requests to /api/auth are forwarded to the User microservice, requests to /api/messages are forwarded to the Chat microservice, and requests to /api/notifications are forwarded to the Notification microservice.

Finally, we start the Express server on port 8080 to listen for incoming requests. We also define error handlers to gracefully handle uncaught exceptions and unhandled rejections to ensure the stability of our application. This completes the setup of our API Gateway, which now acts as a central entry point for accessing the functionalities provided by our microservices.

Next, update the package.json file. Update the "script" tag with the following:

"scripts": {
   "dev": "nodemon index.ts",
},

and the "main" tag with "index.ts".

Now, run the following command to start the Gateway server:

npm run dev

Testing and Debugging

With the setup of our microservices and API Gateway complete, it's crucial to conduct thorough testing to ensure flawless functionality.

Microservice Verification:

Confirm that each microservice is up and running. The user service should be accessible at [http://localhost:8081](http://localhost:8081), the chat service at [http://localhost:8082](http://localhost:8082/), and the notification service at [http://localhost:8083](http://localhost:8083/).

With your Postman or any other API tool of your choice. We’d be using Postman in this tutorial.

User Registration and Authentication:

Register a new user by sending a POST request to http://localhost:8080/user/register with the following JSON data:

{
   "name": "Test User",
   "email": "{{REAL_EMAIL_ADDRESS}}",
   "password": "password"
}

Replace REAL_EMAIL_ADDRESS with a valid email address (we’ll need this to send emails). Upon successful registration, proceed to log in by sending a POST request to [http://localhost:8080/user/login](http://localhost:8080/user/login) with the registered email and password.

Message Sending:

Test the message-sending functionality by making a POST request to [http://localhost:8080/chat/send](http://localhost:8080/chat/send) with the following JSON data:

{
   "receiverId": "{{RECEIVER_ID}}",
   "message": "Hello there!"
}

Replace RECEIVER_ID with the ID of another registered user obtained from the registration response. Upon sending the message, an email notification will be dispatched to the recipient's email address.

By meticulously executing these testing steps, we ensure that our microservices operate seamlessly and deliver the desired functionality. Any encountered issues or discrepancies will be addressed promptly, ensuring the reliability and robustness of our system.

Nginx Configuration

Configure Nginx by following these steps:

Create the necessary files and folders by running the following code in the chat-server root folder:

mkdir nginx && cd nginx
touch Dockerfile nginx.conf

This command creates the nginx folder to house our Nginx configurations and creates both the Dockerfile and nginx.conf files.

Add the following code to the Dockerfile:

FROM nginx:latest

RUN rm /etc/nginx/nginx.conf

COPY nginx.conf /etc/nginx/nginx.conf

Add the following code to the nginx.conf file:

http {
   upstream user {
       server user:8081;
   }
   upstream chat {
       server chat:8082;
   }
   upstream notification {
       server notification:8083;
   }

   server {
       listen 85;

       location /user/ {
           proxy_pass http://user/;
       }

       location /chat/ {
           proxy_pass http://chat/;
       }

       location /notification/ {
           proxy_pass http://notification/;
       }
   }
}
events {}

This configuration uses Nginx as a reverse proxy to route requests to the appropriate microservices based on the URL path. Each microservice is defined as an upstream server, and Nginx listens on port 85 for incoming requests. Requests to /user/, /chat/, and /notification/ are proxied to the respective microservices running on ports 8081, 8082, and 8083.

Docker and Containerization

With everything working perfectly, it’s time to containerize our microservices. Containerization is pivotal for deploying and managing our services efficiently. Let's proceed with containerizing each microservice using Docker. Follow these steps:

1. Open the Dockerfile located in the user-service folder, and add the following code:

FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 8081

CMD [ "npm", "start" ]

This Dockerfile configures a lightweight Node.js environment, copies the necessary files, installs dependencies, builds the application, exposes port 8081, and starts the user service upon container initialization.

2. Open the Dockerfile located in the chat-service folder, and append the following code:

FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 8082

CMD [ "npm", "start" ]

Similarly, this exposes port 8082 and starts the chat service upon container initialization.

3. Open the Dockerfile located in the notification-service folder, and add the following code:

FROM node:18-alpine

WORKDIR /usr/src/app

COPY package*.json ./

RUN npm install

COPY . .

RUN npm run build

EXPOSE 8083

CMD [ "npm", "start" ]

This also exposes port 8083 and starts the notification service upon container initialization.

4. Update the .dockerignore file of every microservice with the following code:

node_modules
npm-debug.log
Dockerfile
.dockerignore
.git
.gitignore

This excludes the above-mentioned files from being included in the Docker image creation process for each microservice. This helps reduce the size of the Docker image and ensures that only necessary files are included.

5. Open the docker-compose.yml file in the chat-server folder (the parent folder) and add the following code:

version: '3.8'
services:
 mongodb:
   image: mongo:latest
   restart: unless-stopped
   ports:
     - "27017:27017"
   volumes:
     - mongo-data:/data/db

 user:
   build:
     context: ./user-service
     dockerfile: Dockerfile
   ports:
     - "8081:8081"
   restart: always
   depends_on:
     - "mongodb"
   environment:
     - NODE_ENV=production

 chat:
   build:
     context: ./chat-service
     dockerfile: Dockerfile
   ports:
     - "8082:8082"
   depends_on:
     - "mongodb"
   environment:
     - NODE_ENV=production

 notification:
   build:
     context: ./notification-service
     dockerfile: Dockerfile
   ports:
     - "8083:8083"
   depends_on:
     - "mongodb"
   environment:
     - NODE_ENV=production

 nginx:
   build:
     context: ./nginx
     dockerfile: Dockerfile
   ports:
     - "85:85"
   depends_on:
     - user
     - chat
     - notification

volumes:
 mongo-data:

This Docker Compose configuration defines several services:

  • mongodb: Runs a MongoDB container.
  • user, chat, and notification: Build and run containers for the user service, chat service, and notification service, respectively.
  • nginx: Configures an NGINX server for routing requests to the appropriate microservices.

Each service has its configuration for building the Docker image, specifying ports to expose, and setting environment variables. Additionally, dependencies are defined to ensure that the services start-up in the correct order. Finally, a volume is specified to persist MongoDB data between container restarts.

1. To start up our application in a containerized environment, run the following command:

docker-compose up --build

This command will build and start all the services defined in the docker-compose.yml file. It will also ensure that any changes made to the Dockerfiles or other configurations are applied during the build process.

Docker logs

Conclusion

We have successfully designed, developed, and containerized a microservices-based chat application server. Through a step-by-step approach, we tackled each component of the system, including the User Service, Chat Service, Notification Service, API Gateway, Docker, and Nginx configuration.

We began by structuring our project directory and setting up each microservice individually. The User Service facilitated user registration and authentication, while the Chat Service enabled real-time communication between users. The Notification Service handled email notifications and push notifications using RabbitMQ for message queuing.

To ensure seamless communication between microservices, we implemented an API Gateway using Express.js, which served as a single entry point for our application. This allowed us to centralize access to our microservices and manage routing efficiently.

Following the development phase, we containerized our microservices using Docker, ensuring consistent deployment across different environments. We configured Nginx as a reverse proxy to route incoming requests to the appropriate microservice based on the URL path.

Throughout the tutorial, we emphasized the importance of testing and debugging to ensure the reliability and functionality of our application. By conducting manual testing and leveraging tools like Postman, we verified the behavior of each microservice and API endpoint, addressing any issues that arose along the way.

In conclusion, this tutorial serves as an introductory guide to microservices, providing foundational knowledge and practical experience in building and deploying microservices-based applications. While it covers essential concepts and implementation steps, it's important to recognize that microservices entail a vast and nuanced ecosystem beyond the scope of this tutorial. By following along with this tutorial, beginners can gain valuable insights into microservices architecture, containerization, and best practices for developing scalable and resilient applications.

For experienced developers, this tutorial offers a refresher on key principles and an opportunity to explore modern technologies in the context of microservices development. To access the full code of this tutorial, please visit this repository.

-
Are you an aspiring Product Manager? The Co.Lab program is the perfect place to gain real-world, cross-functional experience that you wouldn’t get anywhere else because you’re going to be owning a product life cycle . Follow us on on Instagram, Twitter, and LinkedIn for the latest updates.

Stay up to date with Co.Lab

We'll be sure to keep you in the loop