Microservices Fundamentals

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 @nestjs/microservices 12.1.2), Node.js 20.19+ or 22.12+, TypeScript 6, RxJS 7.8, Express 5.2 and Fastify 5.12, all checked on 2026-10-02. In Nest, a microservice is an application that talks through a transport other than HTTP: the same modules, controllers, providers, guards, pipes, interceptors and exception filters you already know, with message handlers instead of route handlers. This page covers the transport-independent part of @nestjs/microservices, using the built-in TCP transport so that everything runs without a broker: the two message styles, the client side, timeouts, hybrid applications, the enhancer pipeline, pre-request hooks and custom transporters. The snippets form one ESM project with Vitest tests: the TCP examples start a real server and client in one process on an ephemeral port, the hybrid example serves HTTP and TCP from one application, and the custom transporter works end to end. The outputs shown are what the snippets produce. The broker-based transports (Redis, MQTT, NATS, RabbitMQ, Kafka, gRPC) are not exercised here; they are covered in Microservice Transports, and the design questions (when to split, gateways, sagas) in Microservices Architecture and Patterns.

The running example is the Bookshelf notifications service: the main API tells it when a review is created and asks it for a few computed answers.

What a Nest microservice is

Nest abstracts each transport behind one canonical interface, so most transporters support both message styles and you can change transport (for reliability, throughput or ecosystem reasons) without touching handlers. The pieces:

  • @nestjs/microservices, a separate package: npm i --save @nestjs/microservices.

  • A server side: NestFactory.createMicroservice() (or connectMicroservice() on an HTTP app) plus controllers whose methods carry @MessagePattern() or @EventPattern().

  • A client side: ClientProxy objects, normally supplied by ClientsModule, with send() and emit().

  • A transporter in between (TCP, Redis, NATS, …​), selected with the transport option.

Patterns are plain values (a string or a literal object) that travel with the payload, so producers and consumers agree on which handler receives which message. The default transport is TCP. The microservice architecture itself is a style, not a requirement of the package: if you only need to talk to an external system and do not want the decorators, a standalone application that manages its own connections is often enough.

Request-response versus event-based

sequenceDiagram autonumber participant API as Bookshelf API (ClientProxy) participant T as Transport (TCP or broker) participant N as notifications (@MessagePattern / @EventPattern) Note over API,N: Request-response: send() is cold, one logical reply channel API->>T: send({cmd: 'sum'}, [1,2,3]) on subscribe T->>N: packet {pattern, data, id} N-->>T: response {id, response: 6} T-->>API: 6, then complete Note over API,N: Event-based: emit() is hot, no reply API->>T: emit('review.created', payload) immediately T->>N: packet {pattern, data} (no id) N->>N: every @EventPattern handler runs Note over API,N: failures never travel back to the producer
Request-response versus event-based messaging

The first style needs two logical channels (one for data, one for responses); NATS provides this natively, other transports emulate it. The second needs none, so it is lighter, and it is the style that fits log-based platforms such as Kafka. The table summarises the differences; the sections below prove each row.

Request-response Event-based

Server decorator

@MessagePattern(pattern)

@EventPattern(pattern)

Client method

send(), a cold Observable: nothing is sent until you subscribe

emit(), a hot Observable: dispatched immediately, subscribing is optional

Handler result

Returned value, Promise or Observable is sent back (an Observable may answer several times)

Return value ignored

Several handlers, one pattern

One answer is expected

Every handler runs, in parallel

Errors

RpcException reaches the caller as the error of the Observable

Nobody to tell: logged by Nest (after your filters) or reported by the transport library

Use when

The caller needs a result or proof of receipt

You announce that something happened

The server: createMicroservice and Transport.TCP

NestFactory.createMicroservice() builds an application with no HTTP server. Its second argument is an options object with transport and a transporter-specific options; listen() starts consuming.

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

export async function startNotifications(port = 4010) {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    NotificationsModule,
    {
      transport: Transport.TCP,
      options: { host: '127.0.0.1', port },
    },
  );
  await app.listen();
  return app;
}

The TCP transporter options are:

Option Meaning

host, port

Where the server listens (and where a client connects).

retryAttempts, retryDelay

How many times the server tries to listen again after it closes unexpectedly, and the delay in ms between attempts (both default to 0).

serializer, deserializer

Custom classes for outgoing and incoming messages.

socketClass

A custom socket extending TcpSocket (default JsonSocket).

tlsOptions

Node tls options. A server needs a PEM key and cert; a client needs the ca (an array, so several authorities may be listed). Use TLS whenever traffic leaves a private network.

maxBufferSize

Largest buffer for incoming messages, in characters (default (512 * 1024 * 1024) / 4).

incompleteMessageTimeout

How long in ms a peer may stay silent in the middle of a packet before the connection is dropped; 0 disables it (default 30000).

maxSendBufferSize

Largest number of response bytes queued for a peer that is not reading them before the connection is dropped; 0 disables it (default 128 MB).

The last two options are recent additions that protect a server from slow or hostile peers, which matters most when the port is reachable by anything other than your own services.

Configuration that needs injected providers

createMicroservice() runs before any provider exists, so a ConfigService cannot be injected into the options. Pass AsyncMicroserviceOptions (a useFactory plus inject) and Nest resolves the providers first. Read the values from the environment through ConfigService; never hard-code hosts or credentials. This form is compile-verified; the factory signature is the one documented.

import { NestFactory } from '@nestjs/core';
import { ConfigService } from '@nestjs/config';
import { AsyncMicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module.js';

const app = await NestFactory.createMicroservice<AsyncMicroserviceOptions>(AppModule, {
  useFactory: (config: ConfigService) => ({
    transport: Transport.TCP,
    options: {
      host: config.get<string>('NOTIFICATIONS_HOST'),
      port: config.get<number>('NOTIFICATIONS_PORT'),
    },
  }),
  inject: [ConfigService],
});
await app.listen();

Handlers: @MessagePattern, @EventPattern, @Payload, @Ctx

Handlers live in controllers (the entry points of an application); the decorators are ignored in plain providers. This is the full notifications controller used throughout this page.

notifications.controller.ts
import { Controller } from '@nestjs/common';
import {
  Ctx, EventPattern, MessagePattern, Payload, RpcException, TcpContext,
} from '@nestjs/microservices';
import { Observable, from } from 'rxjs';

export interface ReviewCreated { reviewId: number; bookId: number; rating: number }

@Controller()
export class NotificationsController {
  readonly received: ReviewCreated[] = [];
  readonly log: string[] = [];

  @MessagePattern({ cmd: 'sum' })
  accumulate(data: number[]): number {
    return (data || []).reduce((a, b) => a + b, 0);
  }

  @MessagePattern({ cmd: 'describe' })
  async describe(@Payload('title') title: string, @Ctx() context: TcpContext): Promise<string> {
    return `${title} via ${context.getPattern()}`;
  }

  @MessagePattern({ cmd: 'countdown' })
  countdown(@Payload() n: number): Observable<number> {
    return from(Array.from({ length: n }, (_, i) => n - i));
  }

  @MessagePattern({ cmd: 'fail' })
  fail(): never {
    throw new RpcException({ code: 'REVIEW_NOT_FOUND', message: 'no such review' });
  }

  @EventPattern('review.created')
  onReviewCreated(@Payload() event: ReviewCreated): void {
    this.received.push(event);
  }

  @EventPattern('review.created')
  audit(@Payload() event: ReviewCreated): void {
    this.log.push(`audit:${event.reviewId}`);
  }
}
  • A handler may be synchronous, async, or return an Observable; an Observable answers once per emitted value until it completes. The client then receives several values (see countdown below).

  • Without a decorator, a handler receives the payload as its single argument. @Payload() makes it explicit and accepts a property key (@Payload('title')) to extract one field; @Payload() also takes a schema option for Standard Schema validation (see pipes).

  • @Ctx() injects the transporter-specific context object, TcpContext for TCP (NatsContext, RmqContext, KafkaContext and so on for the others). With the TCP transport getPattern() returns the pattern serialized as a string: the describe handler above answers Dune via {"cmd":"describe"}. Context objects are what let a NATS wildcard handler read the real subject or a Kafka handler read headers.

  • Several @EventPattern() handlers for the same pattern all run (both onReviewCreated and audit run for the event: received holds the payload and log is ['audit:7']).

  • In a hybrid application with several transports, a second argument binds a handler to one transport: @MessagePattern('time.us.*', Transport.NATS); a custom transporter uses its transportId symbol.

  • Request-scoped providers can inject the RequestContext with @Inject(CONTEXT) (imported from @nestjs/microservices): it exposes pattern, data and context plus getPattern(), getData() and getContext(). Scopes and their cost are covered in Injection Scopes.

The client: ClientsModule and ClientProxy

A client application talks to the service through a ClientProxy. The recommended way to obtain one is ClientsModule: each registration needs a name (the injection token; a string or a symbol), may give a transport (TCP when omitted) and options (the same shape as in createMicroservice()).

import { Module } from '@nestjs/common';
import { ClientsModule, Transport } from '@nestjs/microservices';

@Module({
  imports: [
    ClientsModule.register([
      { name: 'NOTIFICATIONS_SERVICE', transport: Transport.TCP },
    ]),
  ],
})
export class ReviewsModule {}

When the connection details come from configuration, use registerAsync(). This is the client module used on this page:

gateway.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { ClientsModule, Transport } from '@nestjs/microservices';
import { ReviewsGatewayService } from './reviews-gateway.service.js';
import { NOTIFICATIONS } from './tokens.js'; // export const NOTIFICATIONS = 'NOTIFICATIONS_SERVICE';

@Module({
  imports: [
    ConfigModule.forRoot({ isGlobal: true }),
    ClientsModule.registerAsync([
      {
        name: NOTIFICATIONS,
        imports: [ConfigModule],
        inject: [ConfigService],
        useFactory: (config: ConfigService) => ({
          transport: Transport.TCP,
          options: {
            host: config.get<string>('NOTIFICATIONS_HOST', '127.0.0.1'),
            port: config.get<number>('NOTIFICATIONS_PORT', 4010),
          },
        }),
      },
    ]),
  ],
  providers: [ReviewsGatewayService],
  exports: [ReviewsGatewayService],
})
export class GatewayModule {}

Other ways to obtain a client exist: a custom provider whose factory calls ClientProxyFactory.create(options), and the @Client() property decorator. The latter is discouraged because the resulting client is harder to test and to share.

send(), emit(), laziness and connection

import { Inject, Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ClientProxy, TcpEvents, TcpStatus } from '@nestjs/microservices';
import { firstValueFrom, lastValueFrom, toArray } from 'rxjs';

@Injectable()
export class ReviewsGatewayService implements OnApplicationBootstrap {
  constructor(
    @Inject(NOTIFICATIONS) private readonly client: ClientProxy<TcpEvents, TcpStatus>,
  ) {}

  async onApplicationBootstrap(): Promise<void> {
    await this.client.connect(); // optional: fail fast at boot
  }

  sum(values: number[]): Promise<number> {
    return firstValueFrom(this.client.send<number, number[]>({ cmd: 'sum' }, values));
  }

  countdown(from: number): Promise<number[]> {
    return lastValueFrom(this.client.send<number, number>({ cmd: 'countdown' }, from).pipe(toArray()));
  }

  reviewCreated(reviewId: number, bookId: number, rating: number): void {
    this.client.emit('review.created', { reviewId, bookId, rating }); // hot: no subscribe needed
  }
}

Against a real TCP server, sum([1, 2, 3]) returns 6, countdown(3) returns [3, 2, 1] (three answers from one Observable handler), and after reviewCreated(7, 1, 5) the service holds [{ reviewId: 7, bookId: 1, rating: 5 }] and ['audit:7'].

  • Lazy connection. A ClientProxy does not connect when created; it connects before the first call and reuses the connection. Call connect() in onApplicationBootstrap() to delay readiness until the peer is reachable. If the peer is down connect() rejects: against a closed port, it rejects with an error whose code is ECONNREFUSED.

  • Cold versus hot. send() returns a cold Observable: without subscribe() (or firstValueFrom()) nothing is transmitted. emit() returns a hot one: the event is dispatched immediately, whether or not anybody subscribes. Awaiting it (for instance with lastValueFrom(client.emit(…​), { defaultValue: undefined })) tells you that the event left the process, not that a consumer handled it.

  • Close it. Call client.close() on shutdown (onApplicationShutdown()); the lifecycle hooks are covered in Lifecycle Events and Shutdown.

  • Typed clients. ClientProxy<TcpEvents, TcpStatus> types on() and status for the TCP transport. Without the generics on('error', …​) does not compile (the events map defaults to never).

Timeouts and firstValueFrom

A call to a service that is down or slow never completes by itself, so apply the RxJS timeout operator. On expiry the Observable errors with a TimeoutError, which you can translate into an HTTP exception. Convert the Observable to a Promise with firstValueFrom() (one answer) or lastValueFrom() (wait for completion); since RxJS 7 the old toPromise() is deprecated.

import { RequestTimeoutException } from '@nestjs/common';
import { Observable, TimeoutError, catchError, throwError, timeout } from 'rxjs';

slow(ms: number): Observable<string> {
  return this.client.send<string>({ cmd: 'slow' }, {}).pipe(
    timeout(ms),
    catchError((err) =>
      throwError(() =>
        err instanceof TimeoutError
          ? new RequestTimeoutException('notifications did not answer in time')
          : err,
      ),
    ),
  );
}

The slow handler waits 300 ms. With slow(50) the call rejected with a RequestTimeoutException (status 408, message notifications did not answer in time); with slow(2000) it resolved to done. A timeout abandons the wait, it does not cancel the work on the service: the handler still runs to the end, so design handlers to be idempotent and see Microservices Architecture and Patterns for retries.

Exception filters and RpcException

The only difference from the HTTP layer is that handlers throw RpcException (from @nestjs/microservices) instead of HttpException. Nest turns a thrown RpcException('Invalid credentials.') into

{ "status": "error", "message": "Invalid credentials." }

and if you pass an object to the constructor, the object is returned as is. The fail handler above throws new RpcException({ code: 'REVIEW_NOT_FOUND', message: 'no such review' }), and the client’s Observable errored with exactly that object. Calling an unknown pattern errors with the string There is no matching message handler defined in the remote service. The error is delivered as the Observable error, so on the client use catchError or a try/catch around firstValueFrom().

A filter works like an HTTP filter, except that catch() must return an Observable; use throwError() to forward the payload.

ratings.controller.ts (excerpt)
import { ArgumentsHost, Catch, RpcExceptionFilter } from '@nestjs/common';
import { RpcException } from '@nestjs/microservices';
import { Observable, throwError } from 'rxjs';

@Catch(RpcException)
export class RpcErrorFilter implements RpcExceptionFilter<RpcException> {
  catch(exception: RpcException, host: ArgumentsHost): Observable<never> {
    const error = exception.getError();
    return throwError(() =>
      typeof error === 'string' ? { code: 'RPC_ERROR', message: error } : error,
    );
  }
}

Bind it with @UseFilters(new RpcErrorFilter()) on a handler or a controller. To reuse Nest’s default behaviour, extend BaseRpcExceptionFilter and call super.catch(exception, host) from an @Catch() filter. Two pitfalls:

  • An event handler has no response stream: an error that a filter rethrows for an @EventPattern() handler never reaches the producer, so handle the error inside the filter. If no filter matches, Nest logs the failure.

  • Global filters registered on the main HTTP application do not apply to a connected microservice unless you set inheritAppConfig (see hybrid applications).

Pipes, guards and interceptors

All three behave like their HTTP counterparts (see Pipes and Validation, Guards and Interceptors); the differences are the exception type and the execution context, which is RPC (context.switchToRpc() gives getData() and getContext()).

Pipes

A pipe that throws an HttpException (such as ValidationPipe’s default `BadRequestException) reaches the client as a generic Internal server error, because nothing translates it. Make ValidationPipe throw an RpcException with its exceptionFactory option. The @Payload() decorator also accepts a Standard Schema through schema, validated by StandardSchemaValidationPipe (exported from @nestjs/common) with the same exceptionFactory trick.

@UsePipes(
  new ValidationPipe({
    transform: true,
    exceptionFactory: (errors) =>
      new RpcException({ code: 'VALIDATION', message: errors.map((e) => e.property).join(',') }),
  }),
)
@MessagePattern({ cmd: 'ratings.add' })
add(@Payload() dto: AddRatingDto): { bookId: number; rating: number } {
  return { bookId: dto.bookId, rating: dto.rating };
}

With rating: 9 (the DTO requires 1 to 5) the client received { "code": "VALIDATION", "message": "rating" }.

Guards

A guard returning false produces an RpcException with the message Forbidden resource. Guards see the message payload through the RPC context; credentials must travel inside the message or transport metadata, since there are no HTTP headers (with TCP there is no metadata channel at all, so a token has to be part of the payload).

@Injectable()
export class TenantGuard implements CanActivate {
  canActivate(context: ExecutionContext): boolean {
    const data = context.switchToRpc().getData<{ tenantId?: string }>();
    return typeof data?.tenantId === 'string';
  }
}

Sending a payload without tenantId errored with { "code": "RPC_ERROR", "message": "Forbidden resource" } (the filter above reshaped the string error).

Interceptors

Interceptors wrap the handler’s Observable. This one adds the correlation id established by the pre-request hook shown next:

@Injectable()
export class EnvelopeInterceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
    return next.handle().pipe(
      map((value) => ({ correlationId: als.getStore()?.correlationId, value })),
    );
  }
}

Composed on one handler with @UseGuards(TenantGuard), @UseInterceptors(EnvelopeInterceptor), @UseFilters(RpcErrorFilter) and the pipe above, a valid call returned { "correlationId": "c8d98aba-81e4-47d6-aebc-b7357f556090", "value": { "bookId": 1, "rating": 4 } }. The order matches the HTTP lifecycle (Request Lifecycle) once the v12 pre-request hooks are added in front of it:

incoming message -> pre-request hooks -> guards -> interceptors (before) -> pipes -> handler
                    exceptions anywhere -> filters

Pre-request hooks (v12)

Nest 12 added pre-request hooks: functions that run before every guard, interceptor and pipe on every pattern handler invocation, the microservice equivalent of HTTP middleware. Register them with registerPreRequestHook() on the microservice instance before it is initialised (before listen() or init()); later registrations are ignored with a logged warning, and several calls accumulate in registration order. A hook receives an ExecutionContext and a next function and must return an Observable: the one from next() or one wrapping it. If a hook never calls next(), the handler never runs. Exceptions thrown in a hook reach your exception filters.

The classic use is AsyncLocalStorage, so that the store exists in guards too (the interceptor above reads it):

hooks.ts
import { INestMicroservice } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Observable } from 'rxjs';
import { als } from './ratings.controller.js';

export function registerCorrelation(app: INestMicroservice): void {
  app.registerPreRequestHook((ctx, next) => {
    return new Observable((subscriber) => {
      als.run({ correlationId: randomUUID() }, () => {
        next().subscribe(subscriber);
      });
    });
  });
}

Registered on the microservice before listen(), the hook gives each reply a fresh UUID. Hooks are global (they cannot be bound to individual patterns), add no overhead when none is registered, and do not apply to WebSocket gateways. In a hybrid application connectMicroservice() initialises the microservice immediately unless you pass deferInitialization: true in its second argument, so hooks registered on the returned instance afterwards are not applied. Plain observation (logging, timing) is possible too, by applying tap and catchError to the Observable returned by next(). See also Logging for propagating the id into logs.

Hybrid applications

A hybrid application listens on two or more sources: an HTTP server plus a microservice listener, or several microservice listeners. createMicroservice() supports only one, so connect each INestMicroservice to an INestApplication with connectMicroservice(), then start them with startAllMicroservices().

hybrid.ts
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';

export async function bootstrapHybrid(httpPort: number, tcpPort: number): Promise<INestApplication> {
  const app = await NestFactory.create(HybridModule);

  // Global enhancers go first when inheritAppConfig is used.
  app.useGlobalPipes(new ValidationPipe({ transform: true }));

  app.connectMicroservice<MicroserviceOptions>(
    { transport: Transport.TCP, options: { host: '127.0.0.1', port: tcpPort } },
    { inheritAppConfig: true },
  );

  await app.listen(httpPort);       // runs onModuleInit / onApplicationBootstrap first
  await app.startAllMicroservices();
  return app;
}

GET /health on the HTTP port answers { "status": "ok" } and a TCP client receives 9 for send({ cmd: 'sum' }, [4, 5]) from the same process.

  • Call order matters. With await app.startAllMicroservices() before listen(), the microservices begin consuming before the application’s lifecycle hooks (onModuleInit, onApplicationBootstrap) have completed. Call listen() (or init() when there is no HTTP) first if handlers must not receive a message before every module is initialised, as above.

  • Several microservices. Call connectMicroservice() once per transport; bind individual handlers with the second argument of @MessagePattern() (see handlers).

  • Sharing configuration. By default a connected microservice does not inherit the application’s global pipes, guards, interceptors and filters, neither those from useGlobal*() nor those registered with APP_PIPE, APP_GUARD, APP_INTERCEPTOR and APP_FILTER. inheritAppConfig: true (second argument of connectMicroservice()) shares them, and the useGlobal*() calls must come before connectMicroservice(), because the microservice registers its handlers at that moment.

  • The inheritance pitfall. Inherited HTTP-flavoured enhancers throw HTTP exceptions. A message with rating: 99 sent to the hybrid app (whose global ValidationPipe is inherited) comes back as { "status": "error", "message": "Internal server error" }, the generic failure the pipes section warned about. Give inherited pipes an exceptionFactory that throws RpcException, or use RPC-aware global filters.

status, on() and unwrap()

The client and the server expose three ways to watch or reach the underlying driver, all typed per transport:

import { Server } from 'node:net';
import { MicroserviceOptions, TcpEvents, TcpStatus, Transport } from '@nestjs/microservices';

// client side
this.client.status.subscribe((status: TcpStatus) => console.log(status));
this.client.on('error', (err) => console.error(err));
const socket = this.client.unwrap<import('node:net').Socket>();

// server side (hybrid app)
const server = app.connectMicroservice<MicroserviceOptions>({ transport: Transport.TCP });
server.status.subscribe((status: TcpStatus) => console.log(status));
server.on<TcpEvents>('error', (err) => console.error(err));
const netServer = server.unwrap<Server>();
  • status is an Observable of connection state changes. For TCP it emits connected and disconnected (the run recorded ['connected'] after the first call); other transports define their own values.

  • on() subscribes to the driver’s internal events (for TCP error, connect, end, close, timeout, drain and lookup). Since NestJS 12.1.1, listeners registered on a TCP client with on() stay registered until you call close(), so they are attached again to the new socket opened by the next send() or emit() after a disconnect. Registrations are not deduplicated: register each listener once, not again from its own callback or after each reconnect.

  • unwrap() returns the underlying driver (for the TCP client, a net.Socket, whose constructor name is Socket). Prefer not to: the abstraction exists so that you do not touch the driver.

TLS

When traffic leaves a private network, set tlsOptions (see the options table above): a server gets key and cert (PEM, read from paths supplied by configuration), a client gets ca listing the authorities that signed the server certificate. Node’s tls module does the encryption. The pieces are the documented ones; this page does not exercise them with certificates.

import * as fs from 'node:fs';

ClientsModule.register([
  {
    name: 'NOTIFICATIONS_SERVICE',
    transport: Transport.TCP,
    options: { tlsOptions: { ca: [fs.readFileSync(process.env.NOTIFICATIONS_CA_PATH!, 'utf-8')] } },
  },
]);

Custom transporters

If no built-in transport fits (a cloud-specific queue, an in-house protocol), build one. The server side extends Server and implements CustomTransportStrategy (listen() and close() are mandatory; on() and unwrap() may throw "not implemented"). Pass an instance as strategy instead of transport and options. Extending an existing server class (for example ServerRedis) is the way to add features to a built-in transport. The client side extends ClientProxy and implements connect(), close(), dispatchEvent() (events), publish() (request-response) and unwrap(). The Server base class gives you messageHandlers, a Map of every registered handler keyed by the normalised pattern, and getHandlerByPattern().

This complete, broker-less transport keeps servers in a process-wide set. It is the one used in the examples:

in-memory.transport.ts
import {
  ClientProxy, CustomTransportStrategy, ReadPacket, Server, WritePacket,
} from '@nestjs/microservices';
import { isObservable, lastValueFrom } from 'rxjs';

const servers = new Set<InMemoryServer>();

export class InMemoryServer extends Server implements CustomTransportStrategy {
  listen(callback: () => void): void { servers.add(this); callback(); }
  close(): void { servers.delete(this); }
  on(): never { throw new Error('Method not implemented.'); }
  unwrap<T = never>(): T { throw new Error('Method not implemented.'); }

  async dispatch(pattern: unknown, data: unknown, isEvent: boolean) {
    const handler = this.getHandlerByPattern(this.normalizePattern(pattern as string));
    if (!handler) throw new Error(`No handler for ${JSON.stringify(pattern)}`);
    const result = await handler(data);
    if (isEvent) return undefined;
    return isObservable(result) ? lastValueFrom(result) : result;
  }
}

export class InMemoryClient extends ClientProxy {
  async connect(): Promise<void> {}
  async close(): Promise<void> {}

  async dispatchEvent(packet: ReadPacket): Promise<any> {
    for (const server of servers) {
      await server.dispatch(packet.pattern, packet.data, true).catch(() => undefined);
    }
  }

  publish(packet: ReadPacket, callback: (packet: WritePacket) => void): () => void {
    const [server] = servers;
    server
      .dispatch(packet.pattern, packet.data, false)
      .then((response) => callback({ response, isDisposed: true }))
      .catch((err) => callback({ err }));
    return () => undefined; // teardown, run when the subscriber unsubscribes
  }

  unwrap<T = never>(): T { throw new Error('Method not implemented.'); }
}

Using it:

const ms = await NestFactory.createMicroservice(NotificationsModule, {
  strategy: new InMemoryServer(),
});
await ms.listen();

const client = new InMemoryClient();
await firstValueFrom(client.send({ cmd: 'sum' }, [1, 2])); // 3
client.emit('review.created', { reviewId: 1, bookId: 1, rating: 3 }); // handled by both handlers

send() returned 3 and the review.created event reached the controller. Notes from the docs that matter in real transporters:

  • Handlers are wrapped in RxJS streams when interceptors are involved: if you call a handler directly, subscribe to the result when isObservable(result) is true, or the controller code never runs. The sketch above turns it into a value with lastValueFrom().

  • WritePacket.isDisposed: true tells the proxy that no more data follows; without it every value is emitted to the Observable.

  • An event handler has no response stream, so its errors have nowhere to go: Nest logs them, but only after your filters ran (a matching filter is then the only report). If your transport awaits the event stream itself and hands the failure to its client library (as ServerKafka does with kafkajs), set propagatesEventHandlerErrors to true on the server class to avoid a duplicate report. The default is false.

  • To customise the serialisation of responses or errors on the client side, extend ClientProxy (or a child such as ClientTCP), override serializeResponse() or serializeError(), and register the class itself (not an instance) with the customClass property of ClientsModule.register().

  • A production strategy also needs reconnection, backpressure and graceful close(). The docs deliberately do not implement a full Google Cloud Pub/Sub transporter; treat the in-memory example as a skeleton for tests and prototypes.

Where the book is outdated and what changed recently

  • ESM and imports. Nest 12 core packages are ESM-only, the template is ESM, and relative imports carry the .js extension (all snippets here do); CommonJS projects drop the extension.

  • toPromise(). Older text converts a call with .toPromise(); use firstValueFrom() or lastValueFrom().

  • Timeouts and timeout import. timeout is imported from rxjs (the rxjs/operators path in older text still works but is the legacy location).

  • NATS 3. The NATS transporter targets the v3 client packages (@nestjs/microservices NATS options and the @nats-io/transport-node package); StringCodec and JSONCodec are gone. Details in Microservice Transports.

  • Pre-request hooks are new in v12 (see above), as are the TCP incompleteMessageTimeout and maxSendBufferSize guards, regular-expression patterns for Kafka and the GrpcExceptionFilter.

  • 12.1.1 keeps client on() listeners across TCP reconnects until close() (see status, on() and unwrap()).

  • Hybrid applications: the documented warning about the order of startAllMicroservices() and listen(), and inheritAppConfig for sharing global enhancers, are part of the current docs; examples that rely on global pipes or filters reaching a connected microservice without it are wrong.

  • Error shape. Filters must return an Observable (throwError(() ⇒ …​)); throw inside catch() is not the documented form.

  • The docs now also describe tracing a request across services with NestJS Observe: the trace id must be forwarded on a channel the transport leaves free (a Kafka header, a NATS header or a field of the TCP payload), see Distributed tracing.

  • The documentation was restructured in the 12.x line: microservice pages live under /microservices/* and the hybrid page under /faq/hybrid-application.

Pitfalls

  • Forgetting to subscribe to send(): nothing is sent.

  • Forgetting timeout(): a service outage hangs the caller until the transport gives up.

  • Treating emit() as delivery confirmation; use a transport with acknowledgements and idempotent consumers (Delivery Guarantees).

  • Throwing HttpException`s (or using HTTP-flavoured pipes) in handlers: the client sees a generic `Internal server error.

  • Putting @MessagePattern() on a provider instead of a controller: it is silently ignored.

  • Calling startAllMicroservices() before listen() and receiving messages before bootstrap finished.

  • Registering useGlobalPipes() after connectMicroservice() with inheritAppConfig.

  • Registering pre-request hooks after the microservice was initialised.

  • Registering the same on() listener again after each reconnect.

  • Using the @Client() decorator: hard to test; inject the proxy by token and mock it in tests (Testing GraphQL, WebSockets and Microservices).

  • Sending secrets or tokens in the clear over TCP outside a private network: enable tlsOptions.

  • Express versus Fastify: microservices do not depend on the HTTP adapter; the adapter only matters for the HTTP side of a hybrid application, whose lifecycle and global enhancers work as described in Fastify and HTTP Platforms.

For the message-broker theory behind these choices see Messaging Models and Patterns, and for events that stay inside one process Events, Scheduling and Queues.