Skip to main content

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.

Already using this library and upgrading to a newer version?

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_deliver is exhausted.
  • Dead letters. Built-in silently drops failed messages. JetStream provides a configurable dead-letter flow (Dead Letter Queue).
  • Decorators. Unchanged. @EventPattern and @MessagePattern work 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:

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.management options, you can now run in full bind-only mode — see Bring Your Own Infrastructure.
  • If you had stream.name or consumer.durable_name set 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 your overrides blocks 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