Want to learn how to put together a small microservices system using Node.js services, MongoDB for persistence, and RabbitMQ as a message broker for notifications? Perfect — this guide walks you through everything: the full code, what each line does, why and when to use RabbitMQ, how to run it with Docker Compose.
Project overview (what you’re building)
A simple microservice system with:
-
user-service — CRUD for users (MongoDB
usersDB). HTTP API on port 3000. -
task-service — CRUD for tasks (MongoDB
tasksDB). When a task is created it publishes a message to RabbitMQ queuetask_created. HTTP API on port 3001. -
notification-service — Listens to the
task_createdqueue and logs notifications (simulates sending emails/push). No HTTP needed (runs on port 3002 but only prints). - mongo — MongoDB container, data persisted to a named Docker volume.
- rabbitmq — RabbitMQ (with management UI) to broker messages between services.
You control all with a single docker-compose.yml.
Full docker-compose.yml
services:
mongo:
image: mongodb/mongodb-community-server:latest
container_name: mongo
ports:
- "27017:27017"
volumes:
- mongo_data:/data/db
rabbitmq:
image: rabbitmq:3-management
container_name: rabbitmq
ports:
- "5672:5672"
- "15672:15672"
user-service:
build: ./user-service
container_name: user-service
ports:
- "3000:3000"
depends_on:
- mongo
task-service:
build: ./task-service
container_name: task-service
ports:
- "3001:3001"
depends_on:
- mongo
- rabbitmq
notification-service:
build: ./notification-service
container_name: notification-service
ports:
- "3002:3002"
depends_on:
- mongo
- rabbitmq
volumes:
mongo_data:
Explanation (line by line — docker-compose.yml)
-
services:— top level key, declares containers to run. -
mongo:— defines a service namedmongo.-
image: mongodb/mongodb-community-server:latest— uses official MongoDB community server image (latest tag). This will pull from Docker Hub. -
container_name: mongo— sets the container’s name tomongofor easierdockercommands. -
ports: - "27017:27017"— exposes MongoDB default port 27017 to the host (host:container). Useful for debugging withmongoCLI / GUI clients. -
volumes: - mongo_data:/data/db— persists DB files into a named volumemongo_dataso data survives container restarts.
-
-
rabbitmq:— defines RabbitMQ service.-
image: rabbitmq:3-management— RabbitMQ image that includes the management UI plugin (accessible on 15672). -
container_name: rabbitmq— container name. ports:-
"5672:5672"— RabbitMQ AMQP port (used by producers/consumers). -
"15672:15672"— management web UI (open in browser to monitor queues).
-
-
user-service:— our Node.js user service.-
build: ./user-service— build fromuser-servicefolder and itsDockerfile. -
container_name: user-service— container name. -
ports: - "3000:3000"— expose port 3000. -
depends_on: - mongo— Docker Compose will startmongobeforeuser-service. Important:depends_ononly controls start order; it does not wait for MongoDB to be fully ready — your code must handle retries or wait.
-
-
task-service:— Node service for tasks.-
depends_on: - mongo - rabbitmq— start mongo and rabbitmq first, but again readiness should be handled by the app (this code does a retry for RabbitMQ).
-
-
notification-service:— consumer that listens to RabbitMQ and prints notifications.-
depends_on:for bothmongoandrabbitmq.
-
volumes:mongo_data:— declares a named Docker volume that was referenced earlier.
user-service
user-service/Dockerfile
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3000
CMD [ "node","index.js" ]
Explanation (line by line):
-
FROM node:22— base image containing Node.js v22. This includes Node and npm. Pick a specific version in production to avoid surprises. -
WORKDIR /app— sets working directory inside container to/app. -
COPY package*.json ./— copiespackage.jsonandpackage-lock.jsonif present (install dependencies first). -
RUN npm install— installs dependencies inside the image so we don't run install on container start. -
COPY . .— copy application code into container. -
EXPOSE 3000— documents that container listens on port 3000 (for humans and some tools). -
CMD [ "node","index.js" ]— default command when container starts; runindex.js.
user-service/index.js
const express = require("express");
const mongoose = require("mongoose");
const bodyParser = require("body-parser");
const app = express();
const port = 3000;
app.use(bodyParser.json());
mongoose
.connect("mongodb://mongo:27017/users")
.then(() => {
console.log("Connected to MongoDB");
})
.catch((error) => {
console.error("Error connecting to MongoDB:", error);
});
const UserSchema = new mongoose.Schema({
name: String,
email: String,
});
const User = mongoose.model("User", UserSchema);
app.get("/users", async (req, res) => {
const users = await User.find();
res.json(users);
});
app.post("/users", async (req, res) => {
const { name, email } = req.body;
try {
const user = new User({ name, email });
await user.save();
res.status(201).json(user);
} catch (error) {
console.error("Error Saving: ", error);
res.status(500).json({ error: "Internal Server Error" });
}
});
app.get("/", (req, res) => {
res.send("Hello World!");
});
app.listen(port, () => {
console.log(`User service listening on port ${port}`);
});
Line-by-line explanation — index.js:
-
const express = require("express");— import Express, a minimal web framework. -
const mongoose = require("mongoose");— import Mongoose, an ODM to talk to MongoDB. -
const bodyParser = require("body-parser");— parse JSON request bodies (Express >=4.16 hasexpress.json(), but herebody-parseris used). -
const app = express();— create Express app. -
const port = 3000;— port where service will listen. -
app.use(bodyParser.json());— middleware to parse incoming JSON payloads intoreq.body. -
mongoose.connect("mongodb://mongo:27017/users")— connect to MongoDB using hostnamemongo(Docker Compose DNS) and DBusers.-
.then(() => console.log(...))— logs success. -
.catch(...)— logs failure. Note: if Mongo isn't ready, this will fail once; in production you likely want a retry loop (or usemongoose.connect()options and retry logic).
-
const UserSchema = new mongoose.Schema({ name: String, email: String });— defines a simple schema for users withnameandemail.const User = mongoose.model("User", UserSchema);— creates a Mongoose model.app.get("/users", async (req, res) => { ... })— route to return all users as JSON.app.post("/users", async (req, res) => { ... })— route to create a new user fromreq.body. On success responds with201and the created user.app.get("/", (req, res) => res.send("Hello World!"));— root route for quick check.app.listen(port, () => console.log(...));— start the server when app starts.
user-service/package.json
{
"name": "user-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
Notes: lists dependencies. In modern Node, you can use express.json() instead of body-parser, but this works fine.
task-service
task-service/Dockerfile
Same pattern as user-service:
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3001
CMD [ "node","index.js" ]
(Explained earlier — just exposes port 3001 and runs index.js.)
task-service/index.js
const express = require("express");
const mongoose = require("mongoose");
const bodyParser = require("body-parser");
const amqp = require("amqplib");
const app = express();
const port = 3001;
app.use(bodyParser.json());
mongoose
.connect("mongodb://mongo:27017/tasks")
.then(() => {
console.log("Connected to MongoDB");
})
.catch((error) => {
console.error("Error connecting to MongoDB:", error);
});
const TaskSchema = new mongoose.Schema({
title: String,
description: String,
userId: String,
createdAt: { type: Date, default: Date.now },
});
const Task = mongoose.model("Task", TaskSchema);
let channel, connection;
async function connectRabbitMQWithRetry(retries = 5, delay = 3000) {
while (retries) {
try {
connection = await amqp.connect("amqp://rabbitmq");
channel = await connection.createChannel();
await channel.assertQueue("task_created");
console.log("Connected to RabbitMQ");
return;
} catch (error) {
console.error("RabbitMQ Connection Error: ", error);
retries--;
console.error("Retrying again: ", retries);
await new Promise((res) => setTimeout(res, delay));
}
}
}
app.get("/tasks", async (req, res) => {
const tasks = await Task.find();
res.json(tasks);
});
app.post("/tasks", async (req, res) => {
const { title, description, userId } = req.body;
try {
const task = new Task({ title, description, userId });
await task.save();
const message = {
taskId: task._id,
userId,
title,
};
if (!channel) {
return res.status(503).json({ error: "RabbitMQ not connected" });
}
channel.sendToQueue("task_created", Buffer.from(JSON.stringify(message)));
res.status(201).json(task);
} catch (error) {
console.error("Error Saving: ", error);
res.status(500).json({ error: "Internal Server Error" });
}
});
app.listen(port, () => {
console.log(`Task service listening on port ${port}`);
connectRabbitMQWithRetry();
});
Line-by-line (key points):
- Imports similar to user-service, plus
amqplibto talk to RabbitMQ using AMQP protocol. -
mongoose.connect("mongodb://mongo:27017/tasks")— connects totasksdatabase. -
TaskSchemaincludestitle,description,userId, andcreatedAt. -
let channel, connection;— will hold RabbitMQ connection and channel (channels are logical connections inside an AMQP connection). -
connectRabbitMQWithRetry(retries = 5, delay = 3000)— helper that tries to connect up to 5 times, waitingdelayms between attempts. This mitigates startup race conditions where RabbitMQ isn't ready when the container starts.-
connection = await amqp.connect("amqp://rabbitmq")— connects to hostrabbitmq(Compose DNS). -
channel = await connection.createChannel()— creates a channel. -
await channel.assertQueue("task_created")— asserts the queue exists (creates if not).
-
-
POST /tasks— creates a new Task, persists it, then:- Builds
messageobject withtaskId,userId,title. - If
channelis falsy (RabbitMQ not connected), returns503 Service Unavailable. -
channel.sendToQueue("task_created", Buffer.from(JSON.stringify(message)));— publishes the message to queue (note: default exchange, queue name used as routing key).
- Builds
app.listen(...); connectRabbitMQWithRetry();— on startup the service starts listening and then attempts to connect to RabbitMQ.
Why this design? Worker/consumer decoupling — the service that creates tasks does not directly call notification system; instead it publishes an event and moves on. This enables scalability and resilience.
task-service/package.json
{
"name": "task-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"amqplib": "^0.10.9",
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
notification-service
notification-service/Dockerfile
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
EXPOSE 3002
CMD [ "node","index.js" ]
Same pattern. (Expose 3002 but this service doesn't necessarily need HTTP; okay for parity.)
notification-service/index.js
const amqp = require("amqplib");
async function start() {
try {
connection = await amqp.connect("amqp://rabbitmq");
channel = await connection.createChannel();
await channel.assertQueue("task_created");
console.log("Notification Service is listening to messages");
channel.consume("task_created", (msg) => {
const taskData = JSON.parse(msg.content.toString());
console.log("Notification: NEW TASK: ", taskData.title);
console.log("Notification: NEW TASK: ", taskData);
channel.ack(msg);
});
} catch (error) {
console.error("RabbitMQ Connection Error: ", error.message);
}
}
start();
Explanation:
- Imports
amqplib. -
start()attempts to connect to RabbitMQ atamqp://rabbitmq. - Creates a channel and
assertQueue("task_created")to ensure the queue exists. -
channel.consume("task_created", (msg) => { ... })— registers a consumer callback for messages arriving on the queue.- Parses the message and logs it (your notification logic would go here: send email, push, etc.)
-
channel.ack(msg)— acknowledges the message so RabbitMQ removes it from the queue.
start();— run the consumer.
Note: This service has no retry loop. If RabbitMQ isn't ready at startup, it will fail once and quit — consider adding the same retry logic as task-service or a supervisor to restart the container. Docker Compose will try to restart depending on restart policy.
notification-service/package.json
{
"name": "notification-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"amqplib": "^0.10.9",
"body-parser": "^2.2.0",
"express": "^5.1.0",
"mongoose": "^8.19.3"
}
}
How to run locally (commands)
From the project root (where docker-compose.yml sits):
- Build and run in detached mode:
docker-compose up --build -d
- See logs:
docker-compose logs -f
- Open RabbitMQ management UI:
http://localhost:15672/
- Default username/password for the base image are
guest/guestwhen accessed from the same host. (If running remote, change credentials.) - There you can inspect the
task_createdqueue.
- Test the API:
- Create a user:
curl -X POST http://localhost:3000/users -H "Content-Type: application/json" -d '{"name":"Alice","email":"alice@example.com"}'
- Create a task for that user:
curl -X POST http://localhost:3001/tasks -H "Content-Type: application/json" -d '{"title":"Buy milk","description":"From store","userId":"<userId>"}'
When you POST /tasks, the task-service will save the task and publish to RabbitMQ — the notification-service will consume the message and print the notification in its logs. Check the notification-service logs:
docker-compose logs -f notification-service
# or
docker logs -f notification-service
Why RabbitMQ? When and why use a message broker
What is a message broker?
A message broker (RabbitMQ, Kafka, Redis streams, etc.) enables asynchronous communication between services. Instead of calling the notification service directly, task-service posts a message describing an event to the broker. The notification service subscribes and reacts to these events.
Why use a broker (benefits)
- Decoupling — Producer and consumer do not need to be running at the same time or know about each other’s HTTP API. They only agree on the message format and queue/exchange name.
- Resilience — If the consumer is down, messages wait in the broker queue for it to come back.
- Scalability — Multiple consumers can process messages in parallel (consumers scale horizontally).
- Retry & DLQ — You can configure retries, dead-letter queues for failed messages.
- Buffering/Load leveling — If a spike in tasks occurs, the queue buffers requests while consumers process them at their pace.
- Flexibility — Add new consumers (e.g., analytics, audit logs) that subscribe to the same events without modifying producers.
When to use a message broker
- When operations triggered by an event are not required to complete synchronously (e.g., sending email after creating task).
- When you expect bursts of events and want to smooth processing.
- When multiple independent systems should react to the same event (fan-out).
- When you want to achieve loose coupling between services.
When not to use a message broker
- For simple CRUD where synchronous response is required (e.g., login). Overusing a broker adds complexity.
- If your operations must be atomic across services without compensation or two-phase commit (distributed transactions are hard).
- Small projects / prototypes where REST calls are simpler — but often using a broker from beginning can pay off later.
Quick troubleshooting tips
-
If
task-servicesaysRabbitMQ not connected:- Check RabbitMQ status:
docker-compose logs rabbitmqandhttp://localhost:15672(guest/guest). - Ensure
task-serviceattempted to reconnect (see console logs).
- Check RabbitMQ status:
-
If Mongo connection fails:
- Check
docker-compose logs mongoand ensure container started. Usedocker exec -it mongo bash+mongoshto test.
- Check
-
If messages aren't delivered:
- Open RabbitMQ management UI, look at queue lengths, bindings, and consumers.
-
If containers don’t start:
-
docker-compose psto inspect states;docker-compose logs <service>for errors.
-
Example flow — what happens when you create a task
- Client sends
POST /taskstotask-servicewith{ title, description, userId }. -
task-servicesaves task to MongoDBtaskscollection. -
task-servicepublishes a JSON message to thetask_createdqueue via RabbitMQ. -
notification-service(consumer) receives the message and executes code to send a notification (currently logs to console). - Consumer
acks the message so RabbitMQ removes it.
This split of responsibilities makes the architecture robust and easily extensible.
Final remarks
This example is a hands-on beginner-friendly microservice demo:
- It shows how to persist data (MongoDB), expose HTTP APIs (Express), and decouple services using RabbitMQ.
- The code is intentionally simple so you can focus on the architecture and messaging patterns.
Follow me on : Github Linkedin Threads Youtube Channel
Top comments (0)