NestJS Microservices: ClientProxy, Message Patterns & RabbitMQ — Complete Guide
Build NestJS microservices with ClientProxy.send(), replace deprecated toPromise() with firstValueFrom(), and connect services via RabbitMQ transport. Covers request-response vs event patterns, error handling, and production architecture decisions.
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: 5andretryDelay: 3000to handle startup order issues in container orchestration. - Environment Variables: Never hardcode hosts or ports. Use ConfigService with sensible defaults.
- Health Checks: Implement
/healthendpoints using@nestjs/terminusfor container orchestration. - Validation: Use
ValidationPipeglobally 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.
Frequently Asked Questions
Q: Why is ClientProxy.send().toPromise() deprecated in NestJS?
toPromise() was deprecated in RxJS 7. Replace it with firstValueFrom() from the rxjs package. Example: const result = await firstValueFrom(this.client.send('pattern', data)). firstValueFrom resolves on the first emitted value and rejects if the observable completes without emitting.
Q: What is the difference between ClientProxy.send() and emit() in NestJS?
send() is for request-response patterns — it waits for a reply from the handler. emit() is fire-and-forget — it sends an event without waiting for a response. Use send() when you need the result; use emit() for notifications and background tasks.
Q: How do I use firstValueFrom with ClientProxy in NestJS?
Import firstValueFrom from 'rxjs', then wrap your call: const result = await firstValueFrom(this.client.send<ResponseType>('message_pattern', payload)). This replaces the deprecated .toPromise() pattern from RxJS 6.
Q: How do I handle microservice errors with RpcException in NestJS?
Throw new RpcException({ message: 'error', statusCode: 400 }) in your microservice handler. In the calling service, catch the error in a try/catch around firstValueFrom(). Use an RpcExceptionFilter to map RpcException to HTTP errors at the gateway.
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.
Related Reading
These posts cover the infrastructure and messaging layer that completes this microservices architecture:
- How Our Services Talk to Each Other: Message Queues Explained — how RabbitMQ decouples the NestJS microservices described here, enabling async communication between Node.js and Python services.
- How We Deployed and Scaled on Azure: A Production Playbook — the Azure Container Apps setup that runs these NestJS microservices in production.
The full production architecture is documented in the projects section, including the Turborepo monorepo structure these services live in.
Working on something similar?
I'm a Technical Lead available for contracts & consulting.
Specializing in NestJS microservices, Azure cloud architecture, and distributed systems. If your team is tackling similar challenges, let's talk.
Get in touchWritten by

Technical Lead and Full Stack Engineer leading a 5-engineer team at Fygurs (Paris, Remote) on Azure cloud-native SaaS. Graduate of 1337 Coding School (42 Network / UM6P). Writes about architecture, cloud infrastructure, and engineering leadership.