NestJS has a built-in microservices module that's genuinely useful once you understand the mental model. The docs cover the API but leave a lot of questions unanswered - why use this instead of HTTP, which transport to pick, how to structure a project with two services, and what actually happens when something throws.
This post walks through a real two-service setup using TCP transport, then covers the parts most examples skip.
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 and RabbitMQ are worth it when you need durability or fan-out. Messages survive a service restart, and you can have multiple consumers. 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 or RabbitMQ 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 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 bootstraps as a microservice instead of an HTTP app:
Occasional notes on software, tools, and things I learn. No spam.
Unsubscribe anytime.
// 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 registers a ClientProxy pointing at the inventory 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 - it fires without waiting for a reply. Both methods are generic so you can type the payload and response.
This is the part that most examples gloss over.
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.
Running two NestJS apps locally and having them find each other is where most setups quietly break.
# 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 - 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 - adding a small retry or a health check is worth it 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.