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()(orconnectMicroservice()on an HTTP app) plus controllers whose methods carry@MessagePattern()or@EventPattern(). -
A client side:
ClientProxyobjects, normally supplied byClientsModule, withsend()andemit(). -
A transporter in between (TCP, Redis, NATS, …), selected with the
transportoption.
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.
Source: NestJS: Microservices overview
Request-response versus event-based
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 |
|
|
Client method |
|
|
Handler result |
Returned value, |
Return value ignored |
Several handlers, one pattern |
One answer is expected |
Every handler runs, in parallel |
Errors |
|
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.
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 |
|---|---|
|
Where the server listens (and where a client connects). |
|
How many times the server tries to listen again after it closes unexpectedly, and the delay in ms between attempts (both default to |
|
Custom classes for outgoing and incoming messages. |
|
A custom socket extending |
|
Node |
|
Largest buffer for incoming messages, in characters (default |
|
How long in ms a peer may stay silent in the middle of a packet before the connection is dropped; |
|
Largest number of response bytes queued for a peer that is not reading them before the connection is dropped; |
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.
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 anObservable; anObservableanswers once per emitted value until it completes. The client then receives several values (seecountdownbelow). -
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 aschemaoption for Standard Schema validation (see pipes). -
@Ctx()injects the transporter-specific context object,TcpContextfor TCP (NatsContext,RmqContext,KafkaContextand so on for the others). With the TCP transportgetPattern()returns the pattern serialized as a string: thedescribehandler above answersDune 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 (bothonReviewCreatedandauditrun for the event:receivedholds the payload andlogis['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 itstransportIdsymbol. -
Request-scoped providers can inject the
RequestContextwith@Inject(CONTEXT)(imported from@nestjs/microservices): it exposespattern,dataandcontextplusgetPattern(),getData()andgetContext(). 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:
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
ClientProxydoes not connect when created; it connects before the first call and reuses the connection. Callconnect()inonApplicationBootstrap()to delay readiness until the peer is reachable. If the peer is downconnect()rejects: against a closed port, it rejects with an error whosecodeisECONNREFUSED. -
Cold versus hot.
send()returns a coldObservable: withoutsubscribe()(orfirstValueFrom()) nothing is transmitted.emit()returns a hot one: the event is dispatched immediately, whether or not anybody subscribes. Awaiting it (for instance withlastValueFrom(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>typeson()andstatusfor the TCP transport. Without the genericson('error', …)does not compile (the events map defaults tonever).
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.
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 };
}
Source: NestJS: Microservices - Pipes
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';
}
}
Source: NestJS: Microservices - Guards
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):
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().
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;
}
Source: NestJS: Hybrid application
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()beforelisten(), the microservices begin consuming before the application’s lifecycle hooks (onModuleInit,onApplicationBootstrap) have completed. Calllisten()(orinit()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 withAPP_PIPE,APP_GUARD,APP_INTERCEPTORandAPP_FILTER.inheritAppConfig: true(second argument ofconnectMicroservice()) shares them, and theuseGlobal*()calls must come beforeconnectMicroservice(), because the microservice registers its handlers at that moment. -
The inheritance pitfall. Inherited HTTP-flavoured enhancers throw HTTP exceptions. A message with
rating: 99sent to the hybrid app (whose globalValidationPipeis inherited) comes back as{ "status": "error", "message": "Internal server error" }, the generic failure the pipes section warned about. Give inherited pipes anexceptionFactorythat throwsRpcException, 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>();
-
statusis anObservableof connection state changes. For TCP it emitsconnectedanddisconnected(the run recorded['connected']after the first call); other transports define their own values. -
on()subscribes to the driver’s internal events (for TCPerror,connect,end,close,timeout,drainandlookup). Since NestJS 12.1.1, listeners registered on a TCP client withon()stay registered until you callclose(), so they are attached again to the new socket opened by the nextsend()oremit()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, anet.Socket, whose constructor name isSocket). 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:
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.'); }
}
Source: NestJS: Custom transporters
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 withlastValueFrom(). -
WritePacket.isDisposed: truetells the proxy that no more data follows; without it every value is emitted to theObservable. -
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
ServerKafkadoes withkafkajs), setpropagatesEventHandlerErrorstotrueon the server class to avoid a duplicate report. The default isfalse. -
To customise the serialisation of responses or errors on the client side, extend
ClientProxy(or a child such asClientTCP), overrideserializeResponse()orserializeError(), and register the class itself (not an instance) with thecustomClassproperty ofClientsModule.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.
Source: NestJS: Custom transporters
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
.jsextension (all snippets here do); CommonJS projects drop the extension. -
toPromise(). Older text converts a call with.toPromise(); usefirstValueFrom()orlastValueFrom(). -
Timeouts and
timeoutimport.timeoutis imported fromrxjs(therxjs/operatorspath in older text still works but is the legacy location). -
NATS 3. The NATS transporter targets the v3 client packages (
@nestjs/microservicesNATS options and the@nats-io/transport-nodepackage);StringCodecandJSONCodecare gone. Details in Microservice Transports. -
Pre-request hooks are new in v12 (see above), as are the TCP
incompleteMessageTimeoutandmaxSendBufferSizeguards, regular-expression patterns for Kafka and theGrpcExceptionFilter. -
12.1.1 keeps client
on()listeners across TCP reconnects untilclose()(see status, on() and unwrap()). -
Hybrid applications: the documented warning about the order of
startAllMicroservices()andlisten(), andinheritAppConfigfor 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(() ⇒ …));throwinsidecatch()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()beforelisten()and receiving messages before bootstrap finished. -
Registering
useGlobalPipes()afterconnectMicroservice()withinheritAppConfig. -
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.