How We Architected a Production SaaS: A Microservices Deep Dive
Building a product strategy platform means handling auth, roadmapping, payments, notifications, and real-time collaboration — all independently scalable. This is the architecture we landed on after several iterations in a production SaaS, the tradeoffs we made, and what we'd reconsider.
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.
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.
Written 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.