How to migrate from @nestjs/microservices NATS to JetStream
This guide walks through replacing the built-in NestJS NATS transport (@nestjs/microservices package, Transport.NATS) with @horizon-republic/nestjs-jetstream. Your ClientProxy already talks to NATS — switching to JetStream is mostly configuration.
You will end up with durable delivery, automatic retries, dead letter handling, and W3C trace context for the same @EventPattern / @MessagePattern handlers you already have.
See the Release Notes instead. This guide covers the one-time switch from @nestjs/microservices.
What changes
The semantic shift is from at-most-once to at-least-once delivery:
- Delivery. Built-in NATS is fire-and-forget; messages are lost if no subscriber is listening. JetStream persists every event in a stream so messages survive restarts.
- Retention. Built-in has no retention. JetStream keeps messages in a stream until acked (workqueue) or until the retention window expires.
- Replay. Built-in does not support replay. JetStream consumers can be created to catch up on history.
- Retries. Built-in does not retry. JetStream redelivers a message until the handler acks or
max_deliveris exhausted. - Dead letters. Built-in silently drops failed messages. JetStream provides a configurable dead-letter flow (Dead Letter Queue).
- Decorators. Unchanged.
@EventPatternand@MessagePatternwork identically.
Step 1 — Install the library
npm install @horizon-republic/nestjs-jetstream @nats-io/transport-node @nats-io/jetstream
You can remove @nestjs/microservices if no other transport (Redis, RabbitMQ, Kafka) is in use.
Step 2 — Replace module registration
Before:
import { Transport, ClientsModule } from '@nestjs/microservices';
@Module({
imports: [
ClientsModule.register([
{
name: 'NATS_SERVICE',
transport: Transport.NATS,
options: { servers: ['nats://localhost:4222'] },
},
]),
],
})
export class AppModule {}
After:
import { JetstreamModule } from '@horizon-republic/nestjs-jetstream';
@Module({
imports: [
// Once in your root module — creates the connection + consumer infrastructure
JetstreamModule.forRoot({
name: 'orders',
servers: ['nats://localhost:4222'],
}),
// Per target service, where you publish to
JetstreamModule.forFeature({ name: 'payments' }),
],
})
export class AppModule {}
The name field is the service identity used to derive stream, consumer, and subject names. See Naming Conventions for the rules.
Step 3 — Keep your handlers
No changes to handler signatures. @EventPattern and @MessagePattern work identically.
import { Controller } from '@nestjs/common';
import { EventPattern, MessagePattern, Payload } from '@nestjs/microservices';
@Controller()
export class OrdersController {
@EventPattern('order.created')
handleOrderCreated(@Payload() data: { orderId: string }) {
// Same code as before. Throws here will trigger JetStream retries.
}
@MessagePattern('order.get')
getOrder(@Payload() data: { id: string }) {
return { id: data.id, status: 'shipped' };
}
}
Step 4 — Replace client injection
Before:
import { ClientProxy } from '@nestjs/microservices';
constructor(@Inject('NATS_SERVICE') private readonly client: ClientProxy) {}
After:
import { ClientProxy } from '@nestjs/microservices';
import { getClientToken } from '@horizon-republic/nestjs-jetstream';
constructor(@Inject(getClientToken('payments')) private readonly client: ClientProxy) {}
client.emit() and client.send() keep their existing signatures.
Step 5 — Adjust for acknowledgment semantics
JetStream is at-least-once. A message may be redelivered after a handler throws or a pod restarts mid-execution. Make handlers idempotent:
- Use a unique identifier from the payload (e.g.,
orderId) to deduplicate. - For commands that produce side effects (charge a card, send an email), check whether the side effect already happened before doing it again.
- See Idempotency in the events pattern for concrete techniques.
What you gain
After migration, you get these capabilities for free:
- Messages survive NATS server restarts.
- Failed messages are automatically retried up to
max_deliver. - Dead letter handling for exhausted retries — see Dead Letter Queue.
- Health checks with RTT monitoring — see Health Checks.
- Graceful shutdown with message drain — see Graceful Shutdown.
- Broadcast fan-out to all service instances — see Broadcast Events.
- Ordered sequential delivery mode — see Ordered Events.
- W3C trace context end-to-end — see Distributed Tracing.
- Prometheus metrics out of the box — see Prometheus Metrics.
Upgrading between versions
v2.12 → v2.13
Behavior change: stream.name and consumer.durable_name overrides are now honored.
In previous releases, setting stream.name or consumer.durable_name inside a stream's overrides block was silently accepted but had no effect — the transport continued to derive names from its naming conventions. Starting with v2.13, these fields are read and applied, so the library will use or create entities under the names you supply.
What this means for you:
- If you intentionally set custom names to bind the library to externally provisioned infrastructure, this is the release that makes it work. Combined with the new
ManagementMode.Manual/provisioning.managementoptions, you can now run in full bind-only mode — see Bring Your Own Infrastructure. - If you had
stream.nameorconsumer.durable_nameset by accident (copy-pasted config, leftover experiments), the library will now attempt to use or create streams and consumers under those names instead of the convention-derived ones. Review youroverridesblocks before upgrading — remove any unintentional name fields to keep the previous behavior.
No other breaking changes. Applications that do not set stream.name or consumer.durable_name are unaffected.
See also
- Installation — setup requirements
- Module Configuration — full options reference
- Quick Start — first handler in 5 minutes
- Release Notes — version-by-version changelog