Backend with NestJS
Backend with NestJS

Microservices & messaging: patterns, delivery, idempotent handlers

Full · ~45 min
After this unit you can
  • Choose @MessagePattern + send() or @EventPattern + emit() (on a controller) for a call, and predict what the caller gets back — a reply, an error payload, or nothing
  • Predict when a ClientProxy message leaves — send() is cold, emit() is hot — and bound send() with timeout() without assuming the remote work stops
  • Match a transport to its delivery guarantee (at most once vs at least once) and name where duplicate messages come from
  • Write an idempotent event handler: a stable event id, an atomic claim, release on failure, and in-flight duplicates sent back for redelivery

Read

Simple
1 / 2

Two ways for services to talk — ask, or announce

Flag

In Nest, a microservice is an ordinary Nest app — modules, providers, controllers, DI — that listens on a transport other than HTTP: TCP by default, or a broker such as Redis, NATS, RabbitMQ or Kafka. Instead of routes, its controller methods match patterns, and there are exactly two kinds of conversation.

Ask: request-response

// pricing service
@Controller()
export class PricingController {
  @MessagePattern("price.get")
  getPrice(@Payload() data: { sku: string }) {
    return this.prices.forSku(data.sku);          // the return value is the reply
  }
}

// orders service — the caller
const price = await lastValueFrom(this.pricing.send<number>("price.get", { sku }));

send() waits for an answer. The caller gets the reply or an error — and if nothing ever comes back, it waits forever, unless it set a timeout (next reading).

Announce: events

// shipping service (and the email service, and analytics…)
@Controller()
export class ShippingController {
  @EventPattern("order.paid")
  async onOrderPaid(@Payload() event: OrderPaid) {
    await this.shipments.bookCourier(event.orderId);  // no reply is sent anywhere
  }
}

// orders service
this.events.emit("order.paid", { eventId: `order-paid:${order.id}`, orderId: order.id });

emit() states a fact and moves on. There is no reply: the publisher never learns whether anyone handled the event, or whether a handler threw. Several handlers can subscribe to one event pattern, and Nest runs them in parallel.

Which one?

  • The caller can't continue without the answer — a price, a stock check, a permission decision → send() / @MessagePattern.
  • Something happened, and other services may care → emit() / @EventPattern. The publisher doesn't know who listens, so adding a fourth consumer changes nothing in the orders service.

A request-response call couples the caller's latency and availability to the callee's: if pricing is slow, checkout is slow. An event decouples them — the price of that decoupling is that the publisher can't know the outcome.

One rule both share: these decorators work only on controller methods. Put @EventPattern on a method of an @Injectable() service and Nest ignores it — the event arrives, and the microservice logs "There is no matching event handler defined in the remote service".

Medium
1 / 2

A gateway that calls a service — wiring, errors, timeouts

Flag

The common shape: an HTTP gateway that turns a request into a message to a microservice, and the reply back into an HTTP response. Here's one end to end, with what crosses the wire at each step (behaviour checked on Nest 12.1 over TCP).

Register and inject the client

@Module({
  imports: [
    ClientsModule.register([
      { name: "PRICING", transport: Transport.TCP, options: { host: "pricing", port: 4000 } },
    ]),
  ],
  controllers: [ProductsController],
})
export class GatewayModule {}

name is an injection token, so the client is injected with @Inject("PRICING") — the modules unit's rule for any token that isn't a class. The client connects lazily, on the first message; call await this.pricing.connect() in onApplicationBootstrap() if a missing service should stop the gateway from starting.

Call it, bound it, translate its errors

@Controller("products")
export class ProductsController {
  constructor(@Inject("PRICING") private readonly pricing: ClientProxy) {}

  @Get(":sku/price")
  async price(@Param("sku") sku: string) {
    try {
      return await lastValueFrom(
        this.pricing.send<number>("price.get", { sku }).pipe(timeout(2_000)),
      );
    } catch (err) {
      if (err instanceof TimeoutError) throw new GatewayTimeoutException();
      if (rpcCode(err) === "SKU_NOT_FOUND") throw new NotFoundException(`no price for ${sku}`);
      throw err;                                     // anything else: a 500, and logged
    }
  }
}

/** Errors from another service arrive as plain objects, not Error instances. */
function rpcCode(err: unknown): string | undefined {
  return typeof err === "object" && err !== null && "code" in err ? String(err.code) : undefined;
}

That catch exists because of what errors look like after a trip across the transport:

The pricing handler…The gateway's await rejects with
throws new RpcException("Out of stock")the plain object { status: "error", message: "Out of stock" }
throws new RpcException({ code: "SKU_NOT_FOUND", message: "…" })that object, as is
throws new NotFoundException() or any other Error{ status: "error", message: "Internal server error" }
doesn't exist for that patternthe string "There is no matching message handler defined in the remote service."
can't be reachedan ECONNREFUSED error

Two consequences. HTTP status codes don't travel: a NotFoundException in the microservice arrives as "Internal server error", so 404s must be expressed as data (RpcException with a code) and turned back into HTTP exceptions by the gateway. And what arrives is usually not an Error: the gateway's default exception filter sees a plain object, not an HttpException, and answers 500 unless you translate it. On the microservice side, throw RpcException for anything the caller is meant to understand.

Timeouts and retries

timeout(2_000) makes the gateway give up; it does not stop the pricing handler, which runs to the end. For a read like price.get, a retry is harmless. For stock.reserve, a retry after a timeout can reserve the stock twice — the first attempt was never cancelled, only abandoned. Retry a message only when its handler is idempotent, which is where the rest of this unit goes.

Complex
1 / 2

Idempotent event handlers — the inbox, step by step

Flag

An idempotent handler has the same effect whether a message arrives once or five times. For a handler with a real side effect — booking a courier — that takes a record of which events it has handled: an inbox, kept somewhere every replica can see (Redis, or a table in the consumer's own database). Four decisions make it correct.

1. Dedupe on an id that's the same for every delivery

const key = `shipping:${event.eventId}`;     // ✓ "order-paid:ord_1001", set by the publisher
const key = `shipping:${messageId}`;          // ✗ new on every publish — a retry looks new
const key = `shipping:${hash(payload)}`;      // ✗ breaks the moment the payload has a timestamp

The id comes from the data: an order is paid once, so order-paid:<orderId> names the event however many times it's sent. The shipping: prefix is the consumer's name. Two services consuming order.paid (shipping books a courier, email sends a receipt) each need to handle every event once, so each keeps its own records.

2. Claim it atomically

// ✗ check-then-act: two concurrent deliveries can both see "absent"
if ((await inbox.get(key)) === undefined) await inbox.set(key, "in-progress", CLAIM_TTL_MS);

// ✓ one atomic step — Redis: SET key in-progress NX PX 60000
const claimed = await inbox.setIfAbsent(key, "in-progress", CLAIM_TTL_MS);

Between the get and the set, another delivery — on this replica or another — can run the same check and also see "absent". SET … NX checks and writes inside Redis as one command, so exactly one delivery wins the claim.

3. Two states, two TTLs, and release on failure

try {
  await this.shipments.bookCourier(event.orderId);
} catch (err) {
  await inbox.delete(key);                     // let the redelivery do the work
  throw err;                                   // and make sure there IS a redelivery
}
await inbox.set(key, "done", DONE_TTL_MS);
  • in-progress with a short TTL. If the worker dies holding the claim, it expires and a redelivery takes over.
  • done only after the side effect, kept longer than anything could be redelivered — days, if dead-lettered messages can be replayed. Marking it done first is at-most-once by accident: one failure, and every redelivery is skipped.
  • Release on failure, or the redelivery waits out the claim for nothing.

4. Don't acknowledge an in-flight duplicate

A delivery that loses the claim reads the state. done: acknowledge it, it's a true duplicate. in-progress: don't acknowledge it as handled — send it back. If you ack it and the first attempt then fails, no copy of the event is left to retry. How you send it back depends on the transport: on Kafka, throw (event-handler errors are retriable, so the offset isn't committed); on RabbitMQ with manual acks, nack it with requeue — a delivery that's simply never acked only returns when its channel closes. A requeued message can be redelivered immediately, so a short delay before requeueing keeps one slow attempt from becoming a redelivery loop.

Quiz

After checkout, the orders service must let shipping, email and analytics know an order was paid. It needs nothing back. Which fits?

Flag

A service ends with this.audit.send("audit.record", entry); return order;. Audit records never show up, and nothing errors. Why?

Flag

The pricing microservice throws new NotFoundException("no such sku"). The HTTP gateway does return await lastValueFrom(this.pricing.send("price.get", { sku })) with no catch. What does the gateway's HTTP client get?

Flag

A consumer uses the RabbitMQ transporter with default options. It crashes halfway through handling a message. What happens to that message?

Flag

An @EventPattern("order.paid") consumer runs on the Redis transporter. You scale it from one replica to three. What changes?

Flag

Which value should an order.paid consumer's inbox use as its dedupe key?

Flag

A second delivery of an event arrives while the first is still booking the courier. The inbox shows in-progress. What should the handler do?

Flag

@EventPattern("user.created") is on a method of @Injectable() EmailService, which is in the module's providers. Events are published. What happens?

Flag

Fill in the blank

A pricing service answers questions; a shipping service reacts to announcements.

Flag
@("price.get")
getPrice(@Payload() data: { sku: string }) { return this.prices.forSku(data.sku); }

@("order.paid")
async onOrderPaid(@Payload() event: OrderPaid) { await this.shipments.book(event.orderId); }

Send the request, wait for the reply as a promise, and give up after two seconds.

Flag
const price = await (this.pricing.send<number>("price.get", { sku }).pipe((2000)));

In the microservice, report a failure the gateway can map to a 404.

Flag
throw new ({ code: "SKU_NOT_FOUND", message: `no price for ${sku}` });

Claim an event in Redis so that exactly one of several concurrent deliveries wins, and the claim expires if its holder dies.

Flag
SET inbox:shipping:order-paid:ord_1001 in-progress  PX 60000

Switch the RabbitMQ transporter to manual acknowledgements, and acknowledge a message after the work is done.

Flag
options: { urls: ["amqp://broker:5672"], queue: "shipping", noAck:  }

@EventPattern("order.paid")
async onOrderPaid(@Payload() event: OrderPaid, @Ctx() context: RmqContext) {
  await this.shipping.handle(event);
  context.getChannelRef().(context.getMessage());
}

Predict the output

// Nest 12, TCP. The remote service has @MessagePattern("sum") and @EventPattern("user.created").
this.client.send("sum", [1, 2]);
this.client.emit("user.created", { id: 1 });

const sum$ = this.client.send("sum", [1, 2]);
await lastValueFrom(sum$);
await lastValueFrom(sum$);
Flag
// pricing service
@MessagePattern("price.history")
history() {
  return from([1199, 1249, 1299]);
}

// caller
const price = await lastValueFrom(this.pricing.send<number>("price.history", {}));
console.log(price);
Flag
// stock service
@MessagePattern("stock.reserve")
async reserve(@Payload() data: { sku: string }) {
  await sleep(300);                      // a slow database
  console.log("reserved", data.sku);
  return true;
}

// gateway
await lastValueFrom(this.stock.send("stock.reserve", { sku: "TEA-1" }).pipe(timeout(100)));
Flag

Challenge

Make the order.paid handler idempotent

Flag

The shipping service consumes order.paid events and books a courier for each one. In Nest this is the body of an @EventPattern("order.paid") handler; here it's a plain function so the sandbox can run it. The broker delivers at least once: a publisher retries after a timeout (same event, new messageId), and the broker redelivers anything that wasn't acknowledged — a crash, an error, or a "retry-later" outcome. Booking a courier is not idempotent on its own: call it twice and the customer gets two parcels.

inbox.ts is the consumer's inbox in Redis, shared by every replica of the service: get, set, delete, and setIfAbsent — Redis's SET key value NX PX ttl, the one operation that checks and writes as a single atomic step. Every call is a network round trip, so every call awaits. shipments.ts books couriers, and can be told to fail once, to wait until the test releases it (a slow courier API), or to hang forever (a worker that died mid-call). clock.ts lets the tests step past a TTL.

The handler survives a demo where every event arrives exactly once. Under real delivery it books duplicate parcels, and sometimes none:

  1. The wrong key. It dedupes on messageId, which is new for every publish.
  2. Check, then act. Two deliveries can both read "not seen" before either writes.
  3. Done too early. It records the event as done before booking, so if booking fails — or the worker dies — every redelivery is skipped as a duplicate and the order never ships.
  4. In-flight duplicates. A delivery that arrives while the first one is still booking is acknowledged as a duplicate. If the first attempt then fails, nothing is left to retry it.

Your task

Edit order-paid-handler.ts so the tests pass:

  • A publisher's retry of an event — same eventId, new messageId — books no second shipment and is acknowledged as "duplicate", even after CLAIM_TTL_MS has passed
  • Two deliveries of one event arriving at the same time book exactly one shipment
  • When booking the shipment fails, the error reaches the broker and the redelivery books it
  • If a worker dies mid-delivery, a redelivery after CLAIM_TTL_MS books the shipment
  • A delivery that arrives while another is still booking the shipment returns "retry-later"; once the first is done, further deliveries return "duplicate"

Loading the in-browser editor…

Further reading