Mastering Microservices: A Hands-On Tutorial with Node.js, RabbitMQ, Nginx, and Docker
Published: 2024-02-08 18:18:20

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](https://dev.to/davydocsurg/demystifying-microservices-with-nodejs-a-journey-from-netflixs-semicolon-crisis-to-the-future-of-software-103e) for a detailed explanation. In this hands-on tutorial, we'll build a real-time chat server using Node.js, [Socket.io](http://socket.io/), RabbitMQ, and Docker. Get ready for a practical journey into the world of microservices! Let's begin.


## **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](https://nodejs.org/).

```bash
# 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.

```bash
# 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](https://www.docker.com/).

**4. MongoDB**

Ensure you have MongoDB installed for data storage. You can download MongoDB Community Server from [MongoDB's official website](https://www.mongodb.com/) 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](https://nginx.com).

**6. Postman (Optional)**

To test the API endpoints, you can use Postman. Download and install Postman from [Postman's official website](https://postman.com).

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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ek94lkzk1a8kj3sa3ie9.png)


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:

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

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

```bash
**/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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/z8t4oa845d0sjcpcyskk.png)

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:

```bash
mkdir user-service && cd user-service
```

Next, create the `src` folder and navigate to it:

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

Create individual files:

```bash
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:

```bash
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

```bash
npm init -y
```

Install dependencies 

```bash
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

```bash
npm install -g typescript
```

Initialize TypeScript configuration

```bash
tsc --init
```

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

```tsx
{
  "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:

```makefile
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](https://www.cloudamqp.com/), by following these steps:

1. Visit [CloudAMQP’s website](https://www.cloudamqp.com/), 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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/t6ws7deuq55bdcygc0l7.png)


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

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```json
"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:

```bash
npm run dev
```

or

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/7naxfwlqjbyufkdpl8aq.png)

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:

```bash
mkdir chat-service && cd chat-service
```

Create the `src`  folder and navigate to it:

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

Create individual files:

```bash
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:

```bash
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:

```bash
npm init -y
```

Install dependencies

```bash
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:

```bash
tsc --init
```

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

```tsx
{
  "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:

```makefile
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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: 

```json
"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:

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/npfb8se0kle9z6auknle.png)

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:

```bash
mkdir notification-service && cd notification-service
```

Next, create the `src`  folder and navigate to it:

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

Now, create individual files:

```bash
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:

```bash
cd ..
touch Dockerfile .dockerignore .env
```

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

1. **config/config.ts**:
    - This file contains configurations related to the Notification Service, such as RabbitMQ connection details and other environment variables.
2. **services/RabbitMQService.ts**:
    - This file implements the RabbitMQ service, which handles message queueing and communication with other microservices via RabbitMQ.
3. **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.
4. **services/FCMService.ts**:
    - This file contains the Firebase Cloud Messaging (FCM) service implementation, allowing the Notification Service to send push notifications to mobile devices.
5. **services/index.ts**:
    - This file exports all service modules, facilitating easy import and access to service functionalities from other parts of the application.
6. **middleware/index.ts**:
    - This file houses and exports middleware functions used within the Notification Service, such as authentication middleware or error handling middleware.
7. **utils/apiError.ts**:
    - This file defines the `ApiError` class, which represents custom error objects used throughout the application to handle API-related errors.
8. **utils/userStatusStore.ts**:
    - This file contains the `UserStatusStore` class, which manages the online/offline status of users, facilitating real-time communication and notification delivery.
9. **utils/index.ts**:
    - This file exports utility functions and classes for easy access and use within the Notification Service.
10. **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:

```bash
npm init -y
```

Install dependencies

```bash
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:

```bash
tsc --init
```

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

```tsx
{
  "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:

```makefile
# 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](https://brevo.com/) 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:

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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.

```tsx
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.

```tsx
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:

```tsx
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:

```tsx
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:

```tsx
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:

```json
"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:

```bash
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

```bash
mkdir gateway && cd gateway
```

1. Set up the development server:

```bash
npm init -y
```

1. Install dependencies

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

1. Create the `index.ts` file:

```bash
touch index.ts
```

1. Add the following code to the `index.ts` file:

```tsx
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:

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

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

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

```tsx
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.

1. **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/)`.
    
2. With your Postman or any other API tool of your choice. We’d be using Postman in this tutorial.
3. **User Registration and Authentication:**
    
    Register a new user by sending a `POST` request to `http://localhost:8080/user/register` with the following JSON data:
    
    ```json
    {
        "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.
    
4. **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:
    
    ```json
    {
        "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:

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

```bash
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.

1. Add the following code to the `Dockerfile`:

```docker
FROM nginx:latest

RUN rm /etc/nginx/nginx.conf

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

1. Add the following code to the `nginx.conf` file:

```bash
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:

```docker
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.

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

```docker
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.

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

```docker
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.

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

```docker
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.

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

```docker
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:

```bash
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](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ibi3kafly1ofqq63o10x.png)


## 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](https://github.com/davydocsurg/chat-server/tree/develop).