Microservices Order System with Docker
Build 3 independent Node.js services orchestrated with Docker Compose.
Introduction
30 Second Summary
Every app you use daily, from food delivery to ride-sharing, is actually dozens of small services talking to each other behind the scenes. When one team updates the payment system, they don't have to redeploy the entire app.
In this project, you will build a microservices order system with three independent Node.js services, each with its own database, all orchestrated by a single Docker Compose command.
What You'll Build
A containerized microservices system consisting of three Express services communicating over HTTP, fronted by an API Gateway, with the entire stack running in Docker containers.
By the end of this project, you'll have:
- A Product Service and Order Service that communicate over HTTP, each backed by its own MongoDB instance.
- An API Gateway that routes incoming requests to the correct backend service based on URL path.
- A Docker Compose configuration that spins up all 5 containers (3 services + 2 databases) with one command.
- Secret Mission: Add a health check dashboard endpoint that reports the real-time status of all downstream services.
Are there any prerequisites?
This project assumes you are comfortable with Node.js/Express and REST APIs (a MERN or MEAN background is perfect). You should also have Docker Desktop, VS Code, and Node.js already installed on your machine.
Before We Start
Before We Start...
Set Up the Project Structure
In a microservices system, each service is its own isolated unit. It has its own dependencies, its own database connection, and its own deployment lifecycle.
In this step, you'll create a monorepo folder structure that gives each service its own directory, initialize them with their own dependencies, and set up placeholder files for the code you'll write next.
In this step, get ready to:
- Create the monorepo folder structure with separate directories for each microservice.
- Initialize each service with its own package.json and install dependencies.
- Create the shared Docker Compose file and Dockerfiles for each service.
Create the project folder structure
Every microservice needs its own directory so its dependencies and code stay isolated from the others. You'll create a root folder on your Desktop and three subdirectories inside it.
- In VS Code, open a new terminal by pressing Ctrl+` (macOS and Windows).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project folder and move into it by running these commands:
mkdir microservices-order-system
cd microservices-order-system
- Create the three service directories by running:
mkdir product-service order-service api-gateway
- Confirm the folders exist by running:
ls
You should see product-service, order-service, and api-gateway listed in the output.
Not seeing all three folders?
Make sure you ran the mkdir command with all three directory names separated by spaces. If one is missing, run mkdir again with just the missing name.
Help me troubleshoot my missing folders
- Now open this folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the microservices-order-system folder, and click Open.
VS Code's Explorer sidebar now shows your three service directories. The integrated terminal also resets to this folder as its working directory.
Initialize each service with its package.json
Each service needs its own package.json that declares its specific dependencies. The Product Service and Order Service both need Express and Mongoose, while the API Gateway uses Express and a proxy library.
- In VS Code's Explorer panel, right-click on the product-service folder and select New File.
- Name the file package.json and press Enter.
- Paste the following code into the file:
{
"name": "product-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"mongoose": "8.13.2"
}
}
What does this file do?
- "name" identifies this service for npm. Each service has a unique name.
- "scripts"."start" defines the command Docker will use to launch this service.
- "dependencies" lists the exact package versions this service needs. Express 5.1.0 is the web framework. Mongoose 8.13.2 is the MongoDB object modeling library.
- Save product-service/package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Now repeat the same process for the Order Service. Right-click on the order-service folder and select New File.
- Name the file package.json and paste the following:
{
"name": "order-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"mongoose": "8.13.2"
}
}
Why the same dependencies?
The Order Service also uses Express for its REST API and Mongoose for its own MongoDB database. Each service gets its own copy of these packages in its own node_modules folder. This isolation means you can upgrade one service's dependencies without affecting the others.
This is the database-per-service pattern in action. Each service owns its data store and its dependency tree independently.
- Save order-service/package.json.
- Finally, right-click on the api-gateway folder and select New File.
- Name the file package.json and paste the following:
{
"name": "api-gateway",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"http-proxy-middleware": "3.0.5"
}
}
What is http-proxy-middleware?
Instead of Mongoose, the gateway uses http-proxy-middleware (3.0.5). This library forwards incoming requests to the correct backend service based on URL path. It handles the plumbing of an API Gateway pattern so you can focus on routing logic.
- Save api-gateway/package.json.
- Install the Product Service dependencies by running these commands:
cd product-service
npm install
cd ..
- Install the Order Service dependencies by running:
cd order-service
npm install
cd ..
- Install the API Gateway dependencies by running:
cd api-gateway
npm install
cd ..
Each npm install creates a node_modules folder and a package-lock.json inside its respective service directory.
Seeing npm errors during install?
Make sure you are inside the correct service directory before running npm install. The terminal prompt should show the service folder name. If you see a permission error, try closing and reopening the terminal.
Help me troubleshoot npm install errors
✔️ Awesome, I've got everything!
Great. Make sure all three package.json files are saved before continuing.
ⓧ I'd like to double check the full code
Here are all three package.json files for comparison:
{
"name": "product-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"mongoose": "8.13.2"
}
}
{
"name": "order-service",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"mongoose": "8.13.2"
}
}
{
"name": "api-gateway",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "5.1.0",
"http-proxy-middleware": "3.0.5"
}
}
Create the Dockerfiles and placeholder files
Each service needs an index.js file for its application code and a Dockerfile for containerization. You also need a compose.yaml at the project root to orchestrate everything with Docker Compose.
- Create the placeholder files for all three services and the Compose file by running these commands:
touch product-service/index.js product-service/Dockerfile
touch order-service/index.js order-service/Dockerfile
touch api-gateway/index.js api-gateway/Dockerfile
touch compose.yaml
- Confirm all the files were created by running:
ls product-service
You should see Dockerfile, index.js, node_modules, package-lock.json, and package.json in the output. The other two service folders should look the same.
- Verify the root-level Compose file exists by running:
ls compose.yaml
You should see compose.yaml listed.
Missing files?
If touch did not create a file, check for typos in the path. The path must include the service folder name (e.g. product-service/index.js). Make sure your terminal's working directory is microservices-order-system (the project root), not one of the service subdirectories.
Help me figure out why my files weren't created
Why create empty files now?
Creating all the placeholder files upfront gives you a complete picture of the project structure in VS Code's Explorer. You'll fill in each file in the upcoming steps. This is a common pattern in microservices projects where you scaffold the skeleton first, then implement service by service.
Your project skeleton is ready. You have three isolated service directories, each with its own dependencies installed. Next up, you'll build the Product Service and connect it to its own MongoDB database.
Build the Product Service
Your project structure is in place with three service directories, each with its own package.json and dependencies installed.
Now it's time to bring the first service to life. The Product Service is the foundation of your order system. It manages the product catalog that other services will depend on. You will build a full REST API backed by MongoDB using Mongoose for data modeling.
In this step, get ready to:
- Create the Mongoose schema and Express routes for product CRUD operations.
- Add seed data so the service starts with sample products.
- Test the service locally and write its Dockerfile.
Create the Mongoose schema and Express routes
Every microservice needs a clearly defined data model and a set of API endpoints that let other services interact with it.
- In VS Code, open product-service/index.js and paste the following code:
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Port and database connection string (overridable via environment variable)
const PORT = 3001;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/products';
// Define the product data model
const productSchema = new mongoose.Schema({
name: { type: String, required: true },
price: { type: Number, required: true },
stock: { type: Number, required: true, default: 0 },
description: { type: String, default: '' }
});
const Product = mongoose.model('Product', productSchema);
// GET all products
app.get('/products', async (req, res) => {
const products = await Product.find();
res.json(products);
});
// GET a single product by ID
app.get('/products/:id', async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
} catch (error) {
res.status(400).json({ error: 'Invalid product ID' });
}
});
What does this code do?
This sets up the foundation of your Product Service. The productSchema defines four fields: name, price, stock, and description. Mongoose uses this schema to validate every document before saving it to MongoDB.
The MONGO_URI reads from an environment variable first, falling back to a local connection. This pattern lets you swap the database URL in production without changing code.
The two GET routes handle listing all products and fetching a single product by its MongoDB _id. The try/catch in the second route handles cases where the ID format is invalid.
- You won't see output yet. Add the write routes below the code you just pasted:
// POST a new product
app.post('/products', async (req, res) => {
try {
const product = new Product(req.body);
await product.save();
res.status(201).json(product);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// PATCH reduce stock (called by Order Service when placing orders)
app.patch('/products/:id/reduce-stock', async (req, res) => {
try {
const { quantity } = req.body;
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
if (product.stock < quantity) {
return res.status(400).json({ error: 'Insufficient stock' });
}
product.stock -= quantity;
await product.save();
res.json(product);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
// Health check endpoint
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'product-service', uptime: process.uptime() });
});
What do these routes do?
- POST /products creates a new product from the request body. Mongoose validates the data against your schema before saving.
- PATCH /products/:id/reduce-stock is the inter-service endpoint. When the Order Service places an order, it calls this route to decrement stock. The route checks that enough stock exists before reducing it.
- GET /health returns the service name and uptime. You will use this later to monitor whether the service is running.
- Save product-service/index.js.
You won't see any output yet because the file has no startup logic. The next section adds the database connection and seed data.
Seeing red underlines in VS Code?
Make sure your code is inside product-service/index.js, not the project root. The require('express') and require('mongoose') calls resolve from the node_modules inside product-service/.
If you see an error about missing modules, confirm that npm install was run inside the product-service folder in the previous step.
Help me fix module resolution errors in my Product Service
Add seed data so the service starts with sample products
A product catalog is not useful if it starts empty. Seed data gives you products to test with immediately, without manually creating them via API calls.
- In product-service/index.js, add the following code below the health endpoint:
// Insert sample products if the database is empty
async function seedProducts() {
const count = await Product.countDocuments();
if (count === 0) {
await Product.insertMany([
{ name: 'Mechanical Keyboard', price: 89.99, stock: 50, description: 'RGB backlit mechanical keyboard' },
{ name: 'Wireless Mouse', price: 34.99, stock: 100, description: 'Ergonomic wireless mouse' },
{ name: 'USB-C Hub', price: 49.99, stock: 75, description: '7-port USB-C hub with HDMI' }
]);
console.log('Seed data inserted');
}
}
// Connect to MongoDB, seed data, then start the server
mongoose.connect(MONGO_URI)
.then(() => {
console.log('Product Service connected to MongoDB');
return seedProducts();
})
.then(() => {
app.listen(PORT, () => {
console.log(`Product Service running on port ${PORT}`);
});
})
.catch(err => {
console.error('Failed to connect to MongoDB:', err.message);
process.exit(1);
});
What does this code do?
The seedProducts() function checks if the database is empty. If so, it inserts three sample products. The if (count === 0) guard ensures seed data is only inserted once, even if you restart the service.
The startup sequence chains three steps: connect to MongoDB, seed products, then start listening on port 3001. If the database connection fails, process.exit(1) stops the service cleanly instead of running without a database.
- Save product-service/index.js.
The file is now complete. You will test it in the next substep by starting a MongoDB container and running the service.
✔️ Awesome, I've got everything!
Great. Double check you have saved product-service/index.js before continuing.
ⓧ I'd like to double check the full code
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
const PORT = 3001;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/products';
const productSchema = new mongoose.Schema({
name: { type: String, required: true },
price: { type: Number, required: true },
stock: { type: Number, required: true, default: 0 },
description: { type: String, default: '' }
});
const Product = mongoose.model('Product', productSchema);
app.get('/products', async (req, res) => {
const products = await Product.find();
res.json(products);
});
app.get('/products/:id', async (req, res) => {
try {
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
res.json(product);
} catch (error) {
res.status(400).json({ error: 'Invalid product ID' });
}
});
app.post('/products', async (req, res) => {
try {
const product = new Product(req.body);
await product.save();
res.status(201).json(product);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.patch('/products/:id/reduce-stock', async (req, res) => {
try {
const { quantity } = req.body;
const product = await Product.findById(req.params.id);
if (!product) {
return res.status(404).json({ error: 'Product not found' });
}
if (product.stock < quantity) {
return res.status(400).json({ error: 'Insufficient stock' });
}
product.stock -= quantity;
await product.save();
res.json(product);
} catch (error) {
res.status(400).json({ error: error.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'product-service', uptime: process.uptime() });
});
async function seedProducts() {
const count = await Product.countDocuments();
if (count === 0) {
await Product.insertMany([
{ name: 'Mechanical Keyboard', price: 89.99, stock: 50, description: 'RGB backlit mechanical keyboard' },
{ name: 'Wireless Mouse', price: 34.99, stock: 100, description: 'Ergonomic wireless mouse' },
{ name: 'USB-C Hub', price: 49.99, stock: 75, description: '7-port USB-C hub with HDMI' }
]);
console.log('Seed data inserted');
}
}
mongoose.connect(MONGO_URI)
.then(() => {
console.log('Product Service connected to MongoDB');
return seedProducts();
})
.then(() => {
app.listen(PORT, () => {
console.log(`Product Service running on port ${PORT}`);
});
})
.catch(err => {
console.error('Failed to connect to MongoDB:', err.message);
process.exit(1);
});
Test the service locally and write the Dockerfile
Before containerizing anything, it is good practice to confirm the service connects to MongoDB and responds to requests. You will spin up a temporary MongoDB container, run the service with Node.js, and hit the endpoints with curl.
- Start a temporary MongoDB container by running this command:
docker run -d --name product-db-test -p 27017:27017 mongo:8.0
What does this command do?
This pulls the official mongo:8.0 image and runs it in detached mode (-d). The -p 27017:27017 flag maps the container's MongoDB port to your host machine so your local Node.js process can connect to it.
- Open a second terminal in VS Code by pressing **Cmd+Shift+`** (macOS) or **Ctrl+Shift+`** (Windows).
- Navigate into the product-service folder and start the service by running:
cd product-service
node index.js
- You should see the following output in your terminal:
Product Service connected to MongoDB
Seed data inserted
Product Service running on port 3001
- Open a third terminal and test the products endpoint by running:
curl http://localhost:3001/products
You should see a JSON array containing three products (Mechanical Keyboard, Wireless Mouse, USB-C Hub) with their prices and stock values.
- Stop the running service by pressing Ctrl+C in the terminal where node index.js is running.
- Remove the temporary MongoDB container by running:
docker stop product-db-test && docker rm product-db-test
Service won't connect to MongoDB?
Make sure Docker Desktop is running. If you see a connection refused error, confirm the MongoDB container started successfully by running docker ps to list running containers.
If you see a port conflict on 27017, another MongoDB instance may already be using that port. Stop it first, then retry the docker run command.
Help me troubleshoot my Product Service MongoDB connection
The service works locally. Now containerize it so it can run alongside other services in Docker Compose later.
- Open product-service/Dockerfile and paste the following:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3001
CMD ["npm", "start"]
What does this Dockerfile do?
- FROM node:22-alpine uses a lightweight Node.js 22 base image to keep the container small.
- COPY package.json is copied first so Docker can cache the npm install layer. If your code changes but dependencies don't, Docker skips reinstalling.
- EXPOSE 3001 documents which port the service listens on. The CMD runs npm start which triggers node index.js as defined in package.json.
- Save product-service/Dockerfile.
- Confirm the file exists by running:
ls product-service/Dockerfile
You should see product-service/Dockerfile printed in the output.
✔️ Awesome, I've got everything!
Great. Double check you have saved product-service/Dockerfile before continuing.
ⓧ I'd like to double check the full code
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3001
CMD ["npm", "start"]
Your first microservice is fully built and tested. Next up, you will build the Order Service that communicates with the Product Service over HTTP to validate and place orders.
Build the Order Service
Your Product Service is running with a full CRUD API and seed data. Now it's time to build the service that actually uses it.
The Order Service processes customer orders. What makes it special is that it doesn't work alone. When someone places an order, it reaches out to the Product Service over HTTP to check that the product exists and has enough stock before confirming.
In this step, get ready to:
- Create the Order schema and a route to list all orders.
- Build a POST route that communicates with the Product Service to validate and place orders.
- Add a health endpoint and containerize the service with a Dockerfile.
Create the Order schema and GET route
The Order Service follows the same pattern as the Product Service. It has its own Mongoose schema, its own database connection, and its own Express app.
One key difference is the PRODUCT_SERVICE_URL config variable. This tells the Order Service where to find the Product Service when it needs to communicate.
- In VS Code, open order-service/index.js and add the following code:
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
// Service configuration
const PORT = 3002;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/orders';
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://localhost:3001';
// Define the shape of an order document
const orderSchema = new mongoose.Schema({
productId: { type: String, required: true },
productName: { type: String, required: true },
quantity: { type: Number, required: true },
totalPrice: { type: Number, required: true },
status: { type: String, default: 'confirmed' },
createdAt: { type: Date, default: Date.now }
});
const Order = mongoose.model('Order', orderSchema);
// Return all orders, newest first
app.get('/orders', async (req, res) => {
const orders = await Order.find().sort({ createdAt: -1 });
res.json(orders);
});
What does this code do?
- PRODUCT_SERVICE_URL stores the address of the Product Service. Inside Docker, this will be overridden by an environment variable pointing to the container's hostname.
- The orderSchema stores key details about each order: which product, how many, the total cost, and a timestamp.
- The GET /orders route returns all orders sorted by newest first, so the most recent orders appear at the top.
- Save index.js.
- You won't see a visible change yet. The next chunk adds the POST route that makes this service come alive.
Implement the POST route with inter-service communication
This is where inter-service communication happens. When a client sends a POST request to create an order, the Order Service calls the Product Service over HTTP to validate the product and reduce its stock.
Why HTTP calls instead of a shared database?
In a microservices architecture, each service owns its own data. The Order Service cannot query the Product Service's database directly.
Instead, it makes an HTTP request to the Product Service's API. This keeps services independent and deployable on their own. If the Product Service changes its database schema, the Order Service is unaffected as long as the API contract stays the same.
- In order-service/index.js, add the POST route below the GET /orders route:
app.post('/orders', async (req, res) => {
try {
const { productId, quantity } = req.body;
// Validate required fields
if (!productId || !quantity) {
return res.status(400).json({ error: 'productId and quantity are required' });
}
// Call Product Service to check the product exists
const productResponse = await fetch(`${PRODUCT_SERVICE_URL}/products/${productId}`);
if (!productResponse.ok) {
if (productResponse.status === 404) {
return res.status(404).json({ error: 'Product not found' });
}
return res.status(502).json({ error: 'Failed to reach Product Service' });
}
const product = await productResponse.json();
// Check there is enough stock available
if (product.stock < quantity) {
return res.status(400).json({ error: `Insufficient stock. Available: ${product.stock}` });
}
// Tell Product Service to reduce the stock
const reduceResponse = await fetch(`${PRODUCT_SERVICE_URL}/products/${productId}/reduce-stock`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity })
});
if (!reduceResponse.ok) {
return res.status(400).json({ error: 'Failed to reduce stock' });
}
// Save the confirmed order
const order = new Order({
productId,
productName: product.name,
quantity,
totalPrice: product.price * quantity
});
await order.save();
res.status(201).json(order);
} catch (error) {
res.status(500).json({ error: 'Internal server error: ' + error.message });
}
});
What does this route do?
This route orchestrates the entire order flow across two services:
- First, it validates the request body has productId and quantity.
- It calls GET /products/:id on the Product Service using Node.js native fetch to confirm the product exists.
- It checks the product has enough stock for the requested quantity.
- It calls PATCH /products/:id/reduce-stock to decrement inventory on the Product Service.
- Only after all checks pass does it save the order to its own database.
- Save index.js.
- You won't see a change yet. The full end-to-end test happens in Step 5 when all services run together via Docker Compose.
Wondering about the fetch call?
Node.js includes a native fetch function (available since Node 18). No extra HTTP client library is needed. If you see a linter warning about fetch being undefined, your editor may be targeting an older Node version. The code will work correctly at runtime with Node 22.
help me understand how fetch works in Node.js
Add the health endpoint and Dockerfile
The health endpoint lets monitoring tools check if the service is alive. The Dockerfile packages the service into a container so Docker can run it.
- In order-service/index.js, add the health endpoint and startup code below the POST route:
// Health check endpoint for monitoring
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'order-service', uptime: process.uptime() });
});
// Connect to MongoDB and start the server
mongoose.connect(MONGO_URI)
.then(() => {
console.log('Order Service connected to MongoDB');
app.listen(PORT, () => {
console.log(`Order Service running on port ${PORT}`);
});
})
.catch(err => {
console.error('Failed to connect to MongoDB:', err.message);
process.exit(1);
});
What does this code do?
- GET /health returns the service name and uptime. You will use this in the Secret Mission to build a health dashboard.
- mongoose.connect() connects to MongoDB using the MONGO_URI environment variable, then starts the Express server on port 3002.
- Save index.js.
✔️ Awesome, I've got everything!
Great. Make sure you have saved order-service/index.js before moving on to the Dockerfile.
ⓧ I'd like to double check the full code
const express = require('express');
const mongoose = require('mongoose');
const app = express();
app.use(express.json());
const PORT = 3002;
const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017/orders';
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://localhost:3001';
const orderSchema = new mongoose.Schema({
productId: { type: String, required: true },
productName: { type: String, required: true },
quantity: { type: Number, required: true },
totalPrice: { type: Number, required: true },
status: { type: String, default: 'confirmed' },
createdAt: { type: Date, default: Date.now }
});
const Order = mongoose.model('Order', orderSchema);
app.get('/orders', async (req, res) => {
const orders = await Order.find().sort({ createdAt: -1 });
res.json(orders);
});
app.post('/orders', async (req, res) => {
try {
const { productId, quantity } = req.body;
if (!productId || !quantity) {
return res.status(400).json({ error: 'productId and quantity are required' });
}
const productResponse = await fetch(`${PRODUCT_SERVICE_URL}/products/${productId}`);
if (!productResponse.ok) {
if (productResponse.status === 404) {
return res.status(404).json({ error: 'Product not found' });
}
return res.status(502).json({ error: 'Failed to reach Product Service' });
}
const product = await productResponse.json();
if (product.stock < quantity) {
return res.status(400).json({ error: `Insufficient stock. Available: ${product.stock}` });
}
const reduceResponse = await fetch(`${PRODUCT_SERVICE_URL}/products/${productId}/reduce-stock`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ quantity })
});
if (!reduceResponse.ok) {
return res.status(400).json({ error: 'Failed to reduce stock' });
}
const order = new Order({
productId,
productName: product.name,
quantity,
totalPrice: product.price * quantity
});
await order.save();
res.status(201).json(order);
} catch (error) {
res.status(500).json({ error: 'Internal server error: ' + error.message });
}
});
app.get('/health', (req, res) => {
res.json({ status: 'ok', service: 'order-service', uptime: process.uptime() });
});
mongoose.connect(MONGO_URI)
.then(() => {
console.log('Order Service connected to MongoDB');
app.listen(PORT, () => {
console.log(`Order Service running on port ${PORT}`);
});
})
.catch(err => {
console.error('Failed to connect to MongoDB:', err.message);
process.exit(1);
});
Now let's containerize the service. The Dockerfile follows the same pattern as the Product Service.
- Create a new file called Dockerfile inside the order-service folder and add this code:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3002
CMD ["npm", "start"]
What does this Dockerfile do?
- FROM node:22-alpine uses a lightweight Node.js 22 base image.
- COPY package.json first, then npm install, lets Docker cache the dependency layer. Rebuilds are faster when only your code changes.
- EXPOSE 3002 documents the port the service listens on (matching the PORT variable in index.js).
- Save Dockerfile.
- Confirm the file exists by running this in your terminal from the order-service directory:
ls
You should see Dockerfile, index.js, package.json, and node_modules in the output.
Don't see the Dockerfile?
Make sure you created the file inside the order-service folder, not in the project root. The file must be named exactly Dockerfile with a capital D and no file extension.
help me find where my Dockerfile ended up
✔️ Awesome, I've got everything!
Great. Make sure you have saved both order-service/index.js and order-service/Dockerfile.
ⓧ I'd like to double check the full code
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3002
CMD ["npm", "start"]
Your Order Service is ready. It knows how to validate products, check stock, and save confirmed orders. Next up, you will build the API Gateway that ties all your services together under a single entry point.
Build the API Gateway
Your two backend services are ready. The Product Service manages catalog data and the Order Service places orders by calling the Product Service internally.
Right now, a client would need to know each service's exact URL and port to send requests. In a real production system, that leaks implementation details and makes scaling painful. The API Gateway pattern solves this by giving clients a single entry point that routes requests to the correct backend service behind the scenes.
In this step, get ready to:
- Create the API Gateway Express server with request logging.
- Configure proxy routes that forward requests to the Product and Order services.
- Write the gateway's Dockerfile for containerization.
Set up the gateway server with logging
The gateway is the only service clients interact with directly. Every request hits the gateway first, gets logged, then gets forwarded to the right backend service.
- In VS Code, open api-gateway/index.js and add the following code:
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
// Port the gateway listens on
const PORT = 3000;
// URLs for the backend services (overridden by environment variables in Docker)
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
// Log every incoming request before routing it
app.use((req, res, next) => {
console.log(`[Gateway] ${req.method} ${req.url} -> routing...`);
next();
});
What does this code do?
The createProxyMiddleware import comes from the http-proxy-middleware package you installed earlier. This is the library that handles forwarding requests to other services.
The two URL constants (PRODUCT_SERVICE_URL and ORDER_SERVICE_URL) default to localhost for local development but will be overridden by environment variables when running inside Docker. The logging middleware runs on every request so you can trace which service handles each call.
- Save api-gateway/index.js.
Configure proxy routes with http-proxy-middleware
The gateway needs to know which URL paths map to which backend service. Requests to /api/products should go to the Product Service, and requests to /api/orders should go to the Order Service.
- In api-gateway/index.js, add the proxy routes below the logging middleware:
// Forward any request starting with /api/products to the Product Service
app.use('/api/products', createProxyMiddleware({
target: PRODUCT_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/products': '/products' }
}));
// Forward any request starting with /api/orders to the Order Service
app.use('/api/orders', createProxyMiddleware({
target: ORDER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/orders': '/orders' }
}));
What does pathRewrite do?
The pathRewrite option transforms the URL before forwarding it. When a client hits /api/products on the gateway, the proxy strips /api/products and replaces it with /products before sending it to the Product Service.
This means the backend services don't need to know they're sitting behind a gateway. They just handle requests at their own paths (/products, /orders) while the gateway handles the public-facing URL structure.
- Save api-gateway/index.js.
You won't see a visible change yet because the gateway needs the backend services running to proxy to. The next step brings everything together with Docker Compose.
Seeing a red underline on createProxyMiddleware?
Make sure the import at the top uses destructuring: const { createProxyMiddleware } = require('http-proxy-middleware'). Without the curly braces, the import won't resolve correctly.
Also confirm that http-proxy-middleware appears in your api-gateway/package.json dependencies. If it's missing, you may need to reinstall it.
help me debug a createProxyMiddleware import error in my API Gateway
Add the root route and write the Dockerfile
A simple root route gives anyone hitting the gateway a quick overview of available endpoints. This is useful when testing whether the gateway itself is running.
- In api-gateway/index.js, add the root route and server startup below the proxy routes:
// Root endpoint listing available routes
app.get('/', (req, res) => {
res.json({
service: 'API Gateway',
routes: {
products: '/api/products',
orders: '/api/orders'
}
});
});
// Start the gateway server
app.listen(PORT, () => {
console.log(`API Gateway running on port ${PORT}`);
});
What does this code do?
The GET / route returns a JSON object describing the gateway and its available paths. This acts as a quick self-documentation endpoint.
The app.listen call starts the server on port 3000. This is the only port clients need to know about. All traffic enters through port 3000 and gets routed internally.
- Save api-gateway/index.js.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
const PORT = 3000;
const PRODUCT_SERVICE_URL = process.env.PRODUCT_SERVICE_URL || 'http://localhost:3001';
const ORDER_SERVICE_URL = process.env.ORDER_SERVICE_URL || 'http://localhost:3002';
app.use((req, res, next) => {
console.log(`[Gateway] ${req.method} ${req.url} -> routing...`);
next();
});
app.use('/api/products', createProxyMiddleware({
target: PRODUCT_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/products': '/products' }
}));
app.use('/api/orders', createProxyMiddleware({
target: ORDER_SERVICE_URL,
changeOrigin: true,
pathRewrite: { '^/api/orders': '/orders' }
}));
app.get('/', (req, res) => {
res.json({
service: 'API Gateway',
routes: {
products: '/api/products',
orders: '/api/orders'
}
});
});
app.listen(PORT, () => {
console.log(`API Gateway running on port ${PORT}`);
});
Now the gateway needs its own Dockerfile so it can run as a container alongside the other services.
- Open api-gateway/Dockerfile and add the following:
FROM node:22-alpine
WORKDIR /app
COPY package.json ./
RUN npm install --production
COPY . .
EXPOSE 3000
CMD ["npm", "start"]
What does this Dockerfile do?
This follows the same pattern as your other service Dockerfiles. It uses the lightweight node:22-alpine base image, copies package.json first (so npm install is cached in a Docker layer), then copies the rest of the code.
The key difference is EXPOSE 3000 instead of 3001 or 3002. Port 3000 is the gateway's public-facing port.
- Save api-gateway/Dockerfile.
- Confirm both files exist by checking the api-gateway folder in the VS Code Explorer sidebar. You should see Dockerfile, index.js, and package.json listed.
Missing a file in api-gateway?
Make sure you created the Dockerfile in the api-gateway folder, not in the project root. The file should be at api-gateway/Dockerfile with no file extension.
help me figure out why my api-gateway folder is missing files
Your API Gateway is complete. All three services are coded and have their own Dockerfiles. Next up, you'll write the Docker Compose file that wires everything together and brings the full system to life with a single command.
Containerize with Docker Compose
You have three independent services and their Dockerfiles ready to go. The challenge now is running all of them together with two MongoDB databases, the right environment variables, and network connections between services.
Docker Compose solves this orchestration problem. You define every container, network link, and environment variable in one YAML file. Then a single command spins up the entire system.
In this step, get ready to:
- Write the Docker Compose configuration defining all 5 services.
- Launch the full system with one command.
- Test end-to-end through the API Gateway.
Write the Docker Compose configuration
The compose.yaml file is the blueprint for your entire system. It tells Docker which containers to build, what ports to expose, which environment variables to inject, and how services depend on each other.
- In VS Code, open the compose.yaml file in your project root (the microservices-order-system folder).
- Replace the empty file with the following configuration:
name: microservices-order-system
services:
api-gateway:
build: ./api-gateway
ports:
- "3000:3000"
environment:
- PRODUCT_SERVICE_URL=http://product-service:3001
- ORDER_SERVICE_URL=http://order-service:3002
depends_on:
- product-service
- order-service
product-service:
build: ./product-service
ports:
- "3001:3001"
environment:
- MONGO_URI=mongodb://product-db:27017/products
depends_on:
- product-db
order-service:
build: ./order-service
ports:
- "3002:3002"
environment:
- MONGO_URI=mongodb://order-db:27017/orders
- PRODUCT_SERVICE_URL=http://product-service:3001
depends_on:
- product-db
- order-db
product-db:
image: mongo:8.0
ports:
- "27018:27017"
volumes:
- product-data:/data/db
order-db:
image: mongo:8.0
ports:
- "27019:27017"
volumes:
- order-data:/data/db
volumes:
product-data:
order-data:
What does this configuration do?
This file defines 5 containers that Docker will run together on a shared network:
- api-gateway builds from its Dockerfile and receives environment variables pointing to the other services by their Compose service names (not localhost).
- product-service and order-service each get a MONGO_URI pointing to their own database container.
- product-db and order-db use the official mongo:8.0 image. Named volumes (product-data, order-data) persist data across container restarts.
- depends_on ensures databases start before the services that need them.
Docker Compose creates a network automatically. Services reference each other by their Compose name (product-service, order-db) instead of localhost. This is the key difference from running services individually.
Why no version field in compose.yaml?
The version top-level property is obsolete in modern Docker Compose. The Compose Specification replaced the old versioned formats, and modern Compose ignores any version field you add. The name field sets the project name that appears in Docker Desktop and CLI output.
- Save compose.yaml.
You won't see a visible change yet. The next substep uses this file to spin up all containers.
YAML looking off?
YAML is indentation-sensitive. Each level uses exactly 2 spaces (not tabs). Make sure services: is at the root level, each service name is indented 2 spaces under services:, and each property is indented 4 spaces from the root.
help me fix YAML indentation in my compose.yaml
Launch the system with Docker Compose
With your configuration saved, one command will build all three service images and start all five containers on a shared network.
- In your terminal, make sure you are in the project root (the microservices-order-system folder that contains compose.yaml).
- Start the full system by running:
docker compose up --build
What does this command do?
- docker compose up reads your compose.yaml, creates a network, pulls images, builds services, and starts all containers.
- --build forces Docker to rebuild the service images from your Dockerfiles. This ensures any code changes are picked up.
Docker will stream logs from all 5 containers into your terminal. Wait until you see output confirming each service has connected and started.
- Look for these messages in the log output:
product-service-1 | Product Service connected to MongoDB
product-service-1 | Seed data inserted
product-service-1 | Product Service running on port 3001
order-service-1 | Order Service connected to MongoDB
order-service-1 | Order Service running on port 3002
api-gateway-1 | API Gateway running on port 3000
Seeing connection errors in the logs?
If you see "Failed to connect to MongoDB" errors followed by retries, give it a few seconds. The depends_on field only controls startup order, not readiness. MongoDB may take a moment to initialize, and the services will retry and connect once the databases are ready.
If errors persist, make sure Docker Desktop is running and has enough allocated memory (2GB minimum).
help me troubleshoot Docker Compose connection errors
Test the full system end-to-end through the gateway
All requests now go through the API Gateway on port 3000. The gateway routes them to the correct backend service. Let's verify the entire flow works.
- Open a new terminal window (keep the Docker Compose logs running in the first one).
- Fetch all products through the gateway by running:
curl http://localhost:3000/api/products
- You should see a JSON array with three seeded products (Mechanical Keyboard, Wireless Mouse, USB-C Hub). Each product has an _id field.
- Copy one of the _id values from the response. You will use it to place an order.
- Place an order through the gateway by running this command (replace the product ID with the one you copied):
curl -X POST http://localhost:3000/api/orders -H "Content-Type: application/json" -d '{"productId": "[[PRODUCT_ID=\"the _id value you copied from the products response\"]]", "quantity": 2}'
- You should see a JSON response with a confirmed order containing the product name, quantity, and total price.
- Verify orders are persisted by running:
curl http://localhost:3000/api/orders
- You should see a JSON array containing the order you just placed.
What just happened?
Your request to /api/orders hit the API Gateway on port 3000. The gateway forwarded it to the Order Service.
The Order Service called the Product Service internally to validate stock. It reduced the stock count, then saved the order to its own database. Three services coordinated across two databases to fulfill one request.
Getting connection refused or empty responses?
Switch back to the terminal running Docker Compose and check for error messages. If you see gateway routing errors, make sure you are hitting http://localhost:3000 (the gateway port) and not the individual service ports.
If the POST order returns a 404 for the product, double-check that you copied the full _id string including all characters.
help me debug my microservices system through the API Gateway
✔️ Awesome, I've got everything!
Great. Make sure your Docker Compose logs are still running in the original terminal.
ⓧ I'd like to double check the full code
Here is the complete compose.yaml for comparison:
name: microservices-order-system
services:
api-gateway:
build: ./api-gateway
ports:
- "3000:3000"
environment:
- PRODUCT_SERVICE_URL=http://product-service:3001
- ORDER_SERVICE_URL=http://order-service:3002
depends_on:
- product-service
- order-service
product-service:
build: ./product-service
ports:
- "3001:3001"
environment:
- MONGO_URI=mongodb://product-db:27017/products
depends_on:
- product-db
order-service:
build: ./order-service
ports:
- "3002:3002"
environment:
- MONGO_URI=mongodb://order-db:27017/orders
- PRODUCT_SERVICE_URL=http://product-service:3001
depends_on:
- product-db
- order-db
product-db:
image: mongo:8.0
ports:
- "27018:27017"
volumes:
- product-data:/data/db
order-db:
image: mongo:8.0
ports:
- "27019:27017"
volumes:
- order-data:/data/db
volumes:
product-data:
order-data:
Secret mission
Add a Health Check Dashboard
In production, knowing whether your services are alive is critical. Can you add a /api/health endpoint to the API Gateway that probes each downstream service and reports their status in real time?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally using Docker containers, so there are no ongoing cloud costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- 5 Docker containers (api-gateway, product-service, order-service, product-db, order-db).
- 2 Docker volumes (product-data, order-data) storing MongoDB data.
- Local project folder with service directories and compose.yaml.
Keep everything running
No action needed. Choose this if you are still actively building or want to keep testing.
- Your containers will keep running in the background using minimal CPU and memory.
- MongoDB data persists in Docker volumes, so your seed products and any orders you created will still be there next time.
- To check that your containers are still running, open a terminal and run docker compose ps from the project folder.
Pause - I'll come back to this later
Shut down running containers to free up memory, but keep everything intact so you can restart later.
- Navigate to your project folder in the terminal.
- Stop all containers by running:
docker compose stop
- This stops all 5 containers without removing them or their volumes.
- When you are ready to resume, run docker compose start from the same folder to bring everything back up.
Delete - I don't want to use this again
Remove all containers, networks, and volumes to start completely fresh.
- Navigate to your project folder in the terminal.
- Remove all containers, networks, and volumes by running:
docker compose down -v
- This stops and removes all 5 containers, deletes the Docker network, and removes the product-data and order-data volumes (erasing all MongoDB data).
- Delete the local project folder by running:
cd ~/Desktop
rm -rf microservices-order-system
- On Windows, use this command instead:
cd ~\Desktop
rmdir /s /q microservices-order-system
- Your machine is now completely clean of this project.
Nice Work!
Nice Work!
Nice work! You just built a fully containerized microservices order system with three independent services communicating over HTTP, all orchestrated by a single Docker Compose file.
You've learned how to:
- Build 3 independent microservices (Product Service, Order Service, API Gateway) with the database-per-service pattern using separate MongoDB instances.
- Implement inter-service communication where the Order Service validates product availability by calling the Product Service over HTTP before placing an order.
- Orchestrate the entire system with Docker Compose, spinning up 5 containers (3 services + 2 databases) with one command.
- Secret Mission: Added a health check dashboard endpoint to the API Gateway that reports real-time status of all downstream services, including graceful degradation when a service is unavailable.
Ready to quiz yourself?