NestJS has a built-in microservices module that runs ordinary controllers over a non-HTTP transport. The API is small: @MessagePattern on the server, ClientProxy on the caller, and a transport config on both sides. The slower questions are why to use this instead of HTTP, which transport to pick, how to lay out a NestJS microservices architecture with two apps, and what happens when a handler throws.
This post walks through a working NestJS microservices example: two services over TCP transport, with error propagation and a Docker Compose file for local dev.
NestJS microservices are transport-agnostic. The same @MessagePattern decorator works regardless of whether you're using TCP, Redis, or RabbitMQ underneath.
TCP is the simplest choice for services running on the same network or in the same Docker Compose setup. No broker needed, low latency, good for request-response. Start here unless you have a specific reason not to.
Redis, RabbitMQ, and Kafka are worth it when you need durability or fan-out. Messages survive a service restart, and you can have multiple consumers. Kafka adds partitioned, replayable log semantics on top of that. If you're already using RabbitMQ in your stack, the RabbitMQ RPC pattern in TypeScript post covers the underlying mechanics. For a refresher on how exchanges and queues fit together, RabbitMQ exchange vs queue is a good starting point.
gRPC is for when you need performance and a strict schema contract between services. More setup, but typed protobuf definitions across service boundaries.
Decision rule: use TCP to start. Switch to Redis, RabbitMQ, or Kafka if you need async durability or multiple consumers. Add gRPC if latency or schema enforcement becomes a real constraint.
The main pain point with multiple NestJS apps is sharing types. A simple NestJS monorepo layout that works:
apps/
inventory/ # the microservice
src/
main.ts
app.module.ts
inventory.controller.ts
orders/ # the client (calls inventory)
src/
main.ts
app.module.ts
orders.service.ts
libs/
shared/
inventory.dto.ts # shared request/response types
package.json # root with workspaces
With npm or pnpm workspaces, both apps can import from @app/shared without duplicating types. The NestJS CLI monorepo setup (nest generate app) handles the path aliases automatically, but you can wire it manually too.
The inventory service uses createMicroservice instead of create:
// apps/inventory/src/main.ts
import { NestFactory } from "@nestjs/core";
import { Transport, MicroserviceOptions } from "@nestjs/microservices";
import { AppModule } from "./app.module";
async function bootstrap() {
const app = await NestFactory.createMicroservice<MicroserviceOptions>(
AppModule,
{
transport: Transport.TCP,
options: {
host: "0.0.0.0",
port: 3001,
},
},
);
await app.listen();
}
bootstrap();The controller uses @MessagePattern instead of @Get or @Post:
// apps/inventory/src/inventory.controller.ts
import { Controller } from "@nestjs/common";
import { MessagePattern, Payload, EventPattern } from "@nestjs/microservices";
import { CheckStockDto, StockResult } from "@app/shared/inventory.dto";
@Controller()
export class InventoryController {
constructor(private readonly inventoryService: InventoryService) {}
// Request-response: caller waits for a reply
@MessagePattern("inventory.check")
checkStock(@Payload() dto: CheckStockDto): StockResult {
const inStock = this.inventoryService.check(dto.itemId);
return { itemId: dto.itemId, inStock };
}
// Fire-and-forget: caller doesn't wait
@EventPattern("inventory.invalidate-cache")
invalidateCache(@Payload() data: { itemId: string }) {
this.inventoryService.invalidateCache(data.itemId);
}
}@MessagePattern is for request-response. @EventPattern is for cases where you don't need a reply: logging, cache invalidation, async side effects.
The orders service stays a normal HTTP app, so it acts as the API gateway in front of the inventory microservice. It registers a ClientProxy pointing at that service:
// apps/orders/src/app.module.ts
import { Module } from "@nestjs/common";
import { ClientsModule, Transport } from "@nestjs/microservices";
import { OrdersService } from "./orders.service";
@Module({
imports: [
ClientsModule.register([
{
name: "INVENTORY_SERVICE",
transport: Transport.TCP,
options: { host: "inventory", port: 3001 },
},
]),
],
providers: [OrdersService],
})
export class AppModule {}Then inject and use it:
// apps/orders/src/orders.service.ts
import { Inject, Injectable } from "@nestjs/common";
import { ClientProxy } from "@nestjs/microservices";
import { firstValueFrom } from "rxjs";
import { CheckStockDto, StockResult } from "@app/shared/inventory.dto";
@Injectable()
export class OrdersService {
constructor(@Inject("INVENTORY_SERVICE") private client: ClientProxy) {}
async placeOrder(itemId: string) {
// send() returns an Observable - firstValueFrom turns it into a Promise
const result = await firstValueFrom(
this.client.send<StockResult, CheckStockDto>("inventory.check", {
itemId,
}),
);
if (!result.inStock) {
throw new Error(`Item ${itemId} is out of stock`);
}
// emit() is fire-and-forget, no response expected
this.client.emit("inventory.invalidate-cache", { itemId });
return this.createOrder(itemId);
}
}send() returns an Observable that emits once with the microservice response. emit() also returns an Observable but you don't subscribe, so it fires without waiting for a reply. Both methods are generic so you can type the payload and response.
Error propagation is where a two-service setup usually goes wrong.
When a microservice handler throws, NestJS serializes the error and sends it back over the transport. On the client side, firstValueFrom rejects with it. The catch: regular NestJS HTTP exceptions (BadRequestException, etc.) don't translate cleanly across a transport layer. They carry HTTP status codes that don't mean anything over TCP.
Use RpcException instead:
// apps/inventory/src/inventory.controller.ts
import { RpcException } from '@nestjs/microservices';
@MessagePattern('inventory.check')
checkStock(@Payload() dto: CheckStockDto): StockResult {
if (!dto.itemId) {
throw new RpcException({ message: 'itemId is required', code: 400 });
}
// ...
}On the client, catch and rethrow as needed:
import { catchError, throwError } from "rxjs";
const result = await firstValueFrom(
this.client
.send("inventory.check", { itemId })
.pipe(catchError((err) => throwError(() => new Error(err.message)))),
);The error object that comes back has whatever shape you passed to RpcException. Keep it consistent across your services: a shared error type in libs/shared saves headaches later.
Two NestJS apps running locally need a way to find each other. Docker Compose gives you one for free.
# docker-compose.yml
services:
inventory:
build:
context: .
dockerfile: apps/inventory/Dockerfile
ports:
- "3001:3001"
orders:
build:
context: .
dockerfile: apps/orders/Dockerfile
ports:
- "3000:3000"
depends_on:
- inventoryIn the orders service ClientsModule config, use host: 'inventory'. Docker Compose's internal DNS resolves service names automatically. If you're running without Docker, swap that for localhost. In production you'd read host and port from env vars rather than hardcoding them, and validating NestJS env vars with Zod covers that setup.
One thing to watch: TCP transport connects eagerly on startup. If orders starts before inventory is ready, it will fail to connect. depends_on helps but doesn't wait for the port to be open, so a small retry or a health check is worth adding in production.
For development without Docker, two terminals work fine:
# terminal 1
nest start inventory --watch
# terminal 2
nest start orders --watchStart inventory first.
For how to test a setup like this (mocking ClientProxy in unit tests, bootstrapping both services for e2e, and handling RpcException across the transport), the NestJS testing post covers the patterns.
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.