Boumlik BrahimBoumlik Brahim
Back to Journal
Microservices

NestJS Microservices: Complete Production Architecture Guide with Testing

A comprehensive guide to building production-ready microservices with NestJS. Covers TCP & RabbitMQ transport, API Gateway patterns, @MessagePattern controllers, cross-service guards, global RPC exception handling, and testing strategies.

Building scalable applications requires moving beyond monolithic architectures. Microservices offer the promise of independent deployment, technology flexibility, and horizontal scaling, but they introduce a fundamental challenge: how do services communicate? In this comprehensive guide, I'll walk you through building a production-ready NestJS microservices architecture using TCP transport, covering everything from basic setup to error handling and resilience patterns.

Understanding Microservices in NestJS

In NestJS, a microservice is fundamentally an application that uses a different transport layer than HTTP. While your API Gateway speaks HTTP to the outside world, internal services communicate through more efficient protocols like TCP, Redis, NATS, or message queues like RabbitMQ.

NestJS abstracts these transport layers behind a unified interface, meaning you can switch from TCP to Redis without changing your business logic. This is powerful: your code remains clean while the framework handles the complexity of distributed communication.

Installation

To get started with NestJS microservices, install the required package:


npm i @nestjs/microservices

Architecture Overview

Before diving into code, let's understand the architecture we're building:

┌─────────────────┐
│  Next.js Client │
└────────┬────────┘
         │ HTTP/REST
         ▼
┌─────────────────────────────────────┐
│       API Gateway (Port 3001)       │
│  • Authentication & Authorization   │
│  • Request Validation (DTOs)        │
│  • Rate Limiting                    │
│  • Swagger Documentation            │
└──────────┬──────────────────────────┘
           │
     ┌─────┴─────┐
     │    TCP    │
     ▼           ▼
┌─────────────┐  ┌───────────────┐
│Microservice │  │ Microservice  │
│  1 :3002    │  │   2 :3003     │
└─────────────┘  └───────────────┘
  • API Gateway (Port 3001): The single entry point exposed to the internet. Handles HTTP requests, validates input, and routes to appropriate microservices via TCP.
  • Microservice 1 (Port 3002): Handles a specific domain of your application.
  • Microservice 2 (Port 3003): Handles another domain, completely isolated from Microservice 1.

Why TCP for Internal Communication?

We chose TCP as our primary transport for synchronous operations. Here's the decision framework:

  • Use TCP when the user is waiting for a response. Login, data fetching, form submissions. These need immediate feedback. TCP provides low-latency, bidirectional communication perfect for request-response patterns.
  • Use Message Queues (RabbitMQ/Redis) when the user doesn't need to wait. Sending emails, generating reports, processing uploads. These can be queued and processed asynchronously.

TCP strikes the right balance: faster than HTTP (no headers overhead), simpler than message queues, and NestJS handles connection pooling automatically.

Setting Up the API Gateway

The API Gateway is the receptionist of your architecture. It's the only service exposed to the public internet, responsible for authentication, validation, and routing requests to the appropriate microservice.

Main Bootstrap File


import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { ValidationPipe } from "@nestjs/common";
import * as bodyParser from "body-parser";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.use("/webhook", bodyParser.raw({ type: "application/json" }));

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
  }));

  const config = new DocumentBuilder()
    .setTitle("Microservices API")
    .setDescription("API Gateway for microservices architecture")
    .setVersion("1.0")
    .addBearerAuth()
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup("api", app, document);

  app.enableCors();
  await app.listen(3001);
}
bootstrap();

Registering Microservice Clients

The API Gateway needs to know how to reach each microservice. NestJS provides ClientsModule for this purpose. Using registerAsync ensures the ConfigService is available and environment variables are properly loaded before the client is configured.


import { Module } from "@nestjs/common";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { AppController } from "./app.controller";
import { AppService } from "./app.service";

@Module({
  imports: [
    ConfigModule.forRoot(),
    ClientsModule.registerAsync([
      {
        name: "MICROSERVICE_ONE",
        imports: [ConfigModule],
        useFactory: (configService: ConfigService) => ({
          transport: Transport.TCP,
          options: {
            host: configService.get("MS_ONE_HOST", "localhost"),
            port: configService.get("MS_ONE_PORT", 3002),
          },
        }),
        inject: [ConfigService],
      },
      {
        name: "MICROSERVICE_TWO",
        imports: [ConfigModule],
        useFactory: (configService: ConfigService) => ({
          transport: Transport.TCP,
          options: {
            host: configService.get("MS_TWO_HOST", "localhost"),
            port: configService.get("MS_TWO_PORT", 3003),
          },
        }),
        inject: [ConfigService],
      },
    ]),
  ],
  controllers: [AppController],
  providers: [AppService],
  exports: [ClientsModule],
})
export class AppModule {}

Communicating with Microservices

Once the client is registered, inject it into your service using the @Inject decorator with the same token name.

The Gateway Service


import { Injectable, Inject, HttpException, HttpStatus } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom, timeout, catchError } from "rxjs";
import { CreateItemDto } from "./dto/create-item.dto";

@Injectable()
export class AppService {
  constructor(
    @Inject("MICROSERVICE_ONE") private microserviceOne: ClientProxy,
    @Inject("MICROSERVICE_TWO") private microserviceTwo: ClientProxy,
  ) {}

  async createItem(createItemDto: CreateItemDto) {
    const pattern = { cmd: "createItem" };

    try {
      const result = await firstValueFrom(
        this.microserviceOne.send(pattern, createItemDto).pipe(
          timeout(5000),
          catchError((error) => {
            throw new HttpException(
              error.message || "Microservice unavailable",
              error.status || HttpStatus.SERVICE_UNAVAILABLE,
            );
          }),
        ),
      );
      return result;
    } catch (error) {
      if (error instanceof HttpException) throw error;
      throw new HttpException(
        "Failed to communicate with microservice",
        HttpStatus.SERVICE_UNAVAILABLE,
      );
    }
  }

  async findAllItems() {
    return firstValueFrom(
      this.microserviceOne.send({ cmd: "findAllItems" }, {}).pipe(
        timeout(5000),
      ),
    );
  }
}

Important: The .toPromise() method is deprecated in RxJS. Always use firstValueFrom() or lastValueFrom() from the rxjs package instead.

Building the Microservice

Now let's build the actual microservice that handles the business logic. Unlike the API Gateway, this service doesn't use HTTP. It listens for TCP messages.

Microservice Bootstrap


import { NestFactory } from "@nestjs/core";
import { MicroserviceOptions, Transport } from "@nestjs/microservices";
import { AppModule } from "./app.module";
import { ValidationPipe } from "@nestjs/common";
import { AllExceptionsFilter } from "./common/filters/rpc-exception.filter";

async function bootstrap() {
  const app = await NestFactory.createMicroservice(
    AppModule,
    {
      transport: Transport.TCP,
      options: {
        host: process.env.MS_HOST || "0.0.0.0",
        port: parseInt(process.env.MS_PORT, 10) || 3002,
        retryAttempts: 5,
        retryDelay: 3000,
      },
    },
  );

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    transform: true,
  }));

  app.useGlobalFilters(new AllExceptionsFilter());

  await app.listen();
}
bootstrap();

Message Pattern Controllers

Microservice controllers don't use @Get(), @Post(), etc. Instead, they use @MessagePattern() for request-response communication and @EventPattern() for fire-and-forget events.


import { Controller } from "@nestjs/common";
import { MessagePattern, Payload, RpcException } from "@nestjs/microservices";
import { ItemService } from "./item.service";
import { CreateItemDto } from "./dto/create-item.dto";
import { UpdateItemDto } from "./dto/update-item.dto";

@Controller()
export class ItemController {
  constructor(private readonly itemService: ItemService) {}

  @MessagePattern({ cmd: "createItem" })
  async createItem(@Payload() createItemDto: CreateItemDto) {
    try {
      const item = await this.itemService.create(createItemDto);
      return item;
    } catch (error) {
      throw new RpcException({
        status: error.status || 500,
        message: error.message || "Failed to create item",
      });
    }
  }

  @MessagePattern({ cmd: "findAllItems" })
  async findAllItems() {
    return this.itemService.findAll();
  }

  @MessagePattern({ cmd: "findOneItem" })
  async findOneItem(@Payload() data: { id: string }) {
    const item = await this.itemService.findOne(data.id);
    if (!item) {
      throw new RpcException({
        status: 404,
        message: `Item with ID ${data.id} not found`,
      });
    }
    return item;
  }

  @MessagePattern({ cmd: "updateItem" })
  async updateItem(@Payload() data: { id: string; updateItemDto: UpdateItemDto }) {
    return this.itemService.update(data.id, data.updateItemDto);
  }

  @MessagePattern({ cmd: "deleteItem" })
  async deleteItem(@Payload() data: { id: string }) {
    return this.itemService.remove(data.id);
  }
}

Pattern Naming Conventions

Consistent naming makes debugging easier. We follow two conventions:

  • Object patterns for clarity: { cmd: "createItem" }
  • Versioned strings for evolving APIs: item.v1.create, item.v2.create

Global RPC Exception Filter

Error handling in microservices requires special attention. When something goes wrong, you need to propagate meaningful errors back to the gateway without exposing internal details.


import { Catch, ArgumentsHost, ExceptionFilter } from "@nestjs/common";
import { RpcException } from "@nestjs/microservices";
import { Observable, throwError } from "rxjs";

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
  catch(exception: unknown, host: ArgumentsHost): Observable {
    if (exception instanceof RpcException) {
      const error = exception.getError();
      return throwError(() => error);
    }

    console.error("Unexpected microservice error:", exception);

    return throwError(() => ({
      status: 500,
      message: "Internal microservice error",
      timestamp: new Date().toISOString(),
    }));
  }
}

Handling Timeouts and Resilience

In distributed systems, services can become slow or unresponsive. Always implement timeouts and consider retry strategies to prevent cascading failures.


import { Injectable, Inject, ServiceUnavailableException } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom, timeout, retry, catchError, throwError } from "rxjs";

@Injectable()
export class ResilientService {
  constructor(
    @Inject("MICROSERVICE_ONE") private microserviceOne: ClientProxy,
  ) {}

  async callWithResilience(pattern: any, payload: any): Promise {
    return firstValueFrom(
      this.microserviceOne.send(pattern, payload).pipe(
        timeout(5000),
        retry({ count: 2, delay: 1000 }),
        catchError((error) => {
          return throwError(() =>
            new ServiceUnavailableException("Service temporarily unavailable")
          );
        }),
      ),
    );
  }
}

Production Deployment Checklist

  • Retry Configuration: Set retryAttempts: 5 and retryDelay: 3000 to handle startup order issues in container orchestration.
  • Environment Variables: Never hardcode hosts or ports. Use ConfigService with sensible defaults.
  • Health Checks: Implement /health endpoints using @nestjs/terminus for container orchestration.
  • Validation: Use ValidationPipe globally in both gateway and microservices.
  • Timeouts: Always set timeouts on microservice calls to prevent cascading failures.
  • Logging: Use structured logging with correlation IDs to trace requests across services.
  • Docker Networking: Use service names instead of IPs in Docker Compose or Kubernetes.

Conclusion

Building microservices with NestJS combines the framework's excellent developer experience with battle-tested patterns for distributed systems. The key takeaways:

  • TCP for synchronous operations where users wait for responses
  • Use registerAsync for proper configuration injection
  • Always implement timeouts and retry strategies for resilience
  • RpcException for meaningful error propagation between services
  • ValidationPipe globally in both gateway and microservices

This architecture scales horizontally. Each microservice can be deployed independently, scaled based on load, and updated without affecting others. NestJS makes the wiring configuration-driven, letting you focus on business logic while the framework handles the complexity of distributed communication.

Boumlik BrahimBoumlik Brahim

Technical Lead & IT Architecture Expert. Architecting resilient cloud ecosystems and scalable, high-performance solutions.

boumlik01brahim@gmail.com

Explore

Connect

  • Boumlik-Brahim
  • brahim-boumlik
  • @BOUMLIKBRAHIM4
  • @brahimboumlik
  • Brahim Boumlik

Status

Location

Casablanca, Morocco

Download Resume

© 2026 Boumlik Brahim.

GitHubLinkedInTwitterInstagramFacebook