Event-Driven Order System with RabbitMQ
Build microservices that communicate through RabbitMQ events using Docker.
Introduction
30 Second Summary
Think about ordering something online. Behind the scenes, your order triggers a chain of events: payment processing, email confirmation, inventory updates.
In this project, you will build an order processing system where three Node.js microservices communicate entirely through events using RabbitMQ and Docker. You'll start with a tightly-coupled version, watch it fail, and refactor it into a resilient event-driven architecture.
What You'll Build
You'll POST an order to a REST endpoint and watch RabbitMQ fan it out to both a Payment Service and a Notification Service simultaneously, with each service processing events independently and recovering from downtime automatically.
By the end of this project, you'll have:
- A fan-out event system where you POST an order once and watch it propagate to both the Payment and Notification services through RabbitMQ without the Order Service knowing anything about its consumers.
- A resilient message queue where you can stop a service, place orders while it is offline, restart it, and watch it process the entire backlog of missed messages.
- A live RabbitMQ Management UI where you can see exchanges, queues, and message counts flowing in real time.
- Secret Mission: a dead-letter queue that captures failed messages instead of losing them.
Are there any prerequisites?
You'll need basic Node.js and JavaScript experience. You'll also need Docker installed on your machine.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters. This step is your moment to commit to the goal before any hands-on work begins.
Set Up Docker Compose with RabbitMQ
Event-driven systems need a message broker to pass events between services. Before any service can publish or consume events, the messaging infrastructure must be running and ready to accept connections.
In this step, you'll create the project structure and spin up RabbitMQ using Docker Compose. By the end, you'll have a working management dashboard you can see in the browser.
In this step, get ready to:
- Create the project directory structure with folders for each service.
- Write a Docker Compose configuration that defines the RabbitMQ service.
- Start RabbitMQ and verify the Management UI is accessible.
Create the project directory structure
Your order system will have three microservices: one for orders, one for payments, and one for notifications. Each service lives in its own folder.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or Command Prompt (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder with service subdirectories by running these commands:
mkdir event-driven-orders
cd event-driven-orders
mkdir order-service payment-service notification-service
- Confirm the folders exist by running:
ls
You should see order-service, payment-service, and notification-service listed in the output.
Don't see all three folders?
Make sure you ran the mkdir command from inside the event-driven-orders folder. Run pwd to check your current location.
help me find my project folders
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the event-driven-orders folder on your Desktop, then click Open.
You should see your three service folders in the Explorer sidebar on the left.
Write the Docker Compose configuration
Docker Compose lets you define and run multi-container applications with a single YAML file. Your compose.yaml will tell Docker exactly which image to pull, which ports to expose, and how to check that RabbitMQ is healthy.
- In the VS Code Explorer sidebar, click the New File icon (at the top of the file list).
- Name the file compose.yaml and press Enter.
- Paste the following configuration into compose.yaml:
# Define the services that make up your system
services:
rabbitmq:
# Management image includes the browser-based UI
image: rabbitmq:4.3.2-management
ports:
# AMQP protocol port for service connections
- "5672:5672"
# Management UI port for browser access
- "15672:15672"
healthcheck:
# Verify RabbitMQ can accept connections before starting dependent services
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
What does this configuration do?
- image: rabbitmq:4.3.2-management pulls the official RabbitMQ image with the Management UI plugin pre-installed.
- Port 5672 is the AMQP protocol port. Your Node.js services will connect here to publish and consume messages.
- Port 15672 exposes the Management UI so you can monitor queues and exchanges in a browser.
- The healthcheck block tells Docker to keep checking that RabbitMQ can accept connections. Other services can wait for this check to pass before they start.
Why RabbitMQ instead of Kafka?
RabbitMQ supports flexible messaging patterns (fanout, direct, topic) with straightforward setup. Kafka excels at massive scale but adds operational complexity that distracts from learning core event-driven concepts.
For a first microservices project, RabbitMQ gives you the clearest path from zero to a working pub/sub system.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file and move on to starting RabbitMQ.
ⓧ I'd like to double check the full code
Your compose.yaml should look exactly like this:
services:
rabbitmq:
image: rabbitmq:4.3.2-management
ports:
- "5672:5672"
- "15672:15672"
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_port_connectivity"]
interval: 10s
timeout: 5s
retries: 5
Start RabbitMQ and confirm the Management UI
With the configuration in place, you can start the RabbitMQ container. Docker will pull the image the first time, which may take a minute.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Start the RabbitMQ container by running this command:
docker compose up -d rabbitmq
Before you check the result, what do you expect to see if RabbitMQ started successfully?
- Verify the container is running by running:
docker compose ps
You should see the rabbitmq container listed with a status of running. The health status may show (healthy) after 10-30 seconds.
- Open your browser and navigate to http://localhost:15672.
- Enter guest as the username.
- Enter guest as the password.
- Click Login.
You should see the RabbitMQ Management dashboard with an overview of your server. This is where you'll monitor message flow later in the project.
Can't reach the Management UI?
- Run docker compose ps and check that the rabbitmq container status shows running. If it shows restarting, the container is still starting up. Wait 15-20 seconds and try again.
- Make sure nothing else is using port 15672 on your machine. If you have another RabbitMQ instance running, stop it first.
- Confirm you're visiting http://localhost:15672 (not https).
help me get the RabbitMQ Management UI working
Your messaging infrastructure is live. Next up, you'll build the Order Service and connect it to the other services using plain HTTP calls. This synchronous version will work at first, but you'll soon see its fatal flaw.
Build the Order Service (Synchronous Version)
Your RabbitMQ broker is running and its Management UI is accessible. Now it is time to build the services that will eventually communicate through it.
Before wiring up event-driven messaging, you will build the simplest possible version first: three microservices that talk to each other over plain HTTP. This gives you a working baseline to compare against once you switch to events later.
In this step, get ready to:
- Create the Order Service with an Express REST endpoint that calls Payment and Notification services directly.
- Create stub Payment and Notification services that accept HTTP requests and log them.
- Wire all three services into Docker Compose and test the synchronous flow end-to-end.
Create the Order Service
The Order Service is the entry point for your system. It exposes a POST endpoint that receives order data, then calls the Payment and Notification services over HTTP.
- Create the file order-service/package.json with the following content:
{
"name": "order-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "4.22.2"
}
}
What does this file do?
This package.json declares the service name and its single dependency: Express at version 4.22.2. Docker will use this file to install dependencies when building the container image.
- Create the file order-service/Dockerfile with the following content:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD ["node", "index.js"]
What does this Dockerfile do?
It starts from a lightweight Node.js 22 Alpine image, copies package.json first to leverage Docker layer caching for installs, then copies the rest of the source code.
The EXPOSE 3001 documents which port the service listens on, and CMD starts the app with Node.
- Confirm both files exist by running:
ls order-service/
You should see Dockerfile and package.json listed.
- Now create order-service/index.js with the following code:
const express = require('express');
const app = express();
app.use(express.json());
// URLs for downstream services (Docker Compose service names resolve as hostnames)
const PAYMENT_URL = process.env.PAYMENT_URL || 'http://payment-service:3002';
const NOTIFICATION_URL = process.env.NOTIFICATION_URL || 'http://notification-service:3003';
app.post('/orders', async (req, res) => {
const order = {
id: Date.now().toString(),
item: req.body.item,
quantity: req.body.quantity,
createdAt: new Date().toISOString()
};
// Call Payment and Notification services directly via HTTP
await fetch(`${PAYMENT_URL}/process`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(order) });
await fetch(`${NOTIFICATION_URL}/notify`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(order) });
console.log(`[Order Service] Order created: ${order.id}`);
res.status(201).json({ status: 'order_created', order });
});
app.listen(3001, () => console.log('[Order Service] Listening on port 3001'));
What does this code do?
- PAYMENT_URL and NOTIFICATION_URL point to the other services. Inside Docker Compose, service names work as hostnames automatically.
- The POST /orders handler creates an order object, then calls both downstream services using fetch. Each call waits for a response before moving to the next.
- This is synchronous, tightly-coupled communication. If either downstream service is unavailable, the fetch call will fail and crash the entire request.
- Confirm all three Order Service files exist by running:
ls order-service/
You should see Dockerfile, index.js, and package.json.
Missing a file?
Make sure you created the files inside the order-service/ directory, not in the project root. Double-check for typos in the filename.
Help me figure out why my Order Service files are missing.
Create stub Payment and Notification services
The Payment and Notification services are simple stubs for now. They accept HTTP requests, log what they receive, and return a 200 response.
- Create the file payment-service/package.json with the following content:
{
"name": "payment-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "4.22.2"
}
}
- Create the file payment-service/Dockerfile with the following content:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]
- Confirm both files exist by running:
ls payment-service/
You should see Dockerfile and package.json listed.
- Create the file payment-service/index.js with the following code:
const express = require('express');
const app = express();
app.use(express.json());
// Accept payment requests and log them
app.post('/process', (req, res) => {
const order = req.body;
console.log(`[Payment Service] Processing payment for order ${order.id}: ${order.item} x${order.quantity}`);
res.status(200).json({ status: 'payment_processed' });
});
app.listen(3002, () => console.log('[Payment Service] Listening on port 3002'));
What does this stub do?
The Payment Service listens on port 3002 and exposes a single POST /process endpoint. When the Order Service calls it, it logs the order details and returns a success response. This simulates what a real payment processor would do.
- Confirm all Payment Service files exist by running:
ls payment-service/
You should see Dockerfile, index.js, and package.json.
Now create the Notification Service. It follows the same pattern as Payment.
- Create the file notification-service/package.json with the following content:
{
"name": "notification-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"express": "4.22.2"
}
}
- Create the file notification-service/Dockerfile with the following content:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
CMD ["node", "index.js"]
- Confirm both files exist by running:
ls notification-service/
You should see Dockerfile and package.json listed.
- Create the file notification-service/index.js with the following code:
const express = require('express');
const app = express();
app.use(express.json());
// Accept notification requests and log them
app.post('/notify', (req, res) => {
const order = req.body;
console.log(`[Notification Service] Sending notification for order ${order.id}: "Your order for ${order.item} has been received!"`);
res.status(200).json({ status: 'notification_sent' });
});
app.listen(3003, () => console.log('[Notification Service] Listening on port 3003'));
What does this stub do?
The Notification Service listens on port 3003 and exposes a POST /notify endpoint. It logs a user-facing notification message when the Order Service calls it.
- Confirm all Notification Service files exist by running:
ls notification-service/
You should see Dockerfile, index.js, and package.json.
Missing files in a service folder?
Verify you are creating files inside the correct subdirectory. Each service has its own folder: payment-service/ and notification-service/. A common mistake is placing files in the project root instead.
Help me find my missing service files.
Add all three services to Docker Compose and test
With all the service code in place, you need to tell Docker Compose about the three new services so it can build and run them alongside RabbitMQ.
- Open compose.yaml and add the following three service definitions below the existing rabbitmq service:
order-service:
build: ./order-service
ports:
- "3001:3001"
environment:
- RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
payment-service:
build: ./payment-service
environment:
- RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
notification-service:
build: ./notification-service
environment:
- RABBITMQ_URL=amqp://guest:guest@rabbitmq:5672
depends_on:
rabbitmq:
condition: service_healthy
What does this configuration do?
- build: ./order-service tells Compose to build an image from the Dockerfile in that directory.
- ports: "3001:3001" exposes the Order Service so you can reach it from your host machine.
- depends_on with condition: service_healthy ensures each service waits for RabbitMQ to pass its healthcheck before starting. This prevents connection errors on startup.
- The RABBITMQ_URL environment variable is defined now even though the synchronous version does not use it yet. You will need it in the event-driven refactor later.
- Save compose.yaml.
✔️ Awesome, I've got everything!
Great. Make sure you saved compose.yaml with the three new service blocks nested under services: at the same indentation level as rabbitmq.
ⓧ I'd like to double check the full code
Your compose.yaml should have the original rabbitmq service block (from Step 1) followed by the three new service definitions you just added. Scroll up and confirm:
- The rabbitmq service with its image, ports, and healthcheck is at the top.
- order-service with build, ports, environment, and depends_on comes next.
- payment-service with build, environment, and depends_on follows.
- notification-service with build, environment, and depends_on is last.
Now start all services together. This will build the Docker images for each service and run them.
- Build and start all containers by running:
docker compose up --build
What does --build do?
The --build flag tells Compose to build (or rebuild) the Docker images from the Dockerfiles before starting the containers. Without it, Compose would try to use previously cached images.
You will see Docker building each image (downloading node:22-alpine, running npm install) and then all four services starting up. Wait until you see log lines from all services.
What should I see?
Once all containers are running, you should see these log lines (order may vary):
- [Order Service] Listening on port 3001.
- [Payment Service] Listening on port 3002.
- [Notification Service] Listening on port 3003.
Before you test the endpoint, predict: what do you think will happen when you POST an order? Will the Order Service call Payment and Notification in sequence, or in parallel?
- Open a new terminal tab (the current one is showing Docker logs) and POST a test order by running:
curl -X POST http://localhost:3001/orders -H "Content-Type: application/json" -d '{"item":"keyboard","quantity":1}'
You should see a JSON response like this:
{
"status": "order_created",
"order": {
"id": "1752753600000",
"item": "keyboard",
"quantity": 1,
"createdAt": "2026-07-17T12:00:00.000Z"
}
}
The id and createdAt values will differ since they use the current timestamp.
- Switch back to the terminal running docker compose up and check the logs.
You should see all three services logged in sequence:
- [Payment Service] Processing payment for order ....
- [Notification Service] Sending notification for order ....
- [Order Service] Order created: ....
Why does Payment log before Notification?
The Order Service calls Payment first with await fetch, waits for its response, then calls Notification. This is sequential, synchronous communication. Each service must respond before the next one is called.
Seeing connection refused or empty response?
- Make sure all four containers are running. In a new terminal, run docker compose ps to check their status.
- If a service failed to start, check the Docker build logs for errors (missing files, typos in filenames, incorrect JSON in package.json).
- Verify your compose.yaml indentation is correct. YAML is sensitive to spaces. Each service name must be indented two spaces under services:.
Help me debug why my order request is failing.
You now have a working synchronous system. All three services are running, communicating over HTTP, and processing orders end-to-end. Next, you will discover what happens when one of those services goes down.
Break It (The Coupling Problem)
In the last step, you proved the synchronous system works when every service is online. Orders flow through Payment and Notification without a hitch.
But what happens when one service goes down? In production, services crash, restart, and get redeployed constantly. Your current architecture has a hidden fragility that you are about to expose.
In this step, get ready to:
- Stop the Notification Service while leaving everything else running.
- Attempt to place an order and observe the system crash.
- Understand why tight coupling is a fundamental problem in distributed systems.
Stop the Notification Service
Docker Compose lets you stop individual containers without touching the rest of your system. You are going to simulate a real-world outage by taking down just the Notification Service.
- Stop only the Notification Service container by running this command:
docker compose stop notification-service
- Confirm the Notification Service is stopped by running:
docker compose ps
You should see notification-service with an "Exited" status, while rabbitmq, order-service, and payment-service remain "Up".
All services still showing as Up?
Make sure you typed notification-service exactly as written (with the hyphen). The name must match the service key in your compose.yaml.
help me figure out why docker compose stop isn't working
Attempt to place an order
The Notification Service is down. The Order Service still tries to call it synchronously via HTTP. Think about what you expect to happen when you place a new order.
Before you run the next command, predict: will the order succeed or fail? Remember, the Order Service makes HTTP calls to both the Payment Service and the Notification Service in sequence.
- Try to place an order by running this command:
curl -X POST http://localhost:3001/orders -H "Content-Type: application/json" -d '{"item":"keyboard","quantity":1}'
You see the Order Service return a 500 error. The entire order failed because the Notification Service is unreachable.
What just happened?
The Order Service tried to make an HTTP call to http://notification-service:3003/notify. Because that container is stopped, the connection was refused. Since the Order Service has no error handling for this scenario, the unhandled rejection crashed the request and returned a 500.
The Payment Service still processed successfully, but the customer gets an error anyway. The order is effectively lost.
- Check the Order Service logs to see the failure in detail by running:
docker compose logs order-service --tail 10
You see a connection refused or fetch failed error in the logs, confirming the Order Service crashed when it could not reach the Notification Service.
Understand the coupling problem
This is the designed failure. You just experienced the fundamental problem with tightly-coupled microservices.
Why is this such a big deal?
In a real system, should a customer's order fail just because the notification system is down? The customer doesn't care about emails. They care that their order was placed and their payment was processed.
When services call each other directly over HTTP, every service in the chain becomes a single point of failure. If any one of them goes down, the entire operation fails. This is tight coupling.
The fix is to decouple the services so they don't need to know about each other at all. The Order Service should publish an event ("an order was created") and move on. Other services react to that event on their own schedule. That is event-driven architecture.
You just proved that your synchronous architecture is fragile. One service going offline takes down the whole chain. In the next step, you will refactor everything to use RabbitMQ events so that services never call each other directly again.
Refactor to Event-Driven Architecture
In the last step, you watched the Order Service crash because it couldn't reach the Notification Service over HTTP. That's the fundamental problem with synchronous coupling: one service going down brings the whole chain down with it.
Now you'll refactor all three services to communicate through RabbitMQ events instead of direct HTTP calls. The Order Service will publish a single event, and the Payment and Notification services will independently consume it from their own queues.
In this step, get ready to:
- Refactor the Order Service to publish events to a fanout exchange instead of making HTTP calls.
- Convert the Payment and Notification services from HTTP servers to event consumers.
- Rebuild and test the full event-driven flow end-to-end.
Refactor the Order Service to publish events
Instead of calling Payment and Notification over HTTP, the Order Service will connect to RabbitMQ and publish order events to a fanout exchange. A fanout exchange broadcasts every message it receives to all bound queues.
- Open order-service/package.json and replace its entire contents with this:
{
"name": "order-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"amqplib": "2.0.1",
"express": "4.22.2"
}
}
What changed here?
You added amqplib as a dependency. This is the Node.js client library for communicating with RabbitMQ over the AMQP protocol. The Order Service still keeps express because it still exposes a REST endpoint for receiving orders.
- Now open order-service/index.js and replace the entire file contents with the following connection and setup logic:
const express = require('express');
const amqplib = require('amqplib');
const app = express();
app.use(express.json());
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://localhost:5672';
const EXCHANGE_NAME = 'order_events';
let channel;
async function connectRabbitMQ() {
const connection = await amqplib.connect(RABBITMQ_URL);
channel = await connection.createChannel();
await channel.assertExchange(EXCHANGE_NAME, 'fanout', { durable: true });
console.log('[Order Service] Connected to RabbitMQ');
}
What does this code do?
The connectRabbitMQ() function establishes a connection, opens a channel, and declares a fanout exchange called order_events. The channel variable is stored at module level so the route handler can use it to publish messages.
A fanout exchange sends every message it receives to all queues bound to it. The Order Service does not need to know how many consumers exist or what they do.
- Add the route handler and startup code directly below in the same file:
app.post('/orders', async (req, res) => {
const order = {
id: Date.now().toString(),
item: req.body.item,
quantity: req.body.quantity,
createdAt: new Date().toISOString()
};
const message = JSON.stringify(order);
channel.publish(EXCHANGE_NAME, '', Buffer.from(message));
console.log(`[Order Service] Order created, event published: ${order.id}`);
res.status(201).json({ status: 'order_created', order });
});
connectRabbitMQ().then(() => {
app.listen(3001, () => {
console.log('[Order Service] Listening on port 3001');
});
});
What does this code do?
The /orders route now publishes the order as a JSON message to the order_events exchange instead of making HTTP calls. The empty string '' as the routing key is standard for fanout exchanges because they ignore routing keys entirely.
At startup, connectRabbitMQ() runs first to establish the connection. Only after connecting does the Express server start listening for HTTP requests.
- Save order-service/package.json and order-service/index.js.
- Rebuild and restart only the Order Service by running this command:
docker compose up -d --build order-service
- Check the Order Service logs by running:
docker compose logs order-service
You should see [Order Service] Connected to RabbitMQ and [Order Service] Listening on port 3001 in the output.
Seeing a connection error?
Make sure RabbitMQ is still running. Run docker compose ps and confirm the rabbitmq container shows as healthy. If it's not running, start it with docker compose up -d rabbitmq and wait for the healthcheck to pass before rebuilding the Order Service.
help me debug a RabbitMQ connection error in my Order Service
Refactor the Payment Service to consume events
The Payment Service no longer needs an Express server. It becomes a pure event consumer that listens on its own queue bound to the order_events exchange.
- Open payment-service/package.json and replace its entire contents with this:
{
"name": "payment-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"amqplib": "2.0.1"
}
}
Why remove Express?
The Payment Service no longer receives HTTP requests. It only consumes messages from RabbitMQ, so Express is no longer needed as a dependency.
- Open payment-service/index.js and replace the entire file contents with this:
const amqplib = require('amqplib');
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://localhost:5672';
const EXCHANGE_NAME = 'order_events';
const QUEUE_NAME = 'payment_queue';
async function start() {
const connection = await amqplib.connect(RABBITMQ_URL);
const channel = await connection.createChannel();
await channel.assertExchange(EXCHANGE_NAME, 'fanout', { durable: true });
await channel.assertQueue(QUEUE_NAME, { durable: true });
await channel.bindQueue(QUEUE_NAME, EXCHANGE_NAME, '');
console.log('[Payment Service] Waiting for order events...');
channel.consume(QUEUE_NAME, (msg) => {
if (msg) {
const order = JSON.parse(msg.content.toString());
console.log(`[Payment Service] Processing payment for order ${order.id}: ${order.item} x${order.quantity}`);
channel.ack(msg);
}
});
}
start();
What does this code do?
The start() function connects to RabbitMQ, declares the same order_events fanout exchange, then creates its own durable queue called payment_queue.
The bindQueue call links the queue to the exchange. From that point on, every message published to order_events gets a copy delivered to payment_queue. The channel.ack(msg) call tells RabbitMQ the message was processed successfully so it can be removed from the queue.
- Save both files and rebuild the Payment Service by running:
docker compose up -d --build payment-service
- Check the Payment Service logs by running:
docker compose logs payment-service
You should see [Payment Service] Waiting for order events... confirming it connected and is ready to consume.
Payment Service not starting?
If you see a module-not-found error for amqplib, Docker may have cached the old image. Run docker compose build --no-cache payment-service to force a fresh install, then run docker compose up -d payment-service again.
help me debug why my Payment Service container won't start after the refactor
Refactor the Notification Service and test the full flow
The Notification Service follows the exact same pattern as Payment. It gets its own queue bound to the same exchange, so both services independently receive a copy of every order event.
- Open notification-service/package.json and replace its entire contents with this:
{
"name": "notification-service",
"version": "1.0.0",
"main": "index.js",
"dependencies": {
"amqplib": "2.0.1"
}
}
- Open notification-service/index.js and replace the entire file contents with this:
const amqplib = require('amqplib');
const RABBITMQ_URL = process.env.RABBITMQ_URL || 'amqp://localhost:5672';
const EXCHANGE_NAME = 'order_events';
const QUEUE_NAME = 'notification_queue';
async function start() {
const connection = await amqplib.connect(RABBITMQ_URL);
const channel = await connection.createChannel();
await channel.assertExchange(EXCHANGE_NAME, 'fanout', { durable: true });
await channel.assertQueue(QUEUE_NAME, { durable: true });
await channel.bindQueue(QUEUE_NAME, EXCHANGE_NAME, '');
console.log('[Notification Service] Waiting for order events...');
channel.consume(QUEUE_NAME, (msg) => {
if (msg) {
const order = JSON.parse(msg.content.toString());
console.log(`[Notification Service] Sending notification for order ${order.id}: "Your order for ${order.item} has been received!"`);
channel.ack(msg);
}
});
}
start();
Why does each service get its own queue?
This is the fan-out pattern. The order_events exchange delivers a copy of every message to every bound queue. Payment gets its copy in payment_queue and Notification gets its own copy in notification_queue.
Each service processes messages at its own pace without affecting the other. If one consumer is slow or temporarily offline, the other keeps processing normally.
- Save both files and rebuild all services by running:
docker compose up -d --build
Before you test, what do you think will happen when you POST an order now? Will the Order Service still call Payment and Notification directly, or will something else deliver the message?
- Place a test order by running:
curl -X POST http://localhost:3001/orders -H "Content-Type: application/json" -d '{"item":"keyboard","quantity":1}'
You should see a success response with the order details:
{"status":"order_created","order":{"id":"...","item":"keyboard","quantity":1,"createdAt":"..."}}
- Now check the logs from all three services by running:
docker compose logs --tail=5 order-service payment-service notification-service
You should see all three services logged their independent actions:
- The Order Service logs [Order Service] Order created, event published: <id>.
- The Payment Service logs [Payment Service] Processing payment for order <id>: keyboard x1.
- The Notification Service logs [Notification Service] Sending notification for order <id>.
All three services operate independently. The Order Service published a single event and moved on. RabbitMQ delivered copies to both consumers.
Not seeing logs from Payment or Notification?
Run docker compose ps and confirm all three service containers are in a running state. If a container exited, check its individual logs with docker compose logs <service-name> to find the specific error.
If you see a queue-binding error, the exchange might not have been created yet. Restart the services with docker compose restart payment-service notification-service so they retry the connection after the Order Service has declared the exchange.
help me debug why my event-driven services aren't receiving messages from RabbitMQ
✔️ Awesome, I've got everything!
Great. Double check you've saved all six files across the three services.
ⓧ I'd like to double check the full code
Scroll up and confirm each chunk is present in order. Here is a summary of what each file should contain:
- order-service/package.json lists both amqplib and express as dependencies.
- order-service/index.js has the connectRabbitMQ() function (chunk 1) followed by the /orders route and connectRabbitMQ().then(...) startup (chunk 2).
- payment-service/package.json lists only amqplib as a dependency.
- payment-service/index.js has the start() function connecting to RabbitMQ and consuming from payment_queue.
- notification-service/package.json lists only amqplib as a dependency.
- notification-service/index.js has the start() function connecting to RabbitMQ and consuming from notification_queue.
The compose.yaml and all three Dockerfiles remain unchanged from the previous step.
Your services are now fully decoupled. Next up, you'll prove why this matters by repeating the exact failure scenario from Step 3 and watching the system survive it.
Prove Resilience
In Step 4, you decoupled your services through RabbitMQ events. The Order Service publishes once, and both consumers react independently without knowing about each other.
The real test of decoupling is resilience. Remember when stopping the Notification Service crashed the entire order flow in Step 3? You are about to repeat that exact scenario and see a completely different outcome.
In this step, get ready to:
- Stop the Notification Service while other services keep running.
- Place two orders and confirm they succeed without the Notification Service.
- Restart the Notification Service and watch it process the backlog from the queue.
Stop the Notification Service
In Step 3, stopping a downstream service broke the entire order flow. Now you will stop the same service and see what happens with the event-driven architecture in place.
- Stop the Notification Service container while leaving the others running:
docker compose stop notification-service
You should see a message confirming the container has stopped. The Order Service, Payment Service, and RabbitMQ continue running.
Place orders while Notification Service is down
This is the moment of truth. In Step 3, this exact scenario returned a 500 error. What do you expect to happen now that the Order Service publishes events instead of making direct HTTP calls?
- POST your first order by running this command:
curl -X POST http://localhost:3001/orders -H "Content-Type: application/json" -d '{"item":"keyboard","quantity":1}'
You should see a 201 success response with your order details. The order did not fail.
- POST a second order by running:
curl -X POST http://localhost:3001/orders -H "Content-Type: application/json" -d '{"item":"monitor","quantity":2}'
Another 201 success. Both orders went through even though the Notification Service is completely offline.
Why did this work?
The Order Service no longer calls the Notification Service directly. It publishes an event to RabbitMQ and immediately returns success. RabbitMQ holds the messages in the notification_queue until a consumer is available to pick them up.
This is the fundamental difference. The producer does not know or care whether the consumer is running.
- Check your Docker logs to confirm the Payment Service processed both orders by running:
docker compose logs payment-service --tail 5
You should see two log lines from the Payment Service showing it processed both orders independently.
- Open the RabbitMQ Management UI at http://localhost:15672 (credentials: guest / guest).
- Click the Queues and Streams tab.
- Look at the notification_queue row. You should see 2 in the Ready column. Those are your two orders waiting for the Notification Service to come back.
Don't see 2 messages in the queue?
Make sure you stopped the Notification Service before posting the orders. If it was still running, it would have consumed the messages immediately.
Also check that the payment_queue shows 0 messages. The Payment Service was running and should have consumed its copies already.
Why aren't my messages showing in the RabbitMQ queue?
Restart and watch the catch-up
The messages are sitting in the queue, waiting patiently. What do you think will happen the moment the Notification Service reconnects to RabbitMQ?
- Restart the Notification Service by running:
docker compose start notification-service
- Check the Notification Service logs to see what happened by running:
docker compose logs notification-service --tail 10
You should see the Notification Service immediately consume and log both queued messages. It processes the keyboard order and the monitor order without missing a beat.
- Return to the RabbitMQ Management UI and refresh the Queues and Streams tab. The notification_queue Ready column now shows 0. The queue has drained completely.
Why this matters in production
In a real system, services go down for deployments, scaling events, and unexpected crashes. With event-driven architecture, no messages are lost during downtime. The queue acts as a buffer, holding work until the consumer is ready.
This is the same pattern that powers systems at companies like Netflix and Uber, where hundreds of microservices must operate independently without blocking each other.
Notification Service didn't process the queued messages?
Make sure you used docker compose start (not docker compose up). The start command restarts an existing stopped container. If the service still does not process messages, check that it connects to RabbitMQ without errors in the logs.
Why isn't my Notification Service consuming queued messages after restart?
You have just proven the core value of event-driven architecture. Services can crash, restart, and scale independently without losing a single message. Next up, the Secret Mission challenges you to handle what happens when a consumer fails to process a message.
Secret mission
Add a Dead Letter Queue
In production, messages sometimes fail to process. Malformed data, crashed dependencies, unexpected nulls. Without a safety net, those messages vanish forever. In this secret mission, you'll add a dead-letter queue that captures failed messages so you can inspect and replay them later.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally using Docker containers, so there are no ongoing cloud costs.
Resources you used:
- Docker containers: RabbitMQ, order-service, payment-service, notification-service.
- Docker volumes created by the RabbitMQ service.
- Local project files: compose.yaml, order-service/, payment-service/, notification-service/.
Keep everything running
No action needed. Choose this if you're still actively experimenting with the system, adding new consumers, or testing different exchange types.
- Your Docker containers continue running in the background.
- The RabbitMQ Management UI stays available at http://localhost:15672.
- You can keep POSTing orders to http://localhost:3001/orders to test new patterns.
Pause - I'll come back to this later
Stop all containers without removing them. This frees up memory and CPU while preserving your container state so you can resume quickly.
- Open the terminal where your project is running (the folder containing compose.yaml).
- Stop all containers by running:
docker compose stop
- When you're ready to resume, start everything again by running:
docker compose start
Delete - I don't want to use this again
Remove all containers, networks, volumes, and project files. This is a complete teardown.
- Open the terminal in the folder containing compose.yaml.
- Remove all containers, networks, and volumes by running:
docker compose down -v
- Navigate up one level and delete the project folder by running:
cd ..
rm -rf $(basename $OLDPWD)
On Windows, use this instead:
cd ..
rmdir /s /q order-service
rmdir /s /q payment-service
rmdir /s /q notification-service
del compose.yaml
What about Docker images?
The docker compose down -v command removes containers and volumes, but the built images remain cached on your machine. To also remove the images, add the --rmi local flag.
That's a Wrap!
That's a Wrap!
Nice work. You just built a production-grade event-driven order system with three independent microservices communicating through RabbitMQ, all orchestrated by Docker Compose.
You've learned how to:
- Experience the coupling problem first-hand by building a synchronous architecture where one service going down crashes the entire chain.
- Implement the pub/sub fan-out pattern where one event triggers multiple independent consumers via a RabbitMQ fanout exchange.
- Prove resilience by taking a service offline, placing orders while it's down, and watching it catch up automatically from the queue when it returns.
- Secret Mission: add a dead-letter queue to capture failed messages instead of losing them.
Ready to quiz yourself?