Skip to content

Microservices with NestJS

Aman Kumar Singh8 min read
Part 34 of 40From the NestJS Production Guide series
Microservices with NestJS — article by Aman Kumar Singh

Last time in the NestJS Production Guide, I covered The Outbox Pattern, which solves a specific problem: making sure a database write and the event it triggers commit together instead of drifting apart. That pattern only matters once you have more than one process that needs to agree on what happened. Microservices are where that becomes the default situation instead of the exception.

This article is about the actual decision to split a NestJS monolith into services, and the mechanics of doing it with @nestjs/microservices. I want to be direct about something up front: most teams that reach for microservices are solving an organizational problem with an architectural tool, and the architecture ends up costing more than the problem did. NestJS makes it genuinely easy to add a microservice transport to an existing app. That's exactly why it's worth being deliberate about when you actually need one.

When splitting into services actually pays for itself

A NestJS monolith is a set of modules running in one process, talking to each other through dependency injection. That's fast, transactionally safe by default, and trivial to debug: one stack trace covers the whole request. The moment you split a module out into its own deployable service, you trade all of that for independent deployability and independent scaling, at the cost of network calls, serialization, partial failure, and eventual consistency where you used to have a function call and a database transaction.

The honest reasons to make that trade are narrow. A module might have a genuinely different scaling profile from the rest of the app, say a video transcoding worker that needs to scale to twenty instances while your API stays at two. It might need a different deployment cadence or risk profile, such as a billing service that a smaller, more careful team owns and ships on its own schedule. Or it might need strong isolation, where a bug in one service should not be able to take down checkout or auth.

"Our codebase feels big" is not one of those reasons. A large NestJS monolith with clean module boundaries, each module owning its own services, repositories, and DTOs, solves most of the pain people associate with monoliths without any network calls at all. If your modules are already well-isolated internally, extracting one into a real microservice later is a mechanical exercise, not a rewrite. That's the sequencing I'd recommend: enforce module boundaries first, extract a service only when a specific module has a concrete reason to run somewhere else.

Setting up a NestJS microservice

NestJS's @nestjs/microservices package supports several transports: TCP, Redis, NATS, MQTT, gRPC, and Kafka among them. For a first microservice inside a stack that already runs Redis for caching and queues, Redis pub/sub is often the path of least resistance operationally, since you're not standing up a new piece of infrastructure just to get service-to-service messaging working. gRPC deserves its own treatment for its stronger contracts, and I'll cover it in the next article.

A NestJS app doesn't have to choose between serving HTTP and listening for microservice messages. NestFactory.create and connectMicroservice let a single process do both, which is a reasonable stepping stone: keep your existing REST or GraphQL API for clients, and add a microservice listener for internal service-to-service traffic.

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  app.connectMicroservice<MicroserviceOptions>({
    transport: Transport.REDIS,
    options: {
      host: process.env.REDIS_HOST ?? 'localhost',
      port: Number(process.env.REDIS_PORT ?? 6379),
    },
  });

  await app.startAllMicroservices();
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

On the consuming side, a service defines its dependency on another service through ClientsModule, which hands you a ClientProxy scoped to a token. Nothing here is different from injecting any other provider; NestJS keeps the dependency injection model consistent whether the thing on the other end is in-process or over the network.

// src/modules/notifications/notifications.module.ts
import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { NotificationsService } from './notifications.service';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'BILLING_SERVICE',
        transport: Transport.REDIS,
        options: {
          host: process.env.REDIS_HOST ?? 'localhost',
          port: Number(process.env.REDIS_PORT ?? 6379),
        },
      },
    ]),
  ],
  providers: [NotificationsService],
})
export class NotificationsModule {}

Message patterns: request-response and fire-and-forget

@nestjs/microservices gives you two distinct decorators for handling incoming messages, and picking the right one matters more than it looks. @MessagePattern is request-response: the caller sends a message and waits for a reply, backed by an RxJS Observable under the hood. @EventPattern is fire-and-forget: the caller emits, nobody waits, and there's no reply channel at all.

// src/modules/billing/billing.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, EventPattern, Payload } from '@nestjs/microservices';
import { BillingService } from './billing.service';

@Controller()
export class BillingController {
  constructor(private readonly billingService: BillingService) {}

  @MessagePattern({ cmd: 'get_invoice_total' })
  async getInvoiceTotal(@Payload() data: { invoiceId: string }) {
    return this.billingService.getInvoiceTotal(data.invoiceId);
  }

  @EventPattern('order.placed')
  async handleOrderPlaced(@Payload() data: { orderId: string; orgId: string }) {
    await this.billingService.createDraftInvoice(data.orderId, data.orgId);
  }
}

The client side mirrors that split. send() returns an observable you subscribe to (or convert with firstValueFrom) and expects a reply; emit() fires an event and returns immediately with no expectation of a response.

// src/modules/notifications/notifications.service.ts
import { Injectable, Inject } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';

@Injectable()
export class NotificationsService {
  constructor(
    @Inject('BILLING_SERVICE') private readonly billingClient: ClientProxy,
  ) {}

  async getInvoiceTotal(invoiceId: string): Promise<number> {
    return firstValueFrom(
      this.billingClient.send<number>({ cmd: 'get_invoice_total' }, { invoiceId }),
    );
  }

  notifyOrderPlaced(orderId: string, orgId: string): void {
    this.billingClient.emit('order.placed', { orderId, orgId });
  }
}

Use send() sparingly. Every request-response call between services is a synchronous dependency: if the billing service is slow or down, whatever called it is now slow or down too, and that chain can cascade across your whole system. Reach for emit() and event-based flows wherever the calling service doesn't actually need an immediate answer to keep working. A checkout flow that needs to know an invoice total before showing a confirmation page has a real reason to call synchronously. A checkout flow that just needs billing to eventually know an order happened doesn't.

Data consistency across service boundaries

Splitting a module into a service usually means splitting its database too, and that's where the real cost of microservices shows up. Inside a monolith, a single PostgreSQL transaction can update an order and decrement inventory atomically; if either fails, both roll back. Across two services with two databases, there's no shared transaction. You either accept eventual consistency and design for it, or you take on a distributed transaction pattern like sagas, which bring their own complexity.

This is exactly where the outbox pattern from the last article stops being optional and starts being load-bearing. If the order service updates its own table and then calls emit() to tell billing, a crash between those two steps leaves the order updated with billing never informed, and no record that it should have been. Writing the outbound event to an outbox table in the same transaction as the order update, then having a separate process deliver it, is what keeps that gap from opening up. Any real microservice architecture in NestJS should be paired with a reliable event delivery strategy. A fire-and-forget emit() alone doesn't count as one.

Idempotency matters just as much on the receiving end. A message broker or your own retry logic can deliver the same event twice, particularly under network failure. Every @EventPattern handler should be safe to run more than once for the same payload, typically by checking whether the operation it triggers has already been applied (an invoice already created for that order ID, for instance) before doing the work again.

Production pitfalls

The most common mistake is a shared database across services that are supposed to be independently deployable. Two NestJS services reading and writing the same PostgreSQL tables aren't independent at all. What you've actually built is a distributed monolith, only now with worse debugging and extra network hops. Each service should own its data, and other services should only reach it through that service's API or events, never through a direct connection to its tables.

The second is skipping contract versioning. A message shape like { cmd: 'get_invoice_total' } is an implicit API contract between two services, and it will change. Without a plan for versioning payloads, either a version field in the message or separate patterns per version, a deploy of the billing service can silently break every consumer that expects the old shape, and you won't find out until something fails in production.

The third is losing observability across the boundary. A request that used to be one stack trace is now a chain of messages across processes. Without distributed tracing (correlation IDs threaded through every message, or a proper tracing setup like OpenTelemetry), debugging a failure means grepping logs across several services by hand. Set up correlation IDs from day one of the split, not after the first incident that needed one.

The fourth is chatty services: splitting a module along a boundary that requires ten round trips to complete one user action. If your services need to call each other constantly to do anything useful, the boundary is probably in the wrong place. Good service boundaries tend to align with things that change together and are read together, not with arbitrary technical layers.

Key takeaways

  • Extract a microservice when a module has a genuinely different scaling profile, deployment cadence, or isolation requirement, not because the codebase feels large.
  • NestJS supports HTTP and a microservice transport in the same process via `connectMicroservice`, which is a reasonable stepping stone before a full split.
  • Use `@MessagePattern` and `send()` for request-response calls, and reserve them for cases where the caller genuinely needs an immediate answer.
  • Use `@EventPattern` and `emit()` for fire-and-forget notifications, and design every handler to be safe to run twice.
  • Pair any cross-service event flow with the outbox pattern; without it, a crash between a write and an event emission silently loses the event.
  • Watch for shared databases, unversioned message contracts, missing correlation IDs, and chatty service boundaries; these are the pitfalls that turn a clean split into a distributed monolith.

Frequently asked questions

Do I need Kafka or RabbitMQ to use NestJS microservices?

No. `@nestjs/microservices` supports Redis, TCP, NATS, MQTT, gRPC, and Kafka as transports. If you already run Redis for caching or queues, Redis pub/sub is a low-friction starting point for internal service-to-service messaging before you introduce a dedicated broker.

Can one NestJS app serve HTTP and listen for microservice messages at the same time?

Yes, using `app.connectMicroservice()` alongside `NestFactory.create()` and `app.startAllMicroservices()`. This hybrid setup lets you keep your existing REST or GraphQL API for clients while adding an internal transport for service-to-service calls.

What's the difference between @MessagePattern and @EventPattern?

`@MessagePattern` handles request-response messages sent with `send()`, where the caller waits for a reply. `@EventPattern` handles fire-and-forget messages sent with `emit()`, where there is no reply channel and the caller doesn't wait.

Should two microservices share the same PostgreSQL database?

No. Sharing a database across services that are supposed to be independently deployable removes the isolation you split them for and creates a distributed monolith. Each service should own its data and expose it only through its own API or events.

How do I keep an event from being lost if a service crashes mid-write?

Write the event to an outbox table in the same database transaction as the state change, then deliver it with a separate process. This is the outbox pattern, and it's the mechanism that makes `emit()` calls reliable instead of best-effort.

When should I NOT split a monolith into microservices?

When the motivation is codebase size or team preference rather than a concrete scaling, deployment, or isolation need. A well-modularized NestJS monolith with clean module boundaries solves most of that pain without the operational cost of a network boundary, and it's far easier to extract a service from it later than to merge services back together.

Related articles

Event-Driven Architecture — Aman Kumar Singh
Scheduling and Cron Tasks — Aman Kumar Singh
Background Jobs with BullMQ — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.