Message and Event Patterns

@MessagePattern waits for a reply, @EventPattern does not, and send() transmits nothing until something subscribes to it.

Syntax@MessagePattern(pattern: string | object, transport?: Transport | symbol, extras?: Record<string, any>)

A pattern is how a message finds its handler: the microservice equivalent of a route. It is either a string such as 'payment.captured' or a plain object such as { cmd: 'order.find' }. Nest normalises either into a single route string and keeps a map from route to handler.

There are two kinds of handler. @MessagePattern is request-response: whatever the method returns - a value, a resolved promise, or every value an Observable emits - is serialised and sent back, and the caller is waiting for it. A handler that returns an Observable of three items replies three times, and the caller's Observable emits all three, which is why calling code that wants a single answer pipes first() or uses firstValueFrom(). @EventPattern is fire-and-forget: there is no reply channel, so the return value is discarded.

The two client calls

send() returns a cold Observable. Nothing is written to the wire until something subscribes, so a forgotten subscription is a message that never leaves. emit() returns a hot one: the event is published whether you subscribe or not, and awaiting it only tells you the broker accepted it.

Where a handler sits

An RPC handler runs the same chain as an HTTP route, minus middleware - there is no HTTP layer for middleware to attach to. In order: a global preRequest hook (added in NestJS 12), then guards, then interceptors on the way in, then pipes, then the handler, then those interceptors again on the way out, then exception filters if anything threw. In an event handler the filter is the end of the line, because the error has no caller to travel to.

Choosing

Ask whether the caller needs the answer to continue. "What is this order's total?" is a message. "This payment was captured" is an event: a fact, already true, that zero or more services may care about.

Example

Example · typescript
// src/orders/orders.controller.ts
import { Controller, Logger } from '@nestjs/common';
import { Ctx, EventPattern, MessagePattern, Payload, RedisContext } from '@nestjs/microservices';
import { OrdersService } from './orders.service.js';

// A microservice controller takes @Controller() with no path argument:
// there are no routes here, only patterns.
@Controller()
export class OrdersController {
  private readonly logger = new Logger(OrdersController.name);

  constructor(private readonly orders: OrdersService) {}

  // Request-response. The resolved value is sent back as the reply, so the
  // return type is half of the contract the caller compiles against.
  @MessagePattern({ cmd: 'order.find' })
  find(@Payload('id') id: string) {
    // @Payload('id') is shorthand for taking the whole payload and reading
    // .id off it: find(@Payload() body: { id: string }) { body.id }
    return this.orders.findOne(id);
  }

  // Fire-and-forget. No reply channel exists, so a returned value goes
  // nowhere and the caller has already continued.
  @EventPattern('payment.captured')
  async onPaymentCaptured(
    @Payload() payload: { orderId: string; cents: number },
    @Ctx() ctx: RedisContext,
  ) {
    this.logger.log(`channel ${ctx.getChannel()} -> ${payload.orderId}`);
    await this.orders.markPaid(payload.orderId, payload.cents);
  }
}

When to use it

  • Fetch an order's total from another service while the HTTP request waits.
  • Tell three services that a payment was captured without waiting for any of them.
  • Group related calls under one object pattern such as { cmd: 'order.find' }.
  • Find out why a published event is never handled on the other side.

More examples

Subscribing: shorthand and longhand

Written out, it is obvious why a forgotten subscription sends nothing: send() builds an Observable and the publish happens inside subscribe(). Keep the shorthand in real code, but recognise the long form when you read a service that wires its own subscriptions, and remember that emit() behaves the other way round.

Example · typescript
// src/gateway/gateway.service.ts
import { Inject, Injectable } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom, lastValueFrom, Observable, take } from 'rxjs';

interface Order {
  id: string;
  totalCents: number;
}

@Injectable()
export class GatewayService {
  constructor(@Inject('ORDERS') private readonly client: ClientProxy) {}

  // Shorthand: firstValueFrom subscribes, resolves with the first value and
  // then unsubscribes. This is what almost all calling code should look like.
  find(id: string): Promise<Order> {
    return firstValueFrom(
      this.client.send<Order, { id: string }>({ cmd: 'order.find' }, { id }),
    );
  }

  // Longhand: the same thing written out. Note that nothing has been sent
  // after the first statement - send() only describes the call.
  findLonghand(id: string): Promise<Order> {
    const response$: Observable<Order> = this.client.send({ cmd: 'order.find' }, { id });
    return new Promise<Order>((resolve, reject) => {
      // take(1) unsubscribes after the first value, which is the part
      // firstValueFrom does for you.
      response$.pipe(take(1)).subscribe({ next: resolve, error: reject });
    });
  }

  // emit() is hot: the event goes out regardless. Awaiting it confirms the
  // broker accepted it, not that anybody handled it. The stream can complete
  // without a value, so lastValueFrom needs a defaultValue.
  async publishPaid(orderId: string, cents: number): Promise<void> {
    await lastValueFrom(this.client.emit('payment.captured', { orderId, cents }), {
      defaultValue: undefined,
    });
  }
}

Typed event patterns (NestJS 12)

Before NestJS 12 the topic was just a string and nothing connected it to the payload type. The typed overload closes that gap on the consumer side at no runtime cost. Use it as soon as more than one service subscribes to the same events, since the event map file becomes the thing you review when a payload changes.

Example · typescript
// src/contracts/app-events.ts
// One map of event name to payload, imported by publishers and consumers.
export interface AppEvents {
  'payment.captured': { orderId: string; cents: number };
  'order.cancelled': { orderId: string; reason: string };
}

// src/orders/orders.events.controller.ts
import { Controller } from '@nestjs/common';
import { EventPattern, Payload } from '@nestjs/microservices';
import { AppEvents } from '../contracts/app-events.js';
import { OrdersService } from './orders.service.js';

@Controller()
export class OrdersEventsController {
  constructor(private readonly orders: OrdersService) {}

  // NestJS 12 added typed overloads to @EventPattern. The generic names the
  // event map, the topic must be a key of it, and the decorator constrains
  // the handler's first parameter to that key's payload type. A typo in the
  // topic is now a compile error instead of a handler that never fires.
  @EventPattern<AppEvents>('payment.captured')
  onPaid(@Payload() data: AppEvents['payment.captured']) {
    return this.orders.markPaid(data.orderId, data.cents);
  }

  @EventPattern<AppEvents>('order.cancelled')
  onCancelled(@Payload() data: AppEvents['order.cancelled']) {
    return this.orders.cancel(data.orderId, data.reason);
  }
}

Where an error goes in each case

Filters run last in the chain, after the handler and after the interceptors unwind. The asymmetry is what matters: for @MessagePattern the Observable this filter returns becomes the caller's error, while for @EventPattern nobody is listening, so an event handler without a logging filter fails invisibly.

Example · typescript
// src/common/rpc-exception.filter.ts
import { ArgumentsHost, Catch, Logger } from '@nestjs/common';
import { BaseRpcExceptionFilter } from '@nestjs/microservices';
import { Observable } from 'rxjs';

// @Catch() with no arguments catches everything thrown by a pattern handler.
@Catch()
export class RpcLoggingFilter extends BaseRpcExceptionFilter {
  private readonly logger = new Logger(RpcLoggingFilter.name);

  catch(exception: unknown, host: ArgumentsHost): Observable<any> {
    const rpc = host.switchToRpc();

    // getData() is the payload, getContext() is the transport context object
    // (RedisContext, RmqContext, KafkaContext, ...). For an event handler
    // this log line is the only trace the failure will ever leave.
    this.logger.error(
      { data: rpc.getData(), context: rpc.getContext() },
      exception instanceof Error ? exception.stack : String(exception),
    );

    // The base filter rethrows an RpcException's payload to the caller, and
    // replaces anything else with { status: 'error', message: 'Internal
    // server error' } so internals do not leak.
    return super.catch(exception, host);
  }
}

// src/main.ts (excerpt)
// app.useGlobalFilters(new RpcLoggingFilter());

Discussion

  • Be the first to comment on this lesson.