Building Scalable Applications: A Guide to Microservices with NestJS

Jump to

In today’s complex software development environment, the demand for scalable and maintainable applications is ever-increasing. Microservice architecture has emerged as a prominent solution, offering a modular approach to application development by breaking down large systems into smaller, independent services. NestJS, a powerful Node.js framework, has gained popularity for building microservices due to its structured approach and developer-friendly features.

This article explores the concept of microservice architecture, its advantages, and provides a step-by-step guide to building a simple Math and String microservice using NestJS.

The Advantages of Microservices

Microservices offer significant advantages over traditional monolithic architectures by decomposing complex applications into manageable, independent units. These advantages include:

  • Scalability: Microservices enable independent scaling of specific components. High-traffic areas, such as payment gateways, can be scaled without affecting other parts of the system, optimizing resource utilization.
  • Faster Development and Deployment: Independent microservices allow development teams to work in parallel, accelerating the development process and enabling continuous deployment of updates. Teams can work on different parts of the application without waiting for others to complete their tasks.
  • Fault Isolation: Unlike monolithic systems where a single point of failure can bring down the entire application, microservices isolate faults. If one service fails, the rest of the application remains operational, minimizing downtime.
  • Technology Flexibility: Teams can choose the most suitable technologies for each microservice. For instance, one service might leverage Node.js while another utilizes Python, based on their specific requirements.
  • Improved Maintenance: Smaller, specialized services simplify understanding, modification, and debugging, leading to more efficient maintenance. The smaller scope makes it easier to comprehend, modify, and track down bugs.

Why Choose NestJS for Microservices?

NestJS simplifies microservice development with its opinionated structure, minimizing setup and management overhead. Its support for asynchronous programming and TypeScript compatibility promotes clean, maintainable code. Built-in tools for inter-service communication further streamline the management of distributed systems.

Designing an Effective Microservice Architecture

Building a microservice architecture requires careful planning to ensure scalability, reliability, and maintainability. NestJS facilitates this process with its modular design and developer-centric approach.

Break Down the Application: 

Identify distinct functionalities within the application and divide them into separate microservices. For an e-commerce platform, services could include user management, product catalogs, order processing, and payments. Each service should focus on a specific task and interact with others via APIs or message brokers. Isolating each service into a separate module promotes independence and self-containment.

@Module({
  controllers: [OrderController],
  providers: [OrderService],
})
export class OrderModule {}

Project Setup:

Create the project directory and initialize the NestJS applications

mkdir multi-microservice-demo && cd multi-microservice-demo
nest new math-service
nest new string-service
nest new client-app

Install the required microservices package in each project:

npm install --save @nestjs/microservices

Build the Math Microservice:

Create a controller to handle math operations in the math-service project. Update the src/math.controller.ts file:

import { Controller } from "@nestjs/common";
import { MessagePattern } from "@nestjs/microservices";

@Controller()
export class MathController {
  @MessagePattern({ cmd: "sum" })
  calculateSum(data: number[]): number {
    return data.reduce((a, b) => a + b, 0);
  }

  @MessagePattern({ cmd: "multiply" })
  calculateProduct(data: number[]): number {
    return data.reduce((a, b) => a * b, 1);
  }
}

The MathController defines methods for summing and multiplying numbers, using the @MessagePattern decorator to listen for specific commands from other microservices.

Register the controller in AppModule by updating src/app.module.ts:

import { Module } from "@nestjs/common";
import { MathController } from "./math.controller";

@Module({
  controllers: [MathController],
})
export class AppModule {}

Modify the entry point to run the microservice by updating src/main.ts:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.TCP,
      options: { host: "127.0.0.1", port: 3001 },
    }
  );
  await app.listen();
  console.log("Math Service is running on port 3001");
}
bootstrap();

This configures the NestJS microservice to use TCP transport and listen on port 3001.

Start the Math Microservice:

npm run start

Build the String Microservice:

Create a controller for string operations in the string-service project. Update src/string.controller.ts:

import { Controller } from "@nestjs/common";
import { MessagePattern } from "@nestjs/microservices";

@Controller()
export class StringController {
  @MessagePattern({ cmd: "concat" })
  concatenateStrings(data: string[]): string {
    return data.join(" ");
  }

  @MessagePattern({ cmd: "capitalize" })
  capitalizeString(data: string): string {
    return data.toUpperCase();
  }
}

The StringController defines methods for concatenating and capitalizing strings, using @MessagePattern to listen for commands.

Register the controller in AppModule by updating src/app.module.ts:

import { Module } from "@nestjs/common";
import { StringController } from "./string.controller";

@Module({
  controllers: [StringController],
})
export class AppModule {}

Modify the entry point by updating src/main.ts:

import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.TCP,
      options: { host: "127.0.0.1", port: 3002 },
    }
  );
  await app.listen();
  console.log("String Service is running on port 3002");
}
bootstrap();

This sets up the String microservice to use TCP transport and listen on port 3002.

Start the String Microservice:

npm run start

Build the Client Application:

Create a gateway service to communicate with both microservices. Open the client-app folder and update src/app.service.ts:

import { Injectable } from "@nestjs/common";
import {
  ClientProxy,
  ClientProxyFactory,
  Transport,
} from "@nestjs/microservices";

@Injectable()
export class AppService {
  private mathClient: ClientProxy;
  private stringClient: ClientProxy;

  constructor() {
    this.mathClient = ClientProxyFactory.create({
      transport: Transport.TCP,
      options: { host: "127.0.0.1", port: 3001 },
    });
    this.stringClient = ClientProxyFactory.create({
      transport: Transport.TCP,
      options: { host: "127.0.0.1", port: 3002 },
    });
  }

  async calculateSum(numbers: number[]): Promise<number> {
    return this.mathClient.send({ cmd: "sum" }, numbers).toPromise();
  }

  async capitalizeString(data: string): Promise<string> {
    return this.stringClient.send({ cmd: "capitalize" }, data).toPromise();
  }
}

The AppService manages communication with the Math and String microservices using ClientProxy instances. The ClientProxyFactory.create() method is used to create the two client proxies, enabling the service to communicate with other microservices over TCP.

Update the AppController to use this service by modifying src/app.controller.ts:

import { Controller, Get } from "@nestjs/common";
import { AppService } from "./app.service";

@Controller()
export class AppController {
constructor(private readonly appService: AppService) {}

@Get("sum")
async getSum(): Promise<number> {
return this.appService.calculateSum([5, 10, 15]);
}

@Get("capitalize")
async getCapitalizedString(): Promise<string> {
return this.appService.capitalizeString("hello world");
}
}

The AppController handles HTTP requests and delegates the logic to the AppService.

Run the Client Application:

npm run start

Testing the System

To test the system, start each microservice:

cd math-service && npm run start
cd string-service && npm run start
cd client-app && npm run start

Test the Endpoints

Open a browser and navigate to the following URLs:

  • http://localhost:3000/sum → Returns 30.
  • http://localhost:3000/capitalize → Returns HELLO WORLD.

Best Practices for Microservices

When working with microservices, certain practices ensure efficiency and maintainability:

  • Domain-Driven Design (DDD): Align microservices with business domains to ensure each service has a clear responsibility.
  • API Gateway: Use an API gateway to manage external requests and route them to the appropriate microservice.
  • Centralized Configuration: Manage configuration centrally to avoid inconsistencies across microservices.
  • Monitoring and Logging: Implement robust monitoring and logging to quickly identify and resolve issues.
  • Automation: Automate deployment and scaling to ensure rapid and reliable releases.

Conclusion

NestJS offers a powerful and efficient way to build microservice architectures. By following the steps outlined in this article, developers can create scalable, maintainable, and flexible applications that meet the demands of modern software development.

Read more such articles from our Newsletter here.

Leave a Comment

Your email address will not be published. Required fields are marked *

You may also like

Microservices Data Consistency

Navigating Data Consistency in Microservices

The move from monolithic to microservices architectures presents numerous benefits, including enhanced scalability, high availability, and increased agility. However, this transition introduces complexities, particularly concerning data consistency. In a traditional

Categories
Scroll to Top