omiid
homenotebookai usage

NestJS microservices with a real two-service example

May 05, 2026 · Updated on August 09, 2026

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.

I write short, practical notes like this one. Get the next one by email:

Unsubscribe anytime.

Start with TCP and switch transports when you need durability

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.

A monorepo keeps the shared types in one place

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 server bootstraps as a microservice, not an HTTP app

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 client calls through a ClientProxy

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.

Throw RpcException, not HTTP exceptions

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.

Docker Compose resolves service names as hosts

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:
      - inventory

In 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 --watch

Start 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.

Join My Newsletter

Occasional notes on software, tools, and things I learn. No spam.

Unsubscribe anytime.

Continue Reading
  • Tuning Postgres and pgvector: the three knobs that matter08-18-2026 · Most pgvector performance problems come down to three settings. This post shows how to read an ANN query plan and tune ef_search, shared_buffers, and work_mem in the right order.
  • AI text watermarking: how it works and what it can't do08-16-2026 · Claude now watermarks its text. The watermark changes where the randomness in word choice comes from, not what the model can say. Here is the whole pipeline, with simulations you can poke at.
  • HNSW vs IVFFlat: choosing and building your pgvector index08-14-2026 · Past a few hundred thousand rows, an exact scan stops being fast enough. Here is how to pick between HNSW and IVFFlat and build the index without locking the table.
  • Vector search relevance: chunking, metadata, and the 0.81 problem08-11-2026 · Most bad vector search results come from one of three failure modes: chunking, modality mismatch, or a confused model. Each one has a specific diagnostic and a specific fix.
  • pgvector setup: your first multimodal query in TypeScript08-03-2026 · One Postgres table can hold text and screenshot embeddings in the same vector column. This post sets up the schema, the Voyage embedding call, and the first query that returns both.