In the previous lesson we fixed TechCorp's REST contracts, but we made it clear that the synchronous calls would be few: Orders → Catalog and Orders → Customers. The rest of the "a customer places an order" flow (reserving stock, charging, confirming, notifying) was designed in 02-05 as a choreography saga in which the services do not call each other, but rather publish and consume events. Until now those events (order.created, stock.reserved, payment.confirmed, order.confirmed, order.cancelled) have been names in a diagram. This lesson turns them into real messages that travel through RabbitMQ.
We will first look at when asynchronous communication is appropriate and which coupling it removes; then the essential vocabulary (message, event and command, queue and pub-sub, broker, ack, redelivery, dead-letter queue, ordering and delivery guarantees); next RabbitMQ in detail (exchanges, routing keys, queues, bindings) with TechCorp's concrete topology; the amqplib code to publish the standard event envelope and to consume with prefetch, ack/nack and forwarding to the DLQ; how it hooks into the outbox and the idempotency from 02-05; and a comparison of RabbitMQ with Kafka and managed cloud queues that justifies TechCorp's choice. The end-to-end integration of a real service (with its outbox, its consumers starting alongside Express) belongs to 04-04, and deploying the broker to module 5.
Contents
- Synchronous versus asynchronous: which coupling we remove
- Messaging vocabulary
- Delivery guarantees: at-most-once, at-least-once, exactly-once
- RabbitMQ: exchanges, queues, routing keys and bindings
- TechCorp's topology
- Publishing an event with
amqplib - Consuming events:
prefetch,ack,nackand DLQ - Hooking into outbox and idempotency
- RabbitMQ versus Kafka and managed queues
- Synchronous versus asynchronous: which coupling we remove
When Orders calls GET /products?ids= and waits for the response, Orders and Catalog have to be alive at the same instant: this is the temporal coupling from 02-01. With six services chained in the order flow, that coupling would be fatal: the availability of the whole would be the product of the six, and an outage in Notifications would prevent orders from being created. Asynchronous messaging breaks that chain: Orders leaves the event in the broker and moves on; Inventory will pick it up when it can, even if that is a minute later.
| Criterion | Synchronous (REST, gRPC) | Asynchronous (messages) |
|---|---|---|
| The caller waits for the response | Yes | No; it carries on with its work |
| Temporal coupling | Yes: both must be available | No: the broker stores the message |
| Who knows whom | The caller knows the receiver (URL) | The producer does not know who consumes |
| Receiver failure | Immediate error to the caller | The message waits; it is processed when the receiver is back |
| Load peaks | The receiver suffers them in real time | The queue absorbs them |
| Mental model | Question and answer | Fact that happened / order given |
| Debugging | Simple: one linear trace | Harder: cause and effect separated in time (06-02) |
| When TechCorp uses it | When we need the response to continue: looking up price and name before saving the order | When the result is not needed right now: reserving, charging, notifying |
The practical rule TechCorp will apply: asynchronous between services by default; synchronous only when the response is essential to answer the user. It is the same conclusion as 02-02, now with the technical justification.
- Messaging vocabulary
- Message. The unit of data that travels through the broker: some headers and a body (at TechCorp, the JSON envelope from 02-05).
- Event versus command. An event describes something that has already happened (
order.created); whoever publishes it expects nothing from anyone, and there may be zero or many consumers. A command is an instruction aimed at a specific receiver (reserve-stock): whoever sends it expects someone to execute it. The choreography saga from 02-05 uses only events; an orchestration saga would use commands. The distinction matters because it changes who decides: with events, the consumer decides whether it is interested; with commands, the sender decides whom to address. - Producer (publisher) and consumer (subscriber). Who sends and who receives. A service is usually both: Inventory consumes
order.createdand producesstock.reserved. - Broker. The intermediary that receives, stores and delivers messages: RabbitMQ, Kafka, SQS. It is the "dumb pipe" from 02-01: it routes, it does not decide.
- Queue. A FIFO buffer from which one or more consumers compete for messages: each message is processed by one instance. Ideal for spreading work across replicas of the same service.
- Topic / pub-sub. A message is copied to every interested subscriber. Ideal for events:
order.confirmedis of interest to Notifications and to Inventory, and both must receive it. In RabbitMQ, the combination "one exchange that fans out to several queues, each queue with the replicas of one service competing" gives both behaviors at once. - Ack / nack. The consumer confirms (acknowledges) that it has processed the message; until then the broker considers it pending. If the consumer rejects it (negative ack) or dies without confirming, the broker redelivers it, to the same or to another instance.
- Dead-letter queue (DLQ). The queue a message goes to when it could not be processed (rejected without requeue, expired or evicted by a limit). It avoids the "poison message" that is redelivered forever and lets you inspect it and reprocess it by hand.
- Ordering. RabbitMQ preserves order within a queue for a single consumer; with several replicas consuming in parallel, global order is not guaranteed. That is why the design from 02-05 makes every event self-contained and has the order state machine reject out-of-order transitions.
- Delivery guarantees: at-most-once, at-least-once, exactly-once
| Guarantee | How it is achieved | What can happen | When it is acceptable |
|---|---|---|---|
| At-most-once | The broker delivers and forgets; the consumer acks before processing (or there is no ack) | Messages are lost if the consumer fails midway | Metrics, non-critical logs |
| At-least-once | Persistent messages; the consumer acks after processing; on failure, redelivery | Messages are duplicated: if the consumer processes and dies before the ack, it receives it again | Everything business-related: orders, payments |
| Exactly-once | Requires broker and consumer to share a transaction, or the consumer to deduplicate | In practice it does not exist end to end across different systems (the broker cannot know whether your UPDATE in PostgreSQL was committed) |
Only within one and the same system (Kafka Streams between Kafka topics) |
The realistic conclusion, already anticipated by 02-05: we choose at-least-once + idempotency. The broker guarantees that no event is lost (even if some arrive twice), and the consumer guarantees that processing the same eventId twice has no effect (processOnce() with the processed_events table). It is the only combination that works with one database per service.
- RabbitMQ: exchanges, queues, routing keys and bindings
RabbitMQ implements the AMQP 0-9-1 protocol, whose model has four pieces:
- Exchange. The producer never publishes directly to a queue: it publishes to an exchange with a routing key (a string like
order.created). The exchange decides which queues to copy the message to depending on its type:direct: to the queues whose binding matches the routing key exactly.fanout: to every bound queue, ignoring the routing key.topic: to the queues whose binding pattern matches the routing key, with wildcards:*stands for exactly one word (separated by dots) and#for zero or more.order.*matchesorder.createdandorder.cancelled;#matches everything.headers: routes by headers; rarely used.
- Queue. Where messages wait. It can be durable (survives a broker restart) and hold persistent messages (written to disk). For at-least-once you need both.
- Binding. The rule that ties an exchange to a queue: "messages whose routing key matches
order.createdgo to theinventory.ordersqueue". - Channel. A logical connection multiplexed inside a TCP connection; each thread or consumer uses its own.
A message arrives at an exchange with its routing key, the exchange copies it to every queue whose binding matches (that is how pub-sub is achieved), and within each queue the replicas of the consuming service compete (that is how load distribution is achieved). One and the same message can end up in three queues and be processed once in each.
- TechCorp's topology
Decisions:
- A single exchange of type
topic, namedtechcorp.events, durable. Every service publishes to it. - The routing key is equal to the event type:
order.created,stock.reserved,payment.confirmed, etc. That way the event name and its routing are the same thing and there are not two vocabularies to maintain. - One queue per consuming service, named
<consumer>.<topic>, with bindings to the events it cares about. The replicas of a service share its queue (load distribution); different services have different queues (each receives its own copy). - Each queue has an associated DLQ (
<queue>.dlq) through atechcorp.events.dlxexchange of typedirect.
| Queue | Service | Bindings (routing keys) | What it does with them |
|---|---|---|---|
inventory.orders |
Inventory | order.created, order.confirmed, order.cancelled |
Reserves stock; consumes the reservation; releases the reservation |
payments.stock |
Payments | stock.reserved, order.cancelled |
Charges when there is a reservation; refunds if the order is cancelled after charging |
notifications.orders |
Notifications | order.confirmed, order.cancelled |
Sends the confirmation or cancellation email |
orders.saga |
Orders | stock.reserved, stock.rejected, payment.confirmed, payment.rejected |
Advances the order state machine and publishes order.confirmed / order.cancelled |
orders.customers |
Orders | customer.updated |
Maintains the customers_ref replica from 02-04 |
flowchart LR
subgraph Producers
P[orders-service]
I[inventory-service]
G[payments-service]
C[customers-service]
end
X{{"exchange techcorp.events (topic)"}}
P -- "order.created / order.confirmed / order.cancelled" --> X
I -- "stock.reserved / stock.rejected / stock.released" --> X
G -- "payment.confirmed / payment.rejected / payment.refunded" --> X
C -- "customer.updated" --> X
X -- "order.created, order.confirmed, order.cancelled" --> Q1[(inventory.orders)]
X -- "stock.reserved, order.cancelled" --> Q2[(payments.stock)]
X -- "order.confirmed, order.cancelled" --> Q3[(notifications.orders)]
X -- "stock.*, payment.*" --> Q4[(orders.saga)]
X -- "customer.updated" --> Q5[(orders.customers)]
Q1 --> CI[Inventory x N replicas]
Q2 --> CG[Payments x N]
Q3 --> CN[Notifications x N]
Q4 --> CP[Orders x N]
Q5 --> CP
Q1 -. "nack without requeue" .-> DLX{{"techcorp.events.dlx"}}
Q2 -.-> DLX
Q3 -.-> DLX
Q4 -.-> DLX
DLX --> D1[(inventory.orders.dlq)]
DLX --> D2[(payments.stock.dlq)]
DLX --> D3[(notifications.orders.dlq)]
DLX --> D4[(orders.saga.dlq)]
Notice that Orders publishes order.confirmed upon receiving payment.confirmed, and that event is consumed by two different queues (Notifications and Inventory): the copy is made by the exchange, not by Orders. Orders neither knows nor cares how many consumers there are: that is the "conformist" role of Notifications and the asymmetry of the context map from 02-03 turned into topology.
About orders.saga with the stock.* and payment.* bindings: it is convenient, but it would also receive stock.released and payment.refunded, which Orders does not need. In practice the four explicit bindings from the table are declared; the wildcard appears in the diagram only for brevity.
- Publishing an event with
amqplib
amqplibamqplib is the standard Node.js library for AMQP 0-9-1. First, the topology declaration. Each service declares at startup what it uses (the exchange and its own queues); assert* is idempotent: if it already exists with the same parameters, it does nothing.
// messaging/topology.js (shared via @techcorp/common-http or copied into each service)
const amqp = require('amqplib');
const EXCHANGE = 'techcorp.events';
const EXCHANGE_DLX = 'techcorp.events.dlx';
async function connect(url = process.env.RABBITMQ_URL ?? 'amqp://localhost:5672') {
// 1. One TCP connection per process...
const connection = await amqp.connect(url);
// 2. ...and one channel per use (here one for publishing; consumers will open their own)
const channel = await connection.createChannel();
// 3. Declare the main exchange (topic, durable) and the dead-letter one (direct, durable)
await channel.assertExchange(EXCHANGE, 'topic', { durable: true });
await channel.assertExchange(EXCHANGE_DLX, 'direct', { durable: true });
return { connection, channel };
}
// Declares a consumer queue with its DLQ and its bindings
async function declareConsumerQueue(channel, queueName, routingKeys) {
// 4. The DLQ: durable queue bound to the DLX exchange with routing key = name of the original queue
await channel.assertQueue(`${queueName}.dlq`, { durable: true });
await channel.bindQueue(`${queueName}.dlq`, EXCHANGE_DLX, queueName);
// 5. The main queue: durable and with dead-lettering configured
await channel.assertQueue(queueName, {
durable: true,
arguments: {
'x-dead-letter-exchange': EXCHANGE_DLX, // where rejected messages go
'x-dead-letter-routing-key': queueName // with which routing key (→ its .dlq)
}
});
// 6. One binding for each event type this consumer cares about
for (const rk of routingKeys) {
await channel.bindQueue(queueName, EXCHANGE, rk);
}
}
module.exports = { connect, declareConsumerQueue, EXCHANGE };Now the publishing. The function receives the standard event envelope from 02-05 already built (eventId, type, version, occurredAt, payload) and sends it:
// messaging/publisher.js
const { randomUUID } = require('node:crypto');
const { EXCHANGE } = require('./topology');
function buildEnvelope(type, payload, { version = 1 } = {}) {
return {
eventId: `evt-${randomUUID()}`, // unique id: used by the consumer's processOnce()
type, // 'order.created'
version, // payload schema version (03-06)
occurredAt: new Date().toISOString(), // when it happened, in UTC
payload // the business JSON
};
}
function publishEvent(channel, envelope) {
const body = Buffer.from(JSON.stringify(envelope));
// publish returns false if the internal buffer is full (backpressure); we deal with it in 06-04
return channel.publish(
EXCHANGE, // target exchange
envelope.type, // routing key = event type
body,
{
persistent: true, // written to disk: survives a broker restart
contentType: 'application/json',
messageId: envelope.eventId, // we duplicate the id in the AMQP header for tooling
type: envelope.type,
timestamp: Math.floor(Date.now() / 1000),
headers: { 'x-version': envelope.version }
}
);
}
module.exports = { buildEnvelope, publishEvent };And its use from the Orders outbox relay, with the order.created event for order ord-88213 (the payload JSON is the one we fixed in 02-05):
const envelope = buildEnvelope('order.created', {
orderId: 'ord-88213',
customerId: 'c-1024',
customer: { email: '[email protected]', name: 'Ana Ruiz' },
shippingAddress: { street: 'Gran Vía 12', postalCode: '28013', city: 'Madrid', country: 'ES' },
lines: [
{ productId: 'p-501', name: 'BT X200 Headphones', quantity: 1, unitPrice: 59.90 },
{ productId: 'p-777', name: 'USB-C Cable 2 m', quantity: 2, unitPrice: 9.90 }
],
total: 79.70
});
publishEvent(channel, envelope);Three details that make the difference between "works on my machine" and "does not lose orders":
persistent: trueanddurable: truequeues go together. A persistent message in a non-durable queue is lost on restart all the same; a durable queue with non-persistent messages, too.- Publisher confirms. With
createChannel(),publishis "fire and forget": if RabbitMQ goes down right then, the message is lost without an error. WithcreateConfirmChannel()the broker confirms each publication and we can wait withawait channel.waitForConfirms()before marking the outbox row as sent. This is what the relay will use in 04-04. - The
eventIdtravels in the body and inmessageId. In the body because it is part of the event contract; in the header because the RabbitMQ console and the DLQ tools show it without opening the JSON.
- Consuming events:
prefetch, ack, nack and DLQ
prefetch, ack, nack and DLQThe Inventory consumer for the inventory.orders queue:
// messaging/inventoryConsumer.js (inventory-service)
const { connect, declareConsumerQueue } = require('./topology');
async function startConsumer({ handlers, processOnce }) {
const { connection, channel } = await connect();
const QUEUE = 'inventory.orders';
await declareConsumerQueue(channel, QUEUE, ['order.created', 'order.confirmed', 'order.cancelled']);
// 1. prefetch: how many unacked messages this instance may hold at once.
// Without it, RabbitMQ would dump the whole queue onto the first replica that connects.
await channel.prefetch(10);
await channel.consume(QUEUE, async (msg) => {
if (msg === null) return; // the channel has been closed
let envelope;
try {
envelope = JSON.parse(msg.content.toString());
} catch (err) {
// 2. A message that is not even JSON: retrying makes no sense → to the DLQ
// nack(msg, allUpTo=false, requeue=false) → RabbitMQ sends it to the configured DLX
console.error('Unreadable message, sent to DLQ', { messageId: msg.properties.messageId });
return channel.nack(msg, false, false);
}
const handler = handlers[envelope.type];
if (!handler) {
// 3. Event with a binding but no handler (e.g. a half-deployed version): DLQ, don't lose it
console.warn('No handler for type', { type: envelope.type, eventId: envelope.eventId });
return channel.nack(msg, false, false);
}
try {
// 4. processOnce (02-05): if eventId is already in processed_events for 'inventory', it does nothing
await processOnce(envelope.eventId, 'inventory', () => handler(envelope));
// 5. All good: ack. Only now does RabbitMQ delete the message from the queue
channel.ack(msg);
} catch (err) {
// 6. Error while processing. Is it transient (DB down) or permanent (impossible data)?
const isFirstAttempt = !msg.fields.redelivered;
if (err.transient && isFirstAttempt) {
// Requeue ONCE: RabbitMQ will deliver it again (to this or another replica)
console.warn('Transient error, requeuing', { eventId: envelope.eventId, error: err.message });
channel.nack(msg, false, true);
} else {
// Second failure or permanent error: to the DLQ for human inspection
console.error('Event sent to DLQ', { eventId: envelope.eventId, type: envelope.type, error: err.message });
channel.nack(msg, false, false);
}
}
}, { noAck: false }); // 7. noAck: false = at-least-once mode (manual ack). It is the default, but we make it explicit
// 8. Graceful shutdown: when the process stops, close channel and connection so unacked messages are redelivered right away
process.on('SIGTERM', async () => { await channel.close(); await connection.close(); });
}
module.exports = { startConsumer };And the handlers that Inventory registers (signature only; the reservation logic belongs to module 4):
startConsumer({
processOnce,
handlers: {
'order.created': (envelope) => reserveStockForOrder(envelope.payload), // → publishes stock.reserved or stock.rejected
'order.confirmed': (envelope) => consumeReservation(envelope.payload.orderId), // ACTIVE → CONSUMED
'order.cancelled': (envelope) => releaseReservation(envelope.payload.orderId) // ACTIVE → RELEASED, publishes stock.released
}
});Points worth understanding well:
prefetch(10)limits the in-flight work per replica. A low value spreads work better across replicas and prevents a dying process from dragging hundreds of messages into redelivery; a high value increases throughput. Ten is a reasonable starting point for handlers that touch the database.ackafter processing is what gives at-least-once. If the process dies betweenhandler()andack, RabbitMQ redelivers andprocessOnceprevents the double effect. Neverackat the beginning "so it doesn't get stuck": that is at-most-once with a pretty name.nack(msg, false, requeue): the second argument (allUpTo) also rejects all previous unacked messages; almost alwaysfalse. The third decides between requeuing (true) and discarding/dead-lettering (false).- Requeuing without a limit is an infinite loop. A message that always fails would go back to the head of the queue and block the rest. That is why it is requeued at most once (
msg.fields.redeliveredsays whether it already was) and then goes to the DLQ. Retries with growing backoff are covered in 06-03; RabbitMQ does not provide them out of the box. - The DLQ is not consumed automatically. Someone (an alert from 06-05 and a person) looks at
inventory.orders.dlq, understands why it failed and decides whether to reprocess (move the message back) or discard.
- Hooking into outbox and idempotency
With what we have seen, the complete chain of guarantees for "a customer places an order" looks like this, and it is worth seeing it all together even though each piece was designed in 02-05:
| Risk | Piece that covers it | Where it lives |
|---|---|---|
The order is saved but order.created is not published (or the reverse) |
Transactional outbox: order and event in the same transaction; a relay reads outbox and calls publishEvent |
Orders (saveWithEvents()) |
| The relay publishes and RabbitMQ goes down before storing it | persistent: true + durable queue + confirm channel |
publisher.js |
Inventory processes and dies before the ack → redelivery |
Consumer idempotency: processOnce(eventId, 'inventory', fn) with processed_events |
Every consumer |
| The relay resends the same outbox row twice | Same eventId in both copies → the consumer deduplicates it |
Envelope contract |
| A message impossible to process blocks the queue | DLQ | Topology |
The user resends POST /orders |
Idempotency-Key (03-01) |
Orders |
None of this is exotic: it is at-least-once at every hop plus deduplication by eventId at the destination. It is the price of not having distributed transactions, and it is cheap.
- RabbitMQ versus Kafka and managed queues
| Criterion | RabbitMQ | Apache Kafka | Managed queues (AWS SQS/SNS, Google Pub/Sub) |
|---|---|---|---|
| Model | AMQP broker: exchanges, queues, flexible routing; the message is deleted on ack | Distributed log: partitioned topics, messages are retained (days or forever); consumers keep their offset | Queue (SQS) + pub-sub (SNS) or topic with subscriptions (Pub/Sub); nothing to operate |
| Routing | Very rich (topic with wildcards, headers) | By topic and partition; filtering is done by the consumer | Simple subscription filters |
| Ordering | Per queue with one consumer | Per partition, guaranteed; very strong | Only with FIFO queues / ordering keys |
| Replaying old messages | No (once consumed, it is gone) | Yes: re-read from an offset; the basis of event sourcing | No (or short retention) |
| Throughput | Tens of thousands of msg/s per node | Millions of msg/s; designed for streaming | Elastic; pay per use |
| Operations | A simple cluster; excellent web console | More complex (partitions, ZooKeeper/KRaft, rebalances) | None, but vendor lock-in |
| Latency | Very low (ms) | Low, but batch-oriented | Variable (tens of ms) |
| Learning curve | Gentle; intuitive concepts | Steep; new mental model | Gentle |
| Fits when | Business events and commands between services, varied routing, medium volume | Streaming, analytics, historical reprocessing, huge volumes | You are already on that cloud and do not want to operate a broker |
Why TechCorp chooses RabbitMQ:
- The volume (~3,000 orders/day, a few events per order) is orders of magnitude below the threshold where Kafka justifies its operational complexity. With ~25 engineers, the Platform team cannot dedicate anyone to looking after partitions.
- Topic routing with one queue per consumer directly models the context map from 02-03: each service subscribes to what it cares about and nobody else finds out.
- The DLQ, prefetch and confirms cover the guarantees the saga needs without extra code.
- In 02-05 we ruled out event sourcing "for now"; if it is ever adopted, Kafka's retention and replay would be the reason to migrate, and the standard event envelope will make that migration less painful.
- Marta ruled out managed queues so as not to tie the system to one cloud at a time when it is still being decided where Kubernetes will run.
There is one topic this lesson has brushed against in every snippet: the shape of the payload of each event (which fields order.created carries, what happens when one has to be added or another changed) and that version field in the envelope. That is the events' contract, and it is covered together with the APIs' contract in 03-06.
Common Mistakes and Tips
- Publishing directly to a queue (
sendToQueue) instead of to the exchange. It works until a second service needs the same event; then the producer has to be touched. With the exchange you just add a binding. ackbefore processing "so it goes fast". It turns at-least-once into at-most-once: a restart halfway through and the order is left without a reservation forever.- Requeuing without a limit (
nack(msg, false, true)in everycatch). A poison message monopolizes the queue. One redelivery and then to the DLQ. - Forgetting
prefetch. The first replica to start takes all the pending messages; the others just watch. - Non-durable queue or non-persistent message. Everything looks fine until the first broker restart. Both flags, always, for business events.
- Event payloads that point to data (
{"orderId": "ord-88213"}and have the consumer callGET /orders/ord-88213). It reintroduces the temporal coupling we wanted to remove. The event carries what the consumer needs (that is whyorder.createdincludes lines, prices and email). - Relying on ordering across queues or across replicas. Design consumers to tolerate
payment.confirmedbefore their own DB reflectsstock.reserved(the state machine from 02-05 andprocessOncehelp). - Ignoring the DLQ. Without an alert on
*.dlq, messages pile up for months. The alert is defined in 06-05; starting today, look at it in the RabbitMQ console. - One connection per request. AMQP connections are expensive. One connection per process, one channel per consumer or publisher, reused.
Exercises
Exercise 1. The Payments & Communications team wants Notifications to also send a "we have received your order" email as soon as it is created (in addition to the confirmation one). Say which binding has to be added, to which queue, and what does not have to be touched. Then reason: would it make sense for Notifications to use the same notifications.orders queue or a new notifications.orders-received one? Give one argument in favor of each option.
Exercise 2. A Payments consumer processes stock.reserved, calls the external payment provider, which charges Ana €79.70, and right before the ack the instance dies. RabbitMQ redelivers the message to another replica. Explain step by step what happens with and without processOnce, and which additional guarantee the Payments consumer needs with respect to the payment provider (hint: 03-01 talked about it under another name).
Exercise 3. Write a function moveFromDlqToQueue(channel, queueName, max) with amqplib that reads up to max messages from ${queueName}.dlq with channel.get() (synchronous fetch, no subscription) and republishes them to the techcorp.events exchange with the original routing key (available in msg.fields.routingKey or, after dead-lettering, in the x-death header), keeping persistent: true, and acks each one in the DLQ only after republishing it. Comment every line.
Solutions
Solution 1.
It is enough to add the order.created binding to the Notifications queue (bindQueue('notifications.orders', 'techcorp.events', 'order.created')) and register an 'order.created' handler in its consumer. Orders does not have to be touched (it keeps publishing exactly the same), nor Inventory, nor the exchange: that is what topic pub-sub buys you. The order.created payload already carries customer.email and customer.name precisely so that Notifications does not have to call anyone.
Same queue: simpler, one consumer, one prefetch, one DLQ to watch; the relative order "received → confirmed" for the same order is better preserved. New queue: it isolates failure (if the "received" email breaks and fills the DLQ, the confirmation ones keep going out) and allows scaling and prioritizing separately (the confirmation ones are more important). For TechCorp, at the current volume, the same queue; split them when there is an operational reason.
Solution 2.
Without processOnce: the second replica receives the same stock.reserved (same eventId), does not know it was already processed, calls the payment provider again and charges €79.70 twice; it also publishes two payment.confirmed. With processOnce: the second replica looks up processed_events for (evt-..., 'payments')... and here is the catch: if the first replica died before committing the transaction that inserts into processed_events, the event is not recorded as processed, and the second replica will charge too. processOnce protects against "I processed and died before the ack" only if the effect and the record in processed_events are in the same local transaction, and the call to the external payment provider cannot be inside that transaction.
The additional guarantee: the payment provider must accept an idempotency key (most real providers do), and Payments must use as the key something derived from the order (ord-88213) or from the eventId, exactly the same concept as the Idempotency-Key header from 03-01, but from Payments outward. That way, the second call to the provider returns the same charge instead of creating another one. General rule: idempotency is needed at every boundary where there is a non-reversible effect.
Solution 3.
async function moveFromDlqToQueue(channel, queueName, max = 100) {
const dlq = `${queueName}.dlq`;
let moved = 0;
for (let i = 0; i < max; i++) {
// get() fetches ONE message without subscribing; returns false if the DLQ is empty
const msg = await channel.get(dlq, { noAck: false });
if (!msg) break;
// After dead-lettering, msg.fields.routingKey is the DLX one (= queueName).
// The original routing key (e.g. 'order.created') is recorded in the x-death header.
const xDeath = msg.properties.headers?.['x-death']?.[0];
const originalRoutingKey = xDeath?.['routing-keys']?.[0] ?? msg.properties.type;
if (!originalRoutingKey) {
// No way to know where it was going: leave it in the DLQ (nack with requeue) and move on
channel.nack(msg, false, true);
continue;
}
// Republish to the main exchange with the same properties (persistent, messageId, type...)
channel.publish('techcorp.events', originalRoutingKey, msg.content, {
...msg.properties,
persistent: true,
headers: { ...msg.properties.headers, 'x-reprocessed-from-dlq': dlq }
});
// Only after republishing do we ack in the DLQ: if the process dies in between,
// the message stays in the DLQ (it could be duplicated, but processOnce absorbs it)
channel.ack(msg);
moved++;
}
return moved;
}Notes: we fill in msg.properties.type in publishEvent with envelope.type, so it serves as a fallback if x-death were missing. With a confirm channel it would be even safer to wait for waitForConfirms() before the ack. And yes, this script can duplicate an event in the worst case; as with everything in this lesson, consumer idempotency is what makes it harmless.
Conclusion
Asynchronous messaging is what makes the saga from 02-05 possible: it removes the temporal coupling between services, absorbs peaks and lets a single fact (order.confirmed) reach several interested parties without the producer knowing about them. We have fixed the vocabulary (event versus command, queue versus pub-sub, ack/nack, redelivery, DLQ), accepted at-least-once + idempotency as the realistic guarantee, and built TechCorp's topology in RabbitMQ: a topic exchange techcorp.events, routing keys equal to the event type, one durable queue per consumer (inventory.orders, payments.stock, notifications.orders, orders.saga, orders.customers) with its DLQ, and the amqplib code to publish the standard envelope with persistent: true and to consume with prefetch, ack after processing and nack to the DLQ. With REST for the synchronous side and RabbitMQ for the asynchronous side, TechCorp now has its two main channels.
But REST/JSON is neither the only form of synchronous call nor always the best: when two internal services talk to each other thousands of times a minute, JSON's verbosity and the lack of a typed contract weigh heavily, and when a front end needs to compose data from several services, REST forces many requests or huge responses. For the first case there is gRPC; for the second, GraphQL. In the next lesson we will look at both, with their Node.js code, and decide where they fit (and where they do not) at TechCorp.
Microservices Course
Module 1: Introduction to Microservices
- Basic Concepts of Microservices
- Advantages and Disadvantages of Microservices
- Comparison with the Monolithic Architecture
- When to Adopt Microservices: Decision Criteria
- The Course Case Study: TechCorp's Online Store
Module 2: Microservice Design
- Microservice Design Principles
- Decomposing Monolithic Applications
- Defining Bounded Contexts
- Data Management: One Database per Service
- Distributed Consistency: Sagas, CQRS and Event Sourcing
Module 3: Communication between Microservices
- RESTful APIs
- Asynchronous Messaging
- Communication Protocols: gRPC, GraphQL
- API Gateway and Backend for Frontend
- Service Discovery and Load Balancing
- API Contracts and Versioning
Module 4: Implementing Microservices
- Choosing Technologies and Tools
- Building a Simple Microservice
- Configuration Management
- Hands-On Integration: Consuming APIs and Publishing Events
- Testing Microservices: Unit, Integration and Contract Tests
Module 5: Deployment and Orchestration
- Containers and Docker
- Orchestration with Kubernetes
- CI/CD for Microservices
- Deployment Strategies: Rolling, Blue-Green and Canary
- Service Mesh: Istio and Linkerd
Module 6: Monitoring and Maintenance
- Monitoring and Logging
- Distributed Tracing with OpenTelemetry
- Error Handling and Recovery
- Scalability and Performance
- SLOs, Alerts and Incident Management
Module 7: Security in Microservices
- Authentication and Authorization
- Communication Security
- Security Practices
- Container and Kubernetes Security
