---
title: "NestJS microservices with a real two-service example"
description: "ClientProxy, @MessagePattern, and transport config in a working setup: two NestJS microservices over TCP, send() vs emit(), errors, and Docker Compose for dev."
canonical: https://omiid.me/notebook/21/nestjs-microservices-with-a-real-two-service-example
author: Omid Sayfun
published: 2026-05-05
updated: 2026-08-09
---

# NestJS microservices with a real two-service example

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.

## 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](https://omiid.me/notebook/14/rabbitmq-rpc-pattern-in-typescript) post covers the underlying mechanics. For a refresher on how exchanges and queues fit together, [RabbitMQ exchange vs queue](https://omiid.me/notebook/33/rabbitmq-exchange-vs-queue-explained) 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`:

```typescript
// 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`:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
// 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:

```typescript
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 already provides one.

```yaml
# 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](https://omiid.me/notebook/38/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:

```bash
# 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](https://omiid.me/notebook/18/unit-and-e2e-testing-in-nestjs-with-jest-and-supertest) covers the patterns.
