Managed Queues

SQS and Pub/Sub have no built-in Nest transporter, so you consume them from a provider or write a custom transport strategy.

SyntaxNestFactory.createMicroservice<MicroserviceOptions>(AppModule, { strategy: new SqsServer(queueUrl) })

A managed queue is a broker someone else runs: Amazon SQS, Google Pub/Sub. You send a message, the broker keeps it until a consumer takes it and confirms. Against BullMQ on your own Redis you give up fine-grained retry control and gain durability, cross-region delivery and no Redis to patch.

Nest does not ship one

A transporter is the adapter that turns broker messages into calls on handlers marked @EventPattern (fire and forget) or @MessagePattern (request/response). The Transport enum in NestJS 12 has exactly seven members: TCP, REDIS, NATS, MQTT, GRPC, RMQ, KAFKA. SQS and Pub/Sub are not among them, which leaves two honest options: an ordinary provider that polls the queue itself, or a custom transport strategy — a class extending Server so that pattern handlers work as usual.

Shorthand and longhand

{ transport: Transport.RMQ, options } is the shorthand: Nest's ServerFactory runs new ServerRMQ(options) for you. { strategy: new ServerRMQ(options) } is the same thing written out, and strategy is the only form available for a broker Nest does not ship.

Ordering and redelivery

There is no HTTP request here, so no middleware runs. Guards, interceptors and pipes still wrap a pattern handler in that order, and failures reach an RPC exception filter rather than an HTTP one. Delete or acknowledge a message only after the handler succeeds, and expect at-least-once delivery: the same message will arrive twice, so every handler must be idempotent.

Example

Example · typescript
// src/orders/order-consumer.service.ts
import {
  Inject,
  Injectable,
  Logger,
  OnApplicationBootstrap,
  OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
  DeleteMessageCommand,
  Message,
  ReceiveMessageCommand,
  SQSClient,
} from '@aws-sdk/client-sqs';
import { SQS_CLIENT } from '../aws/aws.module';
import { OrdersService } from './orders.service';

@Injectable()
export class OrderConsumer implements OnApplicationBootstrap, OnApplicationShutdown {
  private readonly logger = new Logger(OrderConsumer.name);
  private readonly queueUrl: string;
  private running = false;
  private loop: Promise<void> = Promise.resolve();

  constructor(
    @Inject(SQS_CLIENT) private readonly sqs: SQSClient,
    private readonly orders: OrdersService,
    config: ConfigService,
  ) {
    this.queueUrl = config.getOrThrow<string>('SQS_ORDERS_URL');
  }

  // Runs once every module is initialised, before the app accepts traffic.
  onApplicationBootstrap(): void {
    this.running = true;
    this.loop = this.poll();
  }

  // Runs on SIGTERM when app.enableShutdownHooks() has been called.
  async onApplicationShutdown(signal?: string): Promise<void> {
    this.logger.log(`Draining on ${signal ?? 'close'}`);
    this.running = false;
    await this.loop; // let the in-flight batch finish before the process exits
  }

  private async poll(): Promise<void> {
    while (this.running) {
      // Long polling: one request waits up to 20s for work instead of
      // returning empty immediately. 20 and 10 are the SQS maximums.
      const { Messages = [] } = await this.sqs.send(
        new ReceiveMessageCommand({
          QueueUrl: this.queueUrl,
          MaxNumberOfMessages: 10,
          WaitTimeSeconds: 20,
          MessageSystemAttributeNames: ['ApproximateReceiveCount'],
        }),
      );

      for (const message of Messages) {
        await this.handle(message);
      }
    }
  }

  private async handle(message: Message): Promise<void> {
    try {
      await this.orders.confirm(JSON.parse(message.Body ?? '{}'));
      // Delete only on success. Throwing instead leaves the message to
      // reappear after the visibility timeout, which is the retry.
      await this.sqs.send(
        new DeleteMessageCommand({
          QueueUrl: this.queueUrl,
          ReceiptHandle: message.ReceiptHandle!,
        }),
      );
    } catch (error) {
      const attempts = message.Attributes?.ApproximateReceiveCount ?? '1';
      this.logger.error(`Message ${message.MessageId} failed on attempt ${attempts}`, error);
      // No delete, no rethrow: the queue's redrive policy moves it to the
      // dead-letter queue once maxReceiveCount is reached.
    }
  }
}

When to use it

  • Consume an SQS queue of order events in a worker deployment that serves no HTTP traffic.
  • Publish a domain event from an HTTP handler without waiting for the consumer.
  • Keep @EventPattern handlers while swapping RabbitMQ for SQS behind a custom strategy.
  • Drain in-flight messages on SIGTERM so a rolling deploy loses no work.

More examples

Publishing, and the FIFO fields that only apply to FIFO queues

The producer side, which is just a provider wrapping one SDK command. Use MessageGroupId when per-customer or per-aggregate ordering matters, and MessageDeduplicationId to make a retried publish harmless. Keeping a pattern field in the body is what lets the same queue feed several handlers later.

Example · typescript
// src/orders/order-publisher.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { SendMessageCommand, SQSClient } from '@aws-sdk/client-sqs';
import { SQS_CLIENT } from '../aws/aws.module';

interface OrderCreated {
  orderId: string;
  customerId: string;
  totalCents: number;
}

@Injectable()
export class OrderPublisher {
  private readonly queueUrl: string;

  constructor(
    @Inject(SQS_CLIENT) private readonly sqs: SQSClient,
    config: ConfigService,
  ) {
    this.queueUrl = config.getOrThrow<string>('SQS_ORDERS_URL');
  }

  async orderCreated(event: OrderCreated): Promise<void> {
    await this.sqs.send(
      new SendMessageCommand({
        QueueUrl: this.queueUrl,
        // Carry a pattern name yourself: SQS has no concept of one.
        MessageBody: JSON.stringify({ pattern: 'order.created', data: event }),
        MessageAttributes: {
          schemaVersion: { DataType: 'String', StringValue: '1' },
        },
        // FIFO queues only (the URL ends in .fifo). One group is processed in
        // order; different groups run in parallel. SQS rejects these two
        // fields on a standard queue.
        MessageGroupId: event.customerId,
        MessageDeduplicationId: event.orderId,
      }),
    );
  }
}

A custom transport strategy, so @EventPattern works over SQS

Worth the extra class when you want handlers to look like every other Nest microservice handler: @EventPattern('order.created') in a controller, with guards and interceptors applied by the framework rather than by your polling loop. The interface is small — listen, close, on, unwrap — and Server gives you getHandlerByPattern and handleEvent to dispatch with.

Example · typescript
// src/sqs/sqs.server.ts
import { BaseRpcContext, CustomTransportStrategy, Server } from '@nestjs/microservices';
import {
  DeleteMessageCommand,
  ReceiveMessageCommand,
  SQSClient,
} from '@aws-sdk/client-sqs';

type SqsEvents = { error: (err: unknown) => void };

export class SqsServer extends Server<SqsEvents> implements CustomTransportStrategy {
  // Identifies this transport in logs and in processing hooks.
  transportId = Symbol('SQS');

  private readonly sqs: SQSClient;
  private running = false;

  constructor(private readonly queueUrl: string, region: string) {
    super();
    this.sqs = new SQSClient({ region });
  }

  // Called by app.listen(). Invoke the callback once you are ready.
  listen(callback: (...optionalParams: unknown[]) => void): void {
    this.running = true;
    void this.poll();
    callback();
  }

  // Called on application shutdown.
  close(): void {
    this.running = false;
    this.sqs.destroy();
  }

  // Required by Server. Throwing is legitimate when there is nothing to emit.
  on<K extends keyof SqsEvents>(event: K, callback: SqsEvents[K]): never {
    throw new Error('SqsServer does not expose broker events');
  }

  // Required by Server: hands back the underlying client.
  unwrap<T>(): T {
    return this.sqs as T;
  }

  private async poll(): Promise<void> {
    while (this.running) {
      const { Messages = [] } = await this.sqs.send(
        new ReceiveMessageCommand({
          QueueUrl: this.queueUrl,
          MaxNumberOfMessages: 10,
          WaitTimeSeconds: 20,
        }),
      );

      for (const message of Messages) {
        const { pattern, data } = JSON.parse(message.Body ?? '{}');

        // messageHandlers was filled from the @EventPattern decorators.
        if (!this.getHandlerByPattern(pattern)) continue;

        // handleEvent runs the handler through its guards, interceptors,
        // pipes and exception filters. The context is whatever you pass.
        await this.handleEvent(pattern, { pattern, data }, new BaseRpcContext([message]));

        await this.sqs.send(
          new DeleteMessageCommand({
            QueueUrl: this.queueUrl,
            ReceiptHandle: message.ReceiptHandle!,
          }),
        );
      }
    }
  }
}

// src/main.ts
import { NestFactory } from '@nestjs/core';
import { MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  // Longhand: you build the strategy. The only form for SQS.
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(AppModule, {
    strategy: new SqsServer(process.env.SQS_ORDERS_URL!, process.env.AWS_REGION!),
  });

  // Shorthand, for the seven brokers Nest ships:
  //   { transport: Transport.RMQ, options: { urls: ['amqp://localhost'] } }
  // Nest's ServerFactory turns that into new ServerRMQ(options) -- the same
  // object you would pass as `strategy` yourself.
  void Transport.RMQ;

  await app.listen();
}
void bootstrap();

Google Pub/Sub: a push-style subscription as a provider

Pub/Sub pushes over a streaming connection, so the shape differs from SQS: an event listener instead of a receive loop, ack/nack instead of DeleteMessage, and flowControl.maxMessages instead of MaxNumberOfMessages. Reach for this when the service already runs on Google Cloud. Note that ack() after a thrown error is the most common bug here: it silently drops the message.

Example · typescript
// src/events/pubsub.consumer.ts
import {
  Injectable,
  Logger,
  OnApplicationBootstrap,
  OnApplicationShutdown,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Message, PubSub, Subscription } from '@google-cloud/pubsub';
import { OrdersService } from '../orders/orders.service';

@Injectable()
export class PubSubConsumer implements OnApplicationBootstrap, OnApplicationShutdown {
  private readonly logger = new Logger(PubSubConsumer.name);
  private readonly pubsub: PubSub;
  private subscription?: Subscription;

  constructor(
    private readonly orders: OrdersService,
    private readonly config: ConfigService,
  ) {
    this.pubsub = new PubSub({ projectId: this.config.getOrThrow<string>('GCP_PROJECT_ID') });
  }

  onApplicationBootstrap(): void {
    // The client streams messages to you; there is no polling loop here.
    this.subscription = this.pubsub.subscription('orders-sub', {
      flowControl: { maxMessages: 20 }, // in-flight cap, your backpressure knob
    });

    this.subscription.on('message', async (message: Message) => {
      try {
        // message.data is a Buffer, not a string.
        await this.orders.confirm(JSON.parse(message.data.toString('utf8')));
        message.ack();
      } catch (error) {
        this.logger.error(`Message ${message.id} failed`, error);
        // deliveryAttempt is populated only when the subscription has a
        // dead-letter policy attached.
        message.nack(); // redelivered immediately rather than after the ack deadline
      }
    });

    this.subscription.on('error', (error) => this.logger.error('Subscription error', error));
  }

  async onApplicationShutdown(): Promise<void> {
    await this.subscription?.close(); // stops delivery, waits for callbacks in flight
    await this.pubsub.close();
  }
}

Discussion

  • Be the first to comment on this lesson.