Microservices & messaging: patterns, delivery, idempotent handlers
- 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
ClientProxymessage leaves —send()is cold,emit()is hot — and boundsend()withtimeout()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
Two ways for services to talk — ask, or announce
FlagIn 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".
A gateway that calls a service — wiring, errors, timeouts
FlagThe 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 pattern | the string "There is no matching message handler defined in the remote service." |
| can't be reached | an 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.
Idempotent event handlers — the inbox, step by step
FlagAn 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-progresswith a short TTL. If the worker dies holding the claim, it expires and a redelivery takes over.doneonly 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?
FlagA service ends with this.audit.send("audit.record", entry); return order;. Audit records never show up, and nothing errors. Why?
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?
A consumer uses the RabbitMQ transporter with default options. It crashes halfway through handling a message. What happens to that message?
FlagAn @EventPattern("order.paid") consumer runs on the Redis transporter. You scale it from one replica to three. What changes?
Which value should an order.paid consumer's inbox use as its dedupe key?
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?
@EventPattern("user.created") is on a method of @Injectable() EmailService, which is in the module's providers. Events are published. What happens?
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.
Flagconst price = await (this.pricing.send<number>("price.get", { sku }).pipe((2000)));
In the microservice, report a failure the gateway can map to a 404.
Flagthrow 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.
FlagSET 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.
Flagoptions: { 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$);// 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);// 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)));Challenge
Make the order.paid handler idempotent
FlagThe 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:
- The wrong key. It dedupes on
messageId, which is new for every publish. - Check, then act. Two deliveries can both read "not seen" before either writes.
- 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.
- 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, newmessageId— books no second shipment and is acknowledged as"duplicate", even afterCLAIM_TTL_MShas 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_MSbooks 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"
Further reading
- NestJS docs: Microservices overview
- NestJS docs: Microservice exception filters
- NestJS docs: Redis transporter
- NestJS docs: NATS transporter
- NestJS docs: RabbitMQ transporter
- NestJS docs: Kafka transporter
- NestJS docs: Idempotency keys
- NestJS docs: Outbox
- Redis docs: Pub/sub
- Redis docs: SET
- RabbitMQ docs: Consumer acknowledgements
- NATS docs: Core NATS