Saikat
← Back to blog

Building Scalable Apps with NestJS Microservices: Patterns, Transports, and Deployment

July 15, 2026·7 min read
NestJSMicroservicesArchitectureBackend
Share:
Building Scalable Apps with NestJS Microservices: Patterns, Transports, and Deployment

When a NestJS monolith starts to creak under load — slow deploys, shared databases fighting over rows, teams stepping on each other — splitting it into microservices is usually the next conversation. But splitting for the sake of splitting is how you end up with a distributed monolith that's worse than what you started with. Here's the approach I've used on the last few projects where the split actually paid off.

When microservices actually make sense

Before reaching for @nestjs/microservices, I look for at least one of these signals:

  • Different scaling profiles. One part of the system (say, image processing) needs 10x the CPU of the rest. Scaling the entire monolith to handle that workload is wasteful.
  • Independent deploy cadence. A "reports" module ships weekly; a "checkout" module ships daily with strict change control. Co-deploying them is risky for the high-traffic one.
  • Team boundaries. Two teams are constantly merging conflicting changes into the same codebase. A service boundary gives them ownership.
  • Failure isolation. A bug in one subsystem shouldn't be able to take down the whole product.

If none of these apply, a well-modularized monolith is usually the better choice. Microservices add operational cost — observability, network failures, deployment, schema coordination — that you only want to pay for when the trade is worth it.

The transport layer

NestJS microservices communicate over a transport. The framework supports several, and the choice matters more than people think:

  • TCP — Lowest friction, great for internal services on the same network. No durability, no replay. My default for trusted, low-latency hops.
  • Redis (pub/sub or streams) — A small step up. Useful when services already share a Redis instance and you want lightweight broadcast or work-queue semantics.
  • NATS — My preferred choice for greenfield systems. Lightweight, has request/reply and pub/sub, supports JetStream for at-least-once delivery.
  • Kafka / RabbitMQ — When you genuinely need durable, replayable event streams (audit logs, financial events, integration with external systems). Heavier to operate, but worth it when you need the guarantees.

A common mistake is picking Kafka "just in case." If you're sending JSON messages between two services you own and you don't need replay, NATS will save you an entire ops dependency.

A real layout

Here's a layout I've shipped a few times:

┌────────────┐    ┌──────────────┐    ┌──────────────┐
│ API Gateway│───▶│ User Service │    │ Order Service│
└────────────┘    └──────────────┘    └──────────────┘
       │                  ▲                    ▲
       ▼                  │                    │
   [Clients]        ┌────┴────────────────────┴────┐
                    │  NATS message bus           │
                    └────┬───────────────────┬────┘
                         ▼                   ▼
                  ┌──────────────┐    ┌──────────────┐
                  │Payment Service│   │Notify Service│
                  └──────────────┘    └──────────────┘

The API gateway (a regular NestJS HTTP app) is the only thing clients talk to. Everything inside the boundary communicates via NATS. Each service owns its database — no shared schemas.

Defining a microservice

Switching a regular NestJS app into a microservice is small:

// main.ts in the user service
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { UserModule } from './user.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    UserModule,
    {
      transport: Transport.NATS,
      options: {
        servers: [process.env.NATS_URL ?? 'nats://localhost:4222'],
      },
    },
  );
  await app.listen();
}
bootstrap();

And the handler:

@Controller()
export class UserController {
  constructor(private readonly users: UserService) {}

  @MessagePattern({ cmd: 'get_user' })
  async getUser(@Payload() id: string) {
    return this.users.findById(id);
  }

  @EventPattern('user.created')
  async onUserCreated(@Payload() data: { id: string; email: string }) {
    // side effect: welcome email, audit log, etc.
    await this.users.indexForSearch(data);
  }
}

Note the distinction: @MessagePattern is request/reply (caller waits for a response), @EventPattern is fire-and-forget (the broker delivers it; nobody is listening for a reply). Picking the wrong one is a common source of bugs — using an event where you actually need a response means your caller has no way to know if the work happened.

The API gateway

The gateway is a normal @nestjs/core HTTP app whose only job is to translate HTTP requests into microservice messages:

@Controller('users')
export class UsersController {
  constructor(@Inject('USER_SERVICE') private readonly client: ClientProxy) {}

  @Get(':id')
  async getUser(@Param('id') id: string) {
    return firstValueFrom(
      this.client.send({ cmd: 'get_user' }, id),
    );
  }
}

The gateway also handles cross-cutting concerns you don't want to duplicate in every service:

  • Authentication / authorization — verify a JWT once, forward a sanitized user context downstream.
  • Rate limiting — per-IP, per-token, per-route.
  • Request tracing — generate a correlation ID and propagate it through NATS headers so logs across services can be stitched together.
  • Response shaping — hide internal field names from clients.

Patterns that age well

A few patterns I've come back to repeatedly:

Database per service. Each service owns its schema. If two services need to share data, they integrate via events (order.placed published by the order service, consumed by the notify and analytics services) — never by reaching into each other's tables. Cross-service joins at the application layer are a code smell.

Idempotent handlers. Networks drop messages. A duplicate NATS delivery shouldn't create two users or charge a card twice. Store a processed-message-ID set (Redis works fine for short windows) and short-circuit duplicates.

Versioned message contracts. Bump the command name (get_userget_user_v2) instead of silently changing the payload shape. Old senders keep working; you can roll out receivers gradually.

Saga for cross-service workflows. When a checkout touches Order, Payment, and Inventory, you don't want a single giant distributed transaction. Implement a saga: each step has a compensating action, and on partial failure you publish compensation events to undo what completed. Yes, it's more code than a SQL transaction — that's the price of decoupling.

Observability is not optional

Distributed systems fail in ways monoliths don't. You need:

  • Structured logs with correlation IDs flowing through every service.
  • Metrics for message queue depth, handler latency, and error rates per pattern/event.
  • Distributed tracing — OpenTelemetry works well here. A single NATS header carrying the trace context is enough to make a 5-service call debuggable.

Without these, the first incident will burn hours just figuring out which service is slow.

Deployment

The same monorepo Docker setup I wrote about earlier applies — one Dockerfile per service, Compose for local dev, and in production each service gets its own container behind a service mesh or a plain load balancer. Add healthchecks (a ping pattern is enough), and make sure your orchestrator waits for the broker to be healthy before starting consumers, otherwise you'll spend a morning debugging services that started before NATS did.

Was it worth it?

On the last project where I did this split — separating a payment service that had different scaling and compliance needs from the main app — the wins were concrete:

  • Payment deploys went from "blocked behind the main app's release train" to "ship whenever."
  • A bad payment release could be rolled back without touching the rest of the system.
  • The payment team got full ownership of their database schema and stopped waiting on migrations from another team.

That said, it added a NATS cluster to operate, a tracing pipeline to maintain, and roughly two weeks of yak-shaving before the split was actually cheaper than the monolith. Don't skip that cost analysis.

If you're staring at a NestJS monolith that's starting to hurt, start with the boundaries: identify a module with a different scaling or deploy profile, extract it into its own microservice behind a transport, and see how the rest of the system feels. You don't have to commit to a full split on day one.