Microservices Architecture & Patterns

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against NestJS 12.1 (npm 12.1.2), TypeScript 6 (6.0.3), Node.js 20.19+ or 22.12+, Express 5 and Fastify 5, and was checked on 2026-10-02. It uses @nestjs/microservices 12.1, @nestjs/config 12.0, @nestjs/typeorm 12.0 with TypeORM 1.1 and better-sqlite3, and RxJS 7.8. The examples build with the Nest 12 CLI and are tested with Vitest and Test.createTestingModule: the catalog, reviews and notifications services listen on real TCP sockets inside one process, with a gateway in front of them. The broker and registry parts compile and are exercised against stand-ins rather than a RabbitMQ broker or a Consul agent (see What the examples are tested against).

This page is about the system around the services. How a single service receives messages (@MessagePattern(), @EventPattern(), ClientProxy, context objects, serialization) is covered in Microservices Fundamentals, and each transport in detail in Microservice Transports. The language-independent theory (sagas, outbox, CQRS, event-driven design) lives in the architecture section; here it is applied to NestJS and to the running example, the original Bookshelf application.

The Bookshelf topology

By this point Bookshelf is a monolith with an HTTP API, a notifications module and a queue. This page splits it into four deployables: a gateway that is the only thing clients talk to, a catalog service that owns books and authors, a reviews service that owns ratings and comments, and a notifications service that sends email and digests. The services answer requests over a Nest transport (TCP in the examples), and the broker carries the events nobody waits for.

A diagram of the Bookshelf topology. Clients reach a gateway over HTTP. The gateway sends request-response messages to the catalog and reviews services and orchestrated saga steps to the notifications service. Each of the three services owns its own database. The reviews service emits a review.created event to a RabbitMQ queue, which delivers it to the notifications service; a dead-letter queue sits beside the broker queue

When to split, and when not to

A microservice architecture buys independent deployment, independent scaling and team autonomy, and charges for them with network failures, distributed data and operational cost. Whether that trade is worth it is an architecture decision, not a NestJS one: start from choosing a monolith or microservices, the benefits and drawbacks and, when you already have a monolith, the monolith to microservices migration path. Split along bounded contexts and team boundaries, not along technical layers; a "database service" or a "validation service" is a distributed monolith.

NestJS makes the cheap middle step easy. Because a microservice is "a Nest application with a different entry point", a module can be written once and exposed over HTTP and a transport at the same time with a hybrid application. Extracting it later means moving the module into its own main.ts. connectMicroservice() registers the handlers immediately, and the inheritAppConfig option makes the connected microservice use the global pipes, guards, interceptors and filters of the host application (call the useGlobal*() methods before connectMicroservice()).

A modular monolith that already answers on a transport
import { INestApplication, Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

// A modular monolith that already answers on a transport: extract it later by moving the module.
export async function startHybrid(module: Type<unknown>, httpPort: number, tcpPort: number): Promise<INestApplication> {
  const app = await NestFactory.create(module);
  app.connectMicroservice<MicroserviceOptions>(
    { transport: Transport.TCP, options: { host: '127.0.0.1', port: tcpPort } },
    { inheritAppConfig: true },
  );
  await app.startAllMicroservices();
  await app.listen(httpPort);
  return app;
}

The example ran: the CatalogModule listened on an HTTP port and on a TCP port in the same process, and a ClientProxy send() over TCP returned a book.

Monorepo: apps and shared libraries

A single repository with one Nest workspace is the default way to host these services. Each service is an application (its own main.ts, its own build, its own container image); code several services must share is a library under libs/. The workspace is created by generating the second and later projects with the CLI.

nest g app catalog
nest g app reviews
nest g app notifications
nest g lib contracts      # prompts for the alias prefix, default @app
nest build notifications  # builds one app plus the libraries it imports
nest start notifications

The CLI turns the project into a monorepo ("monorepo": true) and records every project in nest-cli.json, and registers each library as a path alias in tsconfig.json (@app/contracts). This is the nest-cli.json the commands above produced, with the gateway application as the default project:

nest-cli.json of the Bookshelf workspace, as generated
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "apps/gateway/src",
  "monorepo": true,
  "root": "apps/gateway",
  "compilerOptions": {
    "deleteOutDir": true,
    "builder": "tsc",
    "tsConfigPath": "apps/gateway/tsconfig.app.json"
  },
  "projects": {
    "catalog": {
      "type": "application",
      "root": "apps/catalog",
      "entryFile": "main",
      "sourceRoot": "apps/catalog/src",
      "compilerOptions": {
        "tsConfigPath": "apps/catalog/tsconfig.app.json"
      }
    },
    "contracts": {
      "type": "library",
      "root": "libs/contracts",
      "entryFile": "index",
      "sourceRoot": "libs/contracts/src",
      "compilerOptions": {
        "tsConfigPath": "libs/contracts/tsconfig.lib.json"
      }
    },
    "gateway": {
      "type": "application",
      "root": "apps/gateway",
      "entryFile": "main",
      "sourceRoot": "apps/gateway/src",
      "compilerOptions": {
        "tsConfigPath": "apps/gateway/tsconfig.app.json"
      }
    },
    "notifications": {
      "type": "application",
      "root": "apps/notifications",
      "entryFile": "main",
      "sourceRoot": "apps/notifications/src",
      "compilerOptions": {
        "tsConfigPath": "apps/notifications/tsconfig.app.json"
      }
    },
    "reviews": {
      "type": "application",
      "root": "apps/reviews",
      "entryFile": "main",
      "sourceRoot": "apps/reviews/src",
      "compilerOptions": {
        "tsConfigPath": "apps/reviews/tsconfig.app.json"
      }
    }
  }
}

What belongs in libs/, and what does not, decides whether the monorepo stays a set of services or turns into one program:

  • Share contracts, not behaviour. Pattern names, message and event types and error shapes are the API between services, so they live in one library (libs/contracts). Every service imports the same TypeScript types, and a change to a message breaks the build of every consumer.

  • Share infrastructure helpers. Logging, configuration schemas, health checks, the idempotent-consumer helper below.

  • Do not share domain code or entities. A Book entity in a shared library couples every service to one schema and defeats the database-per-service rule below.

  • Version messages deliberately. Services deploy independently, so for a while two versions of a message are in flight. Add fields, do not rename or remove them, and keep consumers tolerant of unknown properties.

libs/contracts: the only code the services share
// libs/contracts/src/contracts.ts: the only code the services share.
export const Patterns = {
  BookGet: { cmd: 'book.get' },
  BookCreate: { cmd: 'book.create' },
  BookDelete: { cmd: 'book.delete' },
  ReviewList: { cmd: 'review.list' },
  ReviewCreate: { cmd: 'review.create' },
  DigestSchedule: { cmd: 'digest.schedule' },
  DigestCancel: { cmd: 'digest.cancel' },
  ReviewCreated: 'review.created',
} as const;

export interface BookDto {
  id: string;
  title: string;
}

export interface ReviewCreatedEvent {
  eventId: string; // unique per business event, reused on every redelivery
  reviewId: string;
  bookId: string;
  rating: number;
}

export const CATALOG_CLIENT = 'CATALOG_CLIENT';
export const REVIEWS_CLIENT = 'REVIEWS_CLIENT';
export const NOTIFICATIONS_CLIENT = 'NOTIFICATIONS_CLIENT';

An application in the workspace imports the library through the alias and is otherwise an ordinary microservice. The notifications main.ts:

apps/notifications/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { Patterns } from '@app/contracts';
import { NotificationsModule } from './notifications.module.js';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(NotificationsModule, {
    transport: Transport.TCP,
    options: { host: '0.0.0.0', port: Number(process.env.PORT ?? 3002) },
  });
  app.enableShutdownHooks();
  await app.listen();
  console.log(Object.keys(Patterns).length);
}
await bootstrap();

nest build notifications compiled the application together with @app/contracts, and running the output started the microservice and printed the pattern count. The CommonJS and ESM templates differ only in the .js import extensions the ESM template needs (relative imports in the examples carry them; path aliases do not). Workspace builders, --all and --parallel are covered in Project Structure & Workspaces.

Database per service

A service owns its data and nobody else reads or writes it. Other services get it by asking the owner (a message), or by keeping their own copy built from the owner’s events. A shared schema is the fastest way to a distributed monolith: any table change becomes a cross-team release, and any service can bypass the rules the owner enforces. See the microservices.io pattern database per service for the trade-offs.

In practice "database per service" means a separate database or at least a separate schema with a separate role, and one connection configuration per service, read from that service’s own environment:

Each service wires exactly one database
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';

// Each service wires exactly one database, and it is the only service that holds credentials for it.
@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    TypeOrmModule.forRootAsync({
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        type: 'better-sqlite3' as const, // 'postgres' in production, with config.getOrThrow('NOTIFICATIONS_DATABASE_URL')
        database: config.getOrThrow<string>('NOTIFICATIONS_DB_FILE'),
      }),
    }),
  ],
})
export class NotificationsDatabaseModule {}

Source: NestJS documentation: TypeORM and NestJS documentation: Configuration. The page on TypeORM covers the module; the same rule applies with Mongoose, Prisma or Drizzle.

Three consequences follow, and each has a standard answer:

  • No joins across services. The gateway composes the response (next section), or the service that needs the data keeps a read model fed by events.

  • No cross-service transactions. Use a saga with compensation (below) instead of a distributed transaction; see why not distributed transactions.

  • Environment values are strings. ConfigService.getOrThrow<number>('PORT') only changes the static type; the value read from the environment is still a string, and createMicroservice() rejects a string port at listen(). The example run hit exactly that, so the code below converts with Number(…​) or validates the configuration with a schema.

An API gateway in Nest

Clients should not know how many services exist, where they run or which protocol they speak. A gateway is the single entry point: it authenticates, applies rate limits, routes, composes responses from several services and translates failures to HTTP. A Nest application is a natural gateway because it already has guards, pipes, interceptors and filters for those cross-cutting concerns (see the request lifecycle); the only new part is that its providers call ClientProxy instances instead of repositories.

First, the clients. The ServiceClientsModule registers one ClientProxy per service from configuration, and every gateway and service module that needs to call another service imports it.

Typed client registrations driven by ConfigService
import { Global, Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { CATALOG_CLIENT, NOTIFICATIONS_CLIENT, REVIEWS_CLIENT } from './contracts.js';

function tcpClient(name: string, prefix: string) {
  return {
    imports: [ConfigModule],
    name,
    inject: [ConfigService],
    useFactory: (config: ConfigService) => ({
      transport: Transport.TCP as const,
      options: {
        // Kubernetes: 'catalog.bookshelf.svc.cluster.local'; Compose: 'catalog'
        host: config.getOrThrow<string>(`${prefix}_HOST`),
        port: Number(config.getOrThrow<string>(`${prefix}_PORT`)), // env values are strings
      },
    }),
  };
}

@Global()
@Module({
  imports: [
    ClientsModule.registerAsync([
      tcpClient(CATALOG_CLIENT, 'CATALOG'),
      tcpClient(REVIEWS_CLIENT, 'REVIEWS'),
      tcpClient(NOTIFICATIONS_CLIENT, 'NOTIFICATIONS'),
    ]),
  ],
  exports: [ClientsModule],
})
export class ServiceClientsModule {}

Then the controller. It calls the catalog and reviews services in parallel, puts a timeout on every call, and maps what comes back into HTTP semantics.

apps/gateway: composition, timeouts and error mapping
import {
  BadGatewayException,
  Body,
  Controller,
  Get,
  GatewayTimeoutException,
  Inject,
  InternalServerErrorException,
  NotFoundException,
  Param,
  Post,
  ServiceUnavailableException,
} from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, TimeoutError, timeout } from 'rxjs';
import { BookDto, CATALOG_CLIENT, Patterns, REVIEWS_CLIENT } from './contracts.js';
import { PublishBookSaga } from './publish-book.saga.js';
import { SagaFailedError } from './saga.js';

@Controller()
export class GatewayController {
  constructor(
    @Inject(CATALOG_CLIENT) private readonly catalog: ClientProxy,
    @Inject(REVIEWS_CLIENT) private readonly reviews: ClientProxy,
    private readonly publishBook: PublishBookSaga,
  ) {}

  /** One round trip per service, in parallel, translated into HTTP semantics. */
  @Get('books/:id')
  async getBook(@Param('id') id: string) {
    const [book, reviews] = await Promise.all([
      this.call<BookDto>(this.catalog, Patterns.BookGet, { id }),
      this.call<unknown[]>(this.reviews, Patterns.ReviewList, { bookId: id }),
    ]);
    return { ...book, reviews };
  }

  @Post('reviews')
  addReview(@Body() body: { bookId: string; rating: number }) {
    return this.call(this.reviews, Patterns.ReviewCreate, body);
  }

  @Post('books')
  async publish(@Body() { title }: { title: string }) {
    try {
      return { id: await this.publishBook.publish(title) };
    } catch (error) {
      if (error instanceof SagaFailedError) {
        throw new ServiceUnavailableException({ failedStep: error.failedStep, compensated: error.compensated });
      }
      throw error;
    }
  }

  private async call<T>(client: ClientProxy, pattern: object, data: unknown): Promise<T> {
    try {
      return await firstValueFrom(client.send<T>(pattern, data).pipe(timeout(2000)));
    } catch (error) {
      if (error instanceof TimeoutError) throw new GatewayTimeoutException();
      const status = (error as { status?: number }).status;
      if (status === 404) throw new NotFoundException((error as { message?: string }).message);
      if (typeof status === 'number') throw new BadGatewayException();
      throw new InternalServerErrorException();
    }
  }
}

The services behind it are plain controllers. The catalog service is the owner of book data and throws RpcException with an object, which Nest sends back as is:

apps/catalog: the owner of book data
import { Controller, Module } from '@nestjs/common';
import { MessagePattern, Payload, RpcException } from '@nestjs/microservices';
import { randomUUID } from 'node:crypto';
import { type BookDto, Patterns } from './contracts.js';

@Controller()
export class CatalogController {
  // The catalog owns this data: no other service reads it directly.
  private readonly books = new Map<string, BookDto>([['b1', { id: 'b1', title: 'Dune' }]]);

  @MessagePattern(Patterns.BookGet)
  get(@Payload() { id }: { id: string }): BookDto {
    const book = this.books.get(id);
    if (!book) throw new RpcException({ status: 404, message: `Book ${id} not found` });
    return book;
  }

  @MessagePattern(Patterns.BookCreate)
  create(@Payload() { title }: { title: string }): BookDto {
    const book = { id: randomUUID(), title };
    this.books.set(book.id, book);
    return book;
  }

  @MessagePattern(Patterns.BookDelete)
  delete(@Payload() { id }: { id: string }): { deleted: boolean } {
    return { deleted: this.books.delete(id) };
  }

  size(): number {
    return this.books.size;
  }
}

@Module({ controllers: [CatalogController] })
export class CatalogModule {}

With the three services on TCP behind the gateway, the equivalent requests answer:

curl -s localhost:3000/books/b1
curl -s -o /dev/null -w '%{http_code}\n' localhost:3000/books/nope
{"id":"b1","title":"Dune","reviews":[{"id":"r1","bookId":"b1","rating":5}]}

The second call returned 404 with {"message":"Book nope not found","error":"Not Found","statusCode":404}.

Points the example depends on:

  • send() is cold. The message is only sent when the observable is subscribed to. Use firstValueFrom() (or lastValueFrom()) in request handlers, and subscribe explicitly to emit() when you do not need the result, otherwise nothing is sent.

  • Always apply timeout(). Without it a request to a service that is down waits for the transport’s own failure handling. A TimeoutError becomes 504 here, and a downstream failure 502.

  • The error on the client is the object the server threw. An RpcException carrying an object arrives as that object, so the gateway can read a status and translate it. A thrown plain Error is reported as a generic internal server error; agree on an error shape in the contracts library.

  • Express or Fastify makes no difference to the gateway logic. The HTTP adapter only affects the HTTP edge; the ClientProxy code is identical. The gateway is an HTTP application like any other: apply the guards, throttling and security headers from the authentication, rate limiting and security headers pages here, once, instead of in every service.

  • Services still authorize. Network position is not authentication. Propagate the caller’s identity (for example a verified user id in the message payload) and check it in the service; the microservice layer has its own guards, pipes, interceptors, filters and pre-request hooks.

Whether the gateway should be a Nest application at all is a separate decision. A Nest gateway is the right place for composition and business-aware logic (the BFF idea in API gateway and BFF). Routing, TLS, rate limiting by client and authentication of third-party tokens are better served by a dedicated gateway or ingress (Traefik, Kong, a cloud gateway, the Kubernetes Gateway API) in front of the Nest gateway; the two often coexist. A dedicated API-gateway section for the whole documentation site is tracked in issue #184 and is not repeated here.

Service discovery: Kubernetes DNS or Consul

A service has to find the others. There are two common answers, and for most Nest deployments only the first is needed.

DNS with an orchestrator. On Kubernetes every Service gets a stable DNS name (catalog.bookshelf.svc.cluster.local, or just catalog inside the same namespace), and the platform load-balances and removes unready pods; Docker Compose gives the same by service name. The application only needs the hostname from configuration, which is what ServiceClientsModule above reads (CATALOG_HOST, CATALOG_PORT). See DNS and service discovery and Services. Note that a ClusterIP Service balances connections, not requests: a long-lived TCP connection from one gateway pod stays on one catalog pod, so run several gateway replicas and keep connection counts in mind, or use a headless Service and client-side balancing.

A registry such as Consul. Outside an orchestrator (virtual machines, mixed environments) each instance registers itself with its address and a health check and clients ask the registry for healthy instances. Nest has no first-party Consul module; the agent is plain HTTP, so registration is a provider with lifecycle hooks (see lifecycle events and shutdown):

Registering and deregistering with the Consul agent
import { Injectable, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';

/** Registers this instance in Consul's agent HTTP API; Kubernetes needs none of this. */
@Injectable()
export class ConsulRegistration implements OnApplicationBootstrap, OnApplicationShutdown {
  private readonly id: string;

  constructor(private readonly config: ConfigService) {
    this.id = `${config.getOrThrow<string>('SERVICE_NAME')}-${config.getOrThrow<string>('SERVICE_INSTANCE')}`;
  }

  async onApplicationBootstrap(): Promise<void> {
    const consul = this.config.getOrThrow<string>('CONSUL_URL');
    const port = Number(this.config.getOrThrow<string>('SERVICE_PORT'));
    const res = await fetch(`${consul}/v1/agent/service/register`, {
      method: 'PUT',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({
        ID: this.id,
        Name: this.config.getOrThrow<string>('SERVICE_NAME'),
        Address: this.config.getOrThrow<string>('SERVICE_HOST'),
        Port: port,
        Check: { TCP: `${this.config.getOrThrow<string>('SERVICE_HOST')}:${port}`, Interval: '10s', DeregisterCriticalServiceAfter: '1m' },
      }),
    });
    if (!res.ok) throw new Error(`Consul registration failed: ${res.status}`);
  }

  async onApplicationShutdown(): Promise<void> {
    const consul = this.config.getOrThrow<string>('CONSUL_URL');
    await fetch(`${consul}/v1/agent/service/deregister/${this.id}`, { method: 'PUT' });
  }
}

Source: NestJS documentation: Lifecycle events and Consul: Agent service API. Remember app.enableShutdownHooks() (the notifications main.ts above does), or onApplicationShutdown() never runs and the instance stays registered until its health check fails.

The class makes two calls over its lifetime, PUT /v1/agent/service/register at startup and PUT /v1/agent/service/deregister/catalog-a1 at shutdown; the listing is exercised against a local HTTP server standing in for Consul, not against a real Consul agent. A client then resolves healthy instances from the registry and builds its ClientProxy options (ClientProxyFactory.create()) from the answer.

Table 1. Choosing a discovery mechanism
Aspect Kubernetes DNS (or Compose) Consul

Registration

Automatic, by the platform

Each instance registers and deregisters itself

Health

Readiness probes remove endpoints

Registry health checks, with deregistration after critical state

Client code

A hostname from configuration

A registry lookup, or a Consul DNS name

Extra moving parts

None

A Consul cluster to operate

Use when

You run on Kubernetes or Compose

You do not have an orchestrator, or you already run Consul

Choreography and orchestration

When one business operation spans services, somebody has to coordinate it. There are two styles, and a system usually has both.

Choreography: each service reacts to events and emits its own; nobody holds the whole flow. In Bookshelf, the reviews service stores a review and announces the fact; the notifications service (and, later, a ratings service) reacts without the reviews service knowing they exist.

apps/reviews: emit a fact, do not command
import { Controller, Inject, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { ClientProxy, MessagePattern, Payload } from '@nestjs/microservices';
import { randomUUID } from 'node:crypto';
import { NOTIFICATIONS_CLIENT, Patterns, ReviewCreatedEvent } from './contracts.js';
import { ServiceClientsModule } from './service-clients.module.js';

interface Review {
  id: string;
  bookId: string;
  rating: number;
}

@Controller()
export class ReviewsController {
  private readonly reviews: Review[] = [{ id: 'r1', bookId: 'b1', rating: 5 }];

  constructor(@Inject(NOTIFICATIONS_CLIENT) private readonly bus: ClientProxy) {}

  @MessagePattern(Patterns.ReviewList)
  list(@Payload() { bookId }: { bookId: string }): Review[] {
    return this.reviews.filter((r) => r.bookId === bookId);
  }

  @MessagePattern(Patterns.ReviewCreate)
  create(@Payload() { bookId, rating }: { bookId: string; rating: number }): Review {
    const review = { id: randomUUID(), bookId, rating };
    this.reviews.push(review);
    // Choreography: announce the fact, do not tell anyone what to do about it.
    const event: ReviewCreatedEvent = { eventId: randomUUID(), reviewId: review.id, bookId, rating };
    this.bus.emit(Patterns.ReviewCreated, event).subscribe();
    return review;
  }
}

@Module({ imports: [ConfigModule.forRoot({ isGlobal: true }), ServiceClientsModule], controllers: [ReviewsController] })
export class ReviewsModule {}

Orchestration: one component (the orchestrator) tells each participant what to do and decides what happens next, including on failure. The gateway’s POST /books below is orchestrated: create the book, then schedule a digest, and undo the first step if the second fails.

Table 2. Choreography against orchestration
Aspect Choreography Orchestration

Coupling

Producers know nothing about consumers

The orchestrator knows every participant

Flow visibility

Spread across services; hard to see end to end

In one place

Adding a step

Add a subscriber, change nothing else

Change the orchestrator

Failure handling

Each service compensates through more events

The orchestrator runs the compensations

Good for

Notifications, projections, fan-out, "tell whoever cares"

Multi-step business transactions with a clear owner

Typical risk

Hidden cycles and an implicit workflow nobody owns

A god service that knows too much

Use events for facts that already happened and commands for things you need done. Prefer choreography for side effects and orchestration for a transaction that must either complete or be undone.

Sagas with compensation

Without a distributed transaction, a multi-service operation is a saga: a sequence of local transactions, each with a compensating action that semantically undoes it. The theory (and its limits) is in sagas. A minimal orchestrator needs only an ordered list of steps and the rule "on failure, compensate completed steps in reverse".

A small saga runner
export interface SagaStep<C> {
  name: string;
  invoke(context: C): Promise<void>;
  compensate?(context: C): Promise<void>;
}

export class SagaFailedError extends Error {
  constructor(
    readonly failedStep: string,
    readonly compensated: string[],
    readonly compensationFailures: string[],
    cause: unknown,
  ) {
    super(`Saga failed at "${failedStep}"`, { cause });
  }
}

/** Orchestrated saga: run steps in order, on failure compensate completed steps in reverse. */
export async function runSaga<C>(steps: SagaStep<C>[], context: C): Promise<void> {
  const done: SagaStep<C>[] = [];
  for (const step of steps) {
    try {
      await step.invoke(context);
      done.push(step);
    } catch (error) {
      const compensated: string[] = [];
      const failures: string[] = [];
      for (const completed of done.reverse()) {
        if (!completed.compensate) continue;
        try {
          await completed.compensate(context);
          compensated.push(completed.name);
        } catch {
          failures.push(completed.name); // park it for a retry queue or a human
        }
      }
      throw new SagaFailedError(step.name, compensated, failures, error);
    }
  }
}

Publishing a book in Bookshelf is two steps: create the book in the catalog, then schedule the author digest in notifications. If the second step fails the catalog entry is deleted.

apps/gateway: the publish-book saga
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
import { BookDto, CATALOG_CLIENT, NOTIFICATIONS_CLIENT, Patterns } from './contracts.js';
import { runSaga, SagaStep } from './saga.js';

interface PublishContext {
  title: string;
  bookId?: string;
}

@Injectable()
export class PublishBookSaga {
  constructor(
    @Inject(CATALOG_CLIENT) private readonly catalog: ClientProxy,
    @Inject(NOTIFICATIONS_CLIENT) private readonly notifications: ClientProxy,
  ) {}

  private call<T>(client: ClientProxy, pattern: object, data: unknown): Promise<T> {
    return firstValueFrom(client.send<T>(pattern, data).pipe(timeout(2000)));
  }

  async publish(title: string): Promise<string> {
    const context: PublishContext = { title };
    const steps: SagaStep<PublishContext>[] = [
      {
        name: 'create-book',
        invoke: async (c) => {
          c.bookId = (await this.call<BookDto>(this.catalog, Patterns.BookCreate, { title: c.title })).id;
        },
        compensate: async (c) => {
          await this.call(this.catalog, Patterns.BookDelete, { id: c.bookId });
        },
      },
      {
        name: 'schedule-digest',
        invoke: async (c) => {
          await this.call(this.notifications, Patterns.DigestSchedule, { bookId: c.title === 'Boom' ? 'fail' : c.bookId });
        },
        compensate: async (c) => {
          await this.call(this.notifications, Patterns.DigestCancel, { bookId: c.bookId });
        },
      },
    ];
    await runSaga(steps, context);
    return context.bookId!;
  }
}

The notifications service handles the digest commands (its full controller is in the notifications section below). POST /books with the title Emma returns 201 and a book id; with the title Boom the digest step fails, the saga deletes the book it has created and the gateway answers 503:

{"failedStep":"schedule-digest","compensated":["create-book"]}

When a compensation itself fails, the saga reports it in compensationFailures instead of hiding it.

What this runner is not, and what to add before you rely on it:

  • Compensations must be idempotent and retryable. They can be called twice, or after a timeout where the original step actually succeeded. Deleting an already deleted book must succeed.

  • The orchestrator can crash mid-saga. This runner keeps its state in memory, so a restart loses it. Persist saga state (and publish the commands reliably with the transactional outbox, see transactional outbox) or use a durable workflow engine: Outbox & Durable Workflows covers @nestjs/outbox and @nestjs/workflows, both pre-1.0.

  • A timeout is not a failure. timeout() only stops waiting; the participant may still finish. This is why compensations and handlers need idempotency keys.

  • @nestjs/cqrs sagas are not distributed sagas. The @Saga() decorator in CQRS reacts to events in one process; durable sagas arrived in @nestjs/cqrs 12.1, but neither coordinates several services by itself.

Dead-letter queues

At-least-once delivery means a message that cannot be processed will be delivered again, and a "poison" message that always fails would block or loop forever. A dead-letter queue (DLQ) is where such messages go after a bounded number of attempts so a human or a repair job can look at them. How you get one depends on the broker; Nest gives you the consumer side.

On RabbitMQ the DLQ is a broker feature: a queue declared with a dead-letter exchange sends rejected (nack without requeue), expired and over-limit messages there. The Nest transport needs manual acknowledgement (noAck: false) for that, and the queue arguments pass straight to the broker:

RabbitMQ options for a durable queue with a dead-letter exchange
import { ConfigService } from '@nestjs/config';
import { RmqOptions, Transport } from '@nestjs/microservices';

export function notificationsRmqOptions(config: ConfigService): RmqOptions {
  return {
    transport: Transport.RMQ,
    options: {
      urls: [config.getOrThrow<string>('RABBITMQ_URL')],
      queue: 'notifications',
      noAck: false, // manual ack: required for dead-lettering
      prefetchCount: 10,
      persistent: true,
      queueOptions: {
        durable: true,
        arguments: {
          'x-dead-letter-exchange': 'bookshelf.dlx',
          'x-dead-letter-routing-key': 'notifications.dead',
        },
      },
    },
  };
}

The consumer acknowledges what it processed and rejects without requeue what it cannot. This handler is the second half of the notifications controller shown in full later:

Ack on success, nack without requeue on failure
  @EventPattern<string>('review.moderated')
  async onModerated(@Payload() event: ReviewCreatedEvent, @Ctx() context: RmqContext) {
    const channel = context.getChannelRef();
    const message = context.getMessage();
    try {
      if (event.rating < 1) throw new Error('poison message');
      this.mailer.send('author', `Review ${event.reviewId} moderated`);
      channel.ack(message);
    } catch {
      channel.nack(message, false, false); // requeue = false -> dead-letter exchange
    }
  }

Pitfalls:

  • Nest does not declare the dead-letter exchange or the DLQ. Declare bookshelf.dlx, the dead-letter queue and the binding for the routing key outside the application (broker definitions, infrastructure as code); otherwise dead-lettered messages are silently dropped.

  • nack with requeue loops. Requeueing a message that fails deterministically redelivers it forever. Reject without requeue, or use a quorum queue with a delivery limit so the broker dead-letters after N attempts.

  • Typing. With @Ctx() on an @EventPattern() handler, the typed overload of the decorator expects a handler whose extra arguments are unknown, which a typed context breaks at compile time. @EventPattern<string>('…​') selects the untyped overload and compiles.

  • Monitor the DLQ. A DLQ nobody reads is a data-loss mechanism with extra steps. Alert on its depth and keep the original headers so a message can be replayed.

Other transports have no native DLQ: with Kafka the convention is to publish the failed record, with the error, to a .dlq topic after a bounded number of retries (see Kafka consumers and delivery guarantees); with NATS JetStream or BullMQ the retry and dead-letter behaviour belongs to the stream or queue configuration. The transport differences are in Microservice Transports, and BullMQ job retries in Events, Scheduling & Queues. The ack/nack logic can be unit-tested without a broker by building an RmqContext by hand around a fake channel (new RmqContext([message, channel, pattern])); dead-lettering itself only happens on a real broker.

Idempotent consumers

Because delivery is at least once (and a producer may retry), every consumer must tolerate duplicates. The standard defence is to record the id of each processed message and skip repeats: see microservices.io, idempotent consumer. The id must come from the producer and stay the same on every redelivery (the eventId in the contracts library), not be generated by the consumer.

A processed-messages table with an atomic first-time check
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';

@Injectable()
export class ProcessedMessages {
  constructor(private readonly dataSource: DataSource) {}

  async init(): Promise<void> {
    await this.dataSource.query(
      `CREATE TABLE IF NOT EXISTS processed_messages (
         consumer TEXT NOT NULL,
         message_id TEXT NOT NULL,
         PRIMARY KEY (consumer, message_id)
       )`,
    );
  }

  /** Resolves true for the first caller of a (consumer, message) pair, false afterwards. */
  async firstTime(consumer: string, messageId: string): Promise<boolean> {
    // Named parameters, rewritten by the driver into its own placeholders: `?` for SQLite, `$1, $2` for PostgreSQL.
    const [sql, parameters] = this.dataSource.driver.escapeQueryWithParameters(
      `INSERT INTO processed_messages (consumer, message_id) VALUES (:consumer, :messageId)
       ON CONFLICT DO NOTHING RETURNING message_id`,
      { consumer, messageId },
    );
    const rows: unknown[] = await this.dataSource.query(sql, parameters);
    return rows.length === 1;
  }
}

INSERT …​ ON CONFLICT DO NOTHING RETURNING returns a row only for the first caller, so two concurrent deliveries cannot both win; the composite key (consumer, message_id) lets several consumers of the same event keep independent records. The statement runs on SQLite 3.35+ and PostgreSQL, but the placeholder syntax of a raw query() does not travel: the better-sqlite3 driver accepts only ? (with $1, $2 and an array it fails with Too many parameter values were provided), and PostgreSQL accepts only $1, $2 (? is a syntax error). driver.escapeQueryWithParameters() takes TypeORM’s :name parameters and produces the right form for the configured database, so the same class works on both. Using it in the handler is one if:

The notifications controller: duplicates are skipped
import { Controller, Injectable, Logger, Module, OnModuleInit } from '@nestjs/common';
import {
  BaseRpcContext,
  Ctx,
  EventPattern,
  MessagePattern,
  Payload,
  RmqContext,
  RpcException,
} from '@nestjs/microservices';
import { ConfigModule } from '@nestjs/config';
import { DataSource } from 'typeorm';
import { Patterns, type ReviewCreatedEvent } from './contracts.js';
import { ProcessedMessages } from './processed-messages.js';

// With noAck: false on RabbitMQ every message must be acked or nacked, or it stays unacknowledged and the
// prefetch window fills up. Over TCP (tests, local runs) there is nothing to acknowledge, so both are no-ops.
function ack(context: BaseRpcContext): void {
  if (context instanceof RmqContext) context.getChannelRef().ack(context.getMessage());
}

function deadLetter(context: BaseRpcContext): void {
  // requeue = false -> the queue's dead-letter exchange
  if (context instanceof RmqContext) context.getChannelRef().nack(context.getMessage(), false, false);
}

@Injectable()
export class Mailer {
  readonly sent: string[] = [];
  send(to: string, subject: string): void {
    this.sent.push(`${to}: ${subject}`);
  }
}

@Controller()
export class NotificationsController implements OnModuleInit {
  private readonly logger = new Logger(NotificationsController.name);
  readonly digests = new Set<string>();

  constructor(
    private readonly processed: ProcessedMessages,
    private readonly mailer: Mailer,
  ) {}

  onModuleInit() {
    return this.processed.init();
  }

  // Idempotent consumer: at-least-once delivery + a dedupe key = effectively-once effects.
  @EventPattern<string>(Patterns.ReviewCreated)
  async onReviewCreated(@Payload() event: ReviewCreatedEvent, @Ctx() context: BaseRpcContext): Promise<void> {
    try {
      if (!(await this.processed.firstTime('notifications', event.eventId))) {
        this.logger.log(`Skipping duplicate ${event.eventId}`);
        ack(context); // a skipped duplicate is still consumed, or the broker redelivers it again
        return;
      }
      this.mailer.send('author', `New ${event.rating}-star review on ${event.bookId}`);
      ack(context);
    } catch (error) {
      deadLetter(context);
      throw error;
    }
  }

  // Dead-letter handling on RabbitMQ: reject without requeue so the broker routes the message.
  @EventPattern<string>('review.moderated')
  async onModerated(@Payload() event: ReviewCreatedEvent, @Ctx() context: RmqContext) {
    const channel = context.getChannelRef();
    const message = context.getMessage();
    try {
      if (event.rating < 1) throw new Error('poison message');
      this.mailer.send('author', `Review ${event.reviewId} moderated`);
      channel.ack(message);
    } catch {
      channel.nack(message, false, false); // requeue = false -> dead-letter exchange
    }
  }

  // Request-response commands: the reply (a result or an RpcException) is the outcome the saga
  // waits for, so the request is acked either way instead of being dead-lettered.
  @MessagePattern(Patterns.DigestSchedule)
  schedule(@Payload() { bookId }: { bookId: string }, @Ctx() context: BaseRpcContext): { scheduled: string } {
    try {
      if (bookId === 'fail') throw new RpcException({ status: 503, message: 'mail provider down' });
      this.digests.add(bookId);
      return { scheduled: bookId };
    } finally {
      ack(context);
    }
  }

  @MessagePattern(Patterns.DigestCancel)
  cancel(@Payload() { bookId }: { bookId: string }, @Ctx() context: BaseRpcContext): { cancelled: boolean } {
    try {
      return { cancelled: this.digests.delete(bookId) };
    } finally {
      ack(context);
    }
  }
}

@Module({
  imports: [ConfigModule.forRoot({ isGlobal: true })],
  providers: [
    Mailer,
    ProcessedMessages,
    { provide: DataSource, useFactory: async () => new DataSource({ type: 'better-sqlite3', database: ':memory:' }).initialize() },
  ],
  controllers: [NotificationsController],
})
export class NotificationsModule {}

POST /reviews produces exactly one email in the notifications service, and delivering the same eventId twice produces one more, not two; both deliveries are acknowledged.

The controller runs unchanged on both transports: in tests and local runs the services talk TCP, where @Ctx() resolves to a TcpContext and there is nothing to acknowledge, and in production the queue is RabbitMQ with noAck: false, where every handler must ack (or nack) the message it received. The ack() and deadLetter() helpers check for RmqContext so that this stays explicit. On RabbitMQ:

  • onReviewCreated acks after the email is sent, and also acks a duplicate it skips; an unacknowledged duplicate stays on the queue and comes back when the connection drops. A failure nacks without requeue, like onModerated.

  • schedule and cancel ack in finally, whether they return or throw: for request-response the reply, success or RpcException, is the result the saga acts on.

  • onModerated is RabbitMQ-only and takes RmqContext directly.

Pitfalls:

  • Mark and act atomically when you can. This example marks first and acts second, so a crash between the two loses the email. When the effect is a database write, insert the processed id in the same transaction as the effect, so both happen or neither does. For effects outside your database (email, HTTP calls), prefer an idempotency key on the external call.

  • Keep ids long enough. A processed-messages table grows; expire rows older than the longest possible redelivery or replay window.

  • Idempotent handlers are cheaper than dedupe tables when they can be. "Set the status to published" is naturally repeatable; "send an email" is not.

  • Same idea at the HTTP edge. The official @nestjs/idempotency package (pre-1.0) offers an @Idempotent() decorator for request idempotency keys; see Resilience, Idempotency & Locks.

The Bookshelf notifications service

The notifications service is the smallest complete example of the pieces above: it owns a database (processed messages, digests), consumes events idempotently, answers two commands for the saga, and uses manual acknowledgement on its broker queue. The module and controller are the listing in the previous section; two more pieces are the module’s wiring and the bootstrap.

In the examples the module registers a TypeORM DataSource with a factory provider so the controller can run raw SQL against its own database; a real service would use TypeOrmModule as in the database section. The service is started in two ways. In tests and local runs it listens on TCP, which is how the gateway reached it:

Starting a service on TCP (used by the e2e tests)
import { INestMicroservice, Type } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

// apps/notifications/src/main.ts, parameterised so the e2e spec can start it on any port.
export async function startMicroservice(module: Type<unknown>, port: number): Promise<INestMicroservice> {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(module, {
    transport: Transport.TCP,
    options: { host: '127.0.0.1', port },
  });
  app.enableShutdownHooks();
  await app.listen();
  return app;
}

In production it listens on the RabbitMQ queue, with the transport options built from ConfigService. createMicroservice() runs before providers exist, so injected configuration is passed with AsyncMicroserviceOptions:

apps/notifications/src/main.ts in production
import { type INestMicroservice } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { type AsyncMicroserviceOptions } from '@nestjs/microservices';
import { notificationsRmqOptions } from './rabbit.options.js';
import { NotificationsModule } from './notifications.service.js';

// apps/notifications/src/main.ts in production: transport options come from ConfigService.
export async function bootstrapNotifications(): Promise<INestMicroservice> {
  const app = await NestFactory.createMicroservice<AsyncMicroserviceOptions>(NotificationsModule, {
    useFactory: (config: ConfigService) => notificationsRmqOptions(config),
    inject: [ConfigService],
  });
  app.enableShutdownHooks();
  await app.listen();
  return app;
}

A queue consumer in production is also a process you must keep healthy: add a readiness check, graceful shutdown (so in-flight messages are acknowledged before the process exits) and tracing. Those are covered in Health Checks & Observability and Lifecycle Events & Shutdown. For testing services like these (mocked ClientProxy, real transports, contract tests), see Testing GraphQL, WebSockets & Microservices.

What the examples are tested against

All the TypeScript listings belong to one project generated by the Nest 12 CLI (ESM layout, Vitest) and pass tsc --noEmit and nest build. A single Vitest spec covers:

  • Real TCP sockets, in one process: the catalog, reviews and notifications services listen on real ports and the gateway (an HTTP application built with Test.createTestingModule, driven with supertest) composes responses, maps a missing book to 404, runs the saga to success and to compensation, and receives a deduplicated review.created event emitted from the reviews service.

  • Database: TypeORM on in-memory better-sqlite3, including the ON CONFLICT DO NOTHING RETURNING statement and a service-owned database module read from ConfigService. The ProcessedMessages class also runs unchanged on PostgreSQL 18 through TypeORM’s postgres driver (against PGlite served over the PostgreSQL wire protocol), where it produces $1, $2 placeholders.

  • Hybrid application: one module answers on a TCP transport with inheritAppConfig.

  • AsyncMicroserviceOptions: resolves ConfigService from the service module, and shows the string-port problem described above.

  • Monorepo: a workspace generated with nest g app and nest g lib, nest build notifications, and starting the built service.

  • Stand-ins, not the real thing: the RabbitMQ options object is built and checked, and the ack/nack calls of every notifications handler are checked against a fake channel; dead-lettering, redelivery and quorum-queue delivery limits need a RabbitMQ broker, so they follow the broker documentation. The Consul calls go to a local HTTP server, not a Consul agent. Kubernetes DNS behaviour is not exercised. Run your own integration tests against a broker (for example with Testcontainers, see Testcontainers fundamentals) before depending on them.

What changed recently

  • The microservices documentation covers AsyncMicroserviceOptions, which resolves injected providers such as ConfigService before the transport starts, instead of reading process.env in main.ts.

  • A connected microservice can inherit the host application’s global pipes, guards, interceptors and filters with inheritAppConfig on connectMicroservice().

  • Pre-request hooks (PreRequestHook from @nestjs/common) run before all guards, interceptors and pipes on every message handler, the microservices counterpart of middleware.

  • NATS moved to v3 (the @nats-io/transport-node client; the StringCodec and JSONCodec helpers are gone), Kafka patterns can be regular expressions, and a GrpcExceptionFilter exists; see Microservice Transports.

  • @nestjs/observe (pre-1.0) traces a request across services and instruments @MessagePattern() and @EventPattern() handlers automatically; see Health Checks & Observability.

  • The reliability building blocks this page points to are first-party but pre-1.0: @nestjs/idempotency, @nestjs/outbox and @nestjs/workflows. The established alternatives (a processed-messages table, a hand-written outbox, a workflow engine of your choice) remain valid.

  • @nestjs/cqrs 12.1 added durable sagas.

Where the book is outdated

The reference book, published in January 2025 (see the section bibliography), was written before NestJS 12. Treat these points with care when following it: the NATS client package and codecs changed with NATS v3; configuration for a microservice’s transport options can now be injected with AsyncMicroserviceOptions; the in-process event emitter and @nestjs/cqrs sagas must not be mistaken for cross-service messaging or distributed sagas; and the pre-1.0 @nestjs/outbox, @nestjs/idempotency and @nestjs/workflows packages post-date it. Verify any import path or decorator from the book against the package typings before copying it.