What a NestJS microservice is and how it differs from an HTTP app
A NestJS microservice is a NestJS application that receives messages from a transport instead of HTTP requests from a port. Modules, providers, dependency injection, guards, interceptors, pipes and exception filters work the same way they do in an HTTP app. The difference is the entry point. Controllers handle message patterns instead of routes, and the framework manages serialization and delivery over the transport you pick.
You bootstrap one with NestFactory.createMicroservice() from @nestjs/microservices. It takes your root module and an options object. You select a transport with the Transport enum (TCP is the default) and give its connection details under options. HTTP apps call NestFactory.create() and listen on a port. A microservice connects to or listens on its transport, and nothing else in the app changes. Your domain code doesn't depend on the delivery mechanism, so you can move a module out of a monolith without rewriting its providers.
The caller uses a ClientProxy, usually registered through ClientsModule. It sends messages over the same transport:
HTTP clients
|
[ API gateway (HTTP app) ]
| ClientProxy.send() / emit()
v
[ Transport: TCP | Redis | NATS | ... ]
| |
[ orders service ] [ billing service ]
@MessagePattern @EventPattern
This approach suits backend and platform teams that already run NestJS and need to split a service out. It lets them do that without adopting a second framework or a second way of structuring code. The reference is the official documentation at https://docs.nestjs.com/.
// orders-service/src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { OrdersModule } from './orders.module';
async function bootstrap() {
// Same root module as an HTTP app, but it listens on a transport, not an HTTP port
const app = await NestFactory.createMicroservice<MicroserviceOptions>(OrdersModule, {
transport: Transport.TCP,
options: { host: '0.0.0.0', port: 3001 },
});
await app.listen();
}
bootstrap();
// orders-service/src/orders.controller.ts
import { Controller } from '@nestjs/common';
import { MessagePattern, Payload } from '@nestjs/microservices';
import { OrdersService } from './orders.service';
@Controller()
export class OrdersController {
// Injected through normal DI, so the provider does not depend on the transport
constructor(private readonly orders: OrdersService) {}
// Matches messages sent with the pattern { cmd: 'get_order' } instead of an HTTP route
@MessagePattern({ cmd: 'get_order' })
getOrder(@Payload() data: { id: string }) {
return this.orders.findById(data.id); // Return value is serialized back to the caller
}
}
// api-gateway/src/app.module.ts (caller side, excerpt)
// ClientsModule.register([
// { name: 'ORDERS_SERVICE', transport: Transport.TCP, options: { host: 'orders', port: 3001 } },
// ])
// Then: this.ordersClient.send({ cmd: 'get_order' }, { id: 'ord_123' })
An orders service bootstrapped with NestFactory.createMicroservice over TCP, a controller that handles a message pattern through ordinary dependency injection, and the gateway-side ClientsModule registration that sends to it.
Choosing a transport: TCP, Redis, NATS, MQTT, RabbitMQ, Kafka or gRPC
Every transport sits behind the same ClientProxy and decorator API, but they differ in what happens to a message once it leaves the producer. Pick the one that matches the guarantees you need and the infrastructure you already operate.
| Transport | Delivery | Ordering | Persistence | Operational burden |
|---|---|---|---|---|
| TCP | Best effort, request fails if consumer is down | Per connection | None | Lowest, but no discovery |
| Redis | At-most-once (pub/sub) | Not guaranteed across subscribers | None | Low if Redis already runs |
| NATS | At-most-once (core NATS) | Per subject, per publisher | None | Low |
| MQTT | QoS 0, 1 or 2 | Per topic | Broker-dependent | Moderate |
| RabbitMQ | At-least-once with acks | Per queue, single consumer | Durable queues | Moderate |
| Kafka | At-least-once | Per partition | Retained log | Highest |
| gRPC | Request-response over HTTP/2 | Per stream | None | Moderate (schema tooling) |
TCP needs no extra dependencies, but it has no broker, persistence or service discovery. If the consumer is down, the request fails. Nothing queues it for later. Every broker transport requires its client library to be installed separately, as listed in the official documentation.
The abstraction leaks. Moving from TCP to RabbitMQ introduces acknowledgements and redelivery, so handlers must tolerate duplicates. Moving to Kafka adds consumer groups, partition-scoped ordering and offset commits. The decorators stay the same, but the semantics do not.
gRPC is contract-first. You define services in .proto files, which Nest loads at runtime. You then typically generate TypeScript interfaces from them so that handlers and clients stay in sync with the schema.
How message patterns work: @MessagePattern, @EventPattern, send() and emit()
Nest offers two interaction styles, and the choice decides who waits for whom. Request-response means the caller needs an answer before it can continue. Fire-and-forget means the caller announces that something happened and moves on.
On the server side, a method decorated with @MessagePattern('orders.get') handles requests. Its return value, whether a plain value, a Promise or an Observable, is serialized and sent back as the reply. A method decorated with @EventPattern('order.created') handles events. Nothing it returns reaches the publisher.
On the client side, you register a named connection with ClientsModule.register(), giving it a name, a transport and transport options. You then inject it as a ClientProxy with @Inject('ORDERS_SERVICE').
send(pattern, payload)returns a cold Observable. No message leaves the process until you subscribe or convert it withfirstValueFrom().emit(pattern, payload)dispatches eagerly in current versions. You should still subscribe or await it, because otherwise connection and publish errors go unnoticed.
Choosing the wrong style fails quietly. A send() whose reply never arrives, because no consumer is attached or an instance crashed mid-handler, can leave the caller waiting indefinitely unless you add a timeout operator. Using emit() for work that must happen is the opposite mistake. On plain pub/sub transports such as Redis or core NATS, an event with no active subscriber is simply dropped.
Defaulting to send() everywhere carries its own cost. Each synchronous hop adds latency, and the availability of a request path becomes the product of every service it touches. Chains of request-response calls give you a monolith's coupling with a network's failure modes. Reserve send() for reads that genuinely need an answer, and model state changes as events.
import { Controller, Inject, Injectable, Module } from '@nestjs/common';
import { ClientProxy, ClientsModule, EventPattern, MessagePattern, Payload, Transport } from '@nestjs/microservices';
import { firstValueFrom, timeout } from 'rxjs';
// Server side (orders service): one request handler, one event handler.
@Controller()
export class OrdersController {
@MessagePattern('orders.get') // the return value is serialized and sent back as the reply
getOrder(@Payload() data: { id: string }) {
return { id: data.id, status: 'paid' };
}
@EventPattern('order.created') // any return value is discarded; the publisher never sees it
async onOrderCreated(@Payload() data: { id: string }) {
// e.g. update a read model; keep this idempotent in case of redelivery
}
}
// Client side (another service): register a named connection, then inject it.
@Module({
imports: [ClientsModule.register([
{ name: 'ORDERS_SERVICE', transport: Transport.TCP, options: { host: 'orders', port: 3001 } },
])],
})
export class CheckoutModule {}
@Injectable()
export class CheckoutService {
constructor(@Inject('ORDERS_SERVICE') private readonly orders: ClientProxy) {}
async placeOrder(id: string) {
// send() is cold: nothing is sent until firstValueFrom subscribes. timeout() turns a missing reply into an error.
const order = await firstValueFrom(this.orders.send('orders.get', { id }).pipe(timeout(3000)));
// Await emit() so connection and publish errors surface here instead of vanishing.
await firstValueFrom(this.orders.emit('order.created', { id: order.id }));
return order;
}
}
A @MessagePattern handler returns a reply and an @EventPattern handler returns nothing to the publisher, while the client awaits send() with a timeout and also awaits emit() so that delivery errors are not lost.
Designing message contracts that survive independent deploys
Treat events as the default and commands as the exception. The contract for each message is its pattern plus its payload shape. Design both on the assumption that producer and consumer will run different versions at the same time, because during any rolling deploy they will.
Naming patterns
Use string patterns with a consistent domain.entity.action shape. Use past tense for events (billing.invoice.paid) and imperative for commands (billing.invoice.void). Past-tense names tell consumers they are reacting to a fact. Imperative names make it obvious which messages expect a handler to act and reply. NestJS also accepts object patterns such as { cmd: 'void' }, but strings are easier to grep, log and route on brokers that map patterns to topics or subjects.
Payload DTOs and validation
Define each payload as a class-validator DTO and bind it with ValidationPipe on the handler's @Payload(). HTTP exceptions mean nothing to a message transport, so configure the pipe's exceptionFactory to throw RpcException instead.
For forward compatibility, use whitelist: true and avoid forbidNonWhitelisted. Stripping unknown fields lets an older consumer accept a newer producer's extra properties. Rejecting unknown fields turns every additive change into an outage.
Versioning
Keep changes additive: add fields only as optional, and never rename or retype a field. For a breaking change, publish a new pattern such as billing.invoice.paid.v2. Have the producer emit both versions until every consumer handles v2, then remove v1. Version suffixes in the pattern are easier to route and observe than a version field buried in the body.
The shared-package trade-off
A shared DTO package gives you compile-time types on both sides. It also tempts teams to bump it in lockstep, which recreates coordinated releases. Publish it with semantic versioning, allow consumers to lag, and treat a major bump as a new pattern rather than an upgrade.
import { Controller, ValidationPipe } from '@nestjs/common';
import { EventPattern, Payload, RpcException } from '@nestjs/microservices';
import { IsISO8601, IsInt, IsOptional, IsString, IsUUID, Min } from 'class-validator';
// Both DTOs live in the shared contracts package.
export class InvoicePaidV1 {
@IsUUID() invoiceId!: string;
@IsInt() @Min(0) amountCents!: number;
// Added later as optional, so payloads from older producers stay valid.
@IsOptional() @IsString() currency?: string;
}
// Breaking change (decimal string amount, required fields) => new pattern.
export class InvoicePaidV2 {
@IsUUID() invoiceId!: string;
@IsString() amount!: string;
@IsString() currency!: string;
@IsISO8601() paidAt!: string;
}
const rpcValidation = new ValidationPipe({
whitelist: true, // strip fields this consumer doesn't know yet; don't reject them
transform: true,
// HTTP exceptions mean nothing to a transport, so throw RpcException instead.
exceptionFactory: (errors) =>
new RpcException({ code: 'INVALID_PAYLOAD', fields: errors.map((e) => e.property) }),
});
@Controller()
export class InvoiceEventsController {
// Kept until every producer has stopped emitting v1.
@EventPattern('billing.invoice.paid')
onPaidV1(@Payload(rpcValidation) event: InvoicePaidV1) { /* ... */ }
@EventPattern('billing.invoice.paid.v2')
onPaidV2(@Payload(rpcValidation) event: InvoicePaidV2) { /* ... */ }
}
A consumer validates versioned event payloads with a whitelisting ValidationPipe that throws RpcException, and handles the v1 and v2 patterns side by side while producers migrate.
Handling errors, timeouts, retries and redelivery
Inside a handler, throw RpcException from @nestjs/microservices instead of an HttpException. The microservice layer has no notion of status codes, so the framework serializes the error payload and returns it to the caller. On the client side, the observable from send() errors with that payload. The microservice does not choose HTTP semantics. The gateway does, by catching the error in an exception filter and mapping it to an HttpException. For example, it can translate an agreed code: 'ORDER_NOT_FOUND' field to a 404. Put that code in your error contract so the mapping does not depend on matching message strings.
Timeouts and retries
send() returns a cold observable with no default deadline. Pipe it through RxJS timeout() so a stalled consumer cannot hold an HTTP request open indefinitely. Add retry() with a count and delay only when the handler is safe to run twice. A timeout does not mean the work failed. The handler may still finish after the caller has given up.
Idempotency is your job
Brokers deliver at least once, and Nest does not deduplicate messages. Include a message or idempotency key in the payload and record processed keys in the same transaction as the side effect.
Transport context
@Ctx() exposes transport-specific details. KafkaContext provides the topic, partition and raw message, including headers and offset. RmqContext provides the channel and original message. With RabbitMQ, set noAck: false and call channel.ack() only after the work commits. If you leave the default automatic acknowledgement in place, a crash mid-handler loses the message silently.
// --- orders microservice (RabbitMQ transport configured with noAck: false) ---
import { Controller } from '@nestjs/common';
import { Ctx, MessagePattern, Payload, RmqContext, RpcException } from '@nestjs/microservices';
@Controller()
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@MessagePattern('orders.get')
async getOrder(@Payload() data: { id: string }, @Ctx() ctx: RmqContext) {
const channel = ctx.getChannelRef();
const msg = ctx.getMessage();
const order = await this.orders.findById(data.id);
// Ack only after the work has finished, so a crash before this line leaves the message for redelivery.
channel.ack(msg);
// Business errors are acked too (retrying will not help), then serialized back to the caller.
if (!order) throw new RpcException({ code: 'ORDER_NOT_FOUND', message: `Order ${data.id} not found` });
return order;
}
}
// --- HTTP gateway ---
import { BadGatewayException, Get, GatewayTimeoutException, NotFoundException, Param } from '@nestjs/common';
import { catchError, throwError, timeout, TimeoutError } from 'rxjs';
@Get('orders/:id')
getOrderHttp(@Param('id') id: string) {
return this.client.send('orders.get', { id }).pipe(
timeout(5000), // no default deadline: bound how long the HTTP request can wait
catchError((err) => throwError(() => {
if (err instanceof TimeoutError) return new GatewayTimeoutException(); // handler may still complete
// Map on the agreed error code, not on message text.
if (err?.code === 'ORDER_NOT_FOUND') return new NotFoundException(err.message);
return new BadGatewayException();
})),
);
}
A RabbitMQ handler that acknowledges manually through @Ctx() and throws an RpcException with an error code, and a gateway that applies timeout() to send() and maps the serialized error to HTTP exceptions.
Hybrid apps and incremental extraction from a monolith
A hybrid app is a single Nest process that serves HTTP and also listens on one or more transports. You create the HTTP application as usual, call app.connectMicroservice() with transport options for each listener, then await app.startAllMicroservices() before app.listen(). Controllers in the same module graph can hold both @Get() routes and @MessagePattern() handlers. By default, global pipes, guards and interceptors registered on the HTTP app do not apply to connected microservices. Pass { inheritAppConfig: true } as the second argument if you want them to.
This setup is most useful during incremental extraction. You can move a bounded context, such as billing, behind message patterns while it still runs inside the monolith. Callers switch from direct method calls to ClientProxy.send() and emit(), and the contract gets exercised in production. Once traffic and error handling look right, you move the handler to its own deployable. The callers do not change.
The costs come from sharing one process. HTTP requests and message handlers run on the same event loop, so a CPU-heavy or blocking handler raises HTTP latency. A crash or memory leak takes down both interfaces. You also cannot scale consumers separately from web traffic. Treat a hybrid app as a waypoint, not a final design.
Local development and testing
Run brokers and extracted services in containers with Compose, using the same transport you use in production. Because handlers are ordinary providers and controllers, you can unit-test them with Test.createTestingModule() and call the methods directly, with no broker involved. For integration tests, start the server over a real transport and connect a client registered through ClientsModule. Assert on actual round trips, including timeouts and serialized errors, since these are where transport differences show up.
When to avoid it, alternatives, and impact on cost and engineering time
These failures cost the most once services are split, so it is fair to ask whether the split is worth it in the first place.
When to avoid it
Skip NestJS microservices if one team owns the whole codebase, every component ships on the same release cadence, and nothing needs to scale on its own. In that case you take on network latency, serialization and partial failure, and you get boundaries that Nest modules would have given you anyway.
| Option | Good fit | Main cost |
|---|---|---|
| NestJS modular monolith | One team, shared deploys | Boundaries depend on team discipline, not on a process split |
| Plain HTTP between services | Few calls, all synchronous, with existing HTTP tooling | No built-in events; you write retries and timeouts yourself |
| Broker client library used directly | One broker whose specific features you need | You lose Nest DI, pipes and filters around handlers |
| NestJS microservices | Several teams, independent scaling, mix of request/reply and events | Broker operations plus the framework's abstraction layer |
Where the cost goes
Most of the cost is operational. Someone has to run, monitor and upgrade the broker. You need distributed tracing. You have to pass correlation IDs along in the message payload or in transport metadata, depending on what the transport supports. Partial failure also has to be designed for. For example, a call can succeed in one service and time out in its caller, so handlers need to be idempotent and you need a way to reconcile the two sides.
Where time is saved
Handlers are ordinary Nest providers, so your existing modules, DI, validation pipes, exception filters and interceptors carry over unchanged. If your contracts are explicit and versioned, switching transports mostly means changing transport options and client registration, and handler logic stays the same. Transport-specific behaviour still leaks through, though: Kafka partitioning and RabbitMQ acknowledgements both need their own handling.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nestjs microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Consumers learn about a breaking change at review time rather than from production errors.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.