Building Node.js Microservices with Docker, RabbitMQ and API Gateway

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

Microservices architecture powers Netflix, Uber, and Amazon — and you can build the same patterns with Node.js. In this tutorial, you will build a complete e-commerce backend with three independent services communicating through RabbitMQ, orchestrated by an API Gateway, and containerized with Docker Compose.

What You Will Build

A mini e-commerce platform composed of independent microservices:

  • API Gateway — single entry point routing requests to services, handling auth and rate limiting
  • User Service — registration, authentication, JWT token management
  • Product Service — product catalog CRUD operations
  • Order Service — order creation with async inventory validation via RabbitMQ
  • RabbitMQ — message broker for asynchronous inter-service communication
  • Docker Compose — orchestrating all services with a single command

Architecture Overview

┌─────────────┐
│   Client     │
└──────┬───────┘
       │
┌──────▼───────┐
│  API Gateway │ :3000
│  (Express)   │
└──┬───┬───┬───┘
   │   │   │
   │   │   └──────────────────┐
   │   │                      │
┌──▼───┴──┐  ┌──────────┐  ┌─▼──────────┐
│  User   │  │ Product  │  │   Order    │
│ Service │  │ Service  │  │  Service   │
│  :3001  │  │  :3002   │  │   :3003    │
└─────────┘  └────┬─────┘  └─────┬──────┘
                  │              │
                  └──────┬───────┘
                         │
                  ┌──────▼───────┐
                  │  RabbitMQ    │
                  │   :5672      │
                  └──────────────┘

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ installed
  • Docker and Docker Compose installed
  • Basic understanding of Express.js and REST APIs
  • Familiarity with async/await patterns in JavaScript
  • A code editor (VS Code recommended)
  • A terminal

All services will run in Docker containers. You do not need RabbitMQ installed locally — Docker handles everything.


Step 1: Project Structure Setup

Create the monorepo structure for all services:

mkdir ecommerce-microservices && cd ecommerce-microservices
 
# Create service directories
mkdir -p api-gateway/src
mkdir -p user-service/src
mkdir -p product-service/src
mkdir -p order-service/src
mkdir -p shared/src

Initialize each service with its own package.json:

# Root package.json for workspace management
cat > package.json << 'EOF'
{
  "name": "ecommerce-microservices",
  "private": true,
  "workspaces": ["api-gateway", "user-service", "product-service", "order-service", "shared"]
}
EOF
 
# Initialize each service
for service in api-gateway user-service product-service order-service shared; do
  cd $service
  npm init -y
  cd ..
done

Your directory structure should look like this:

ecommerce-microservices/
├── api-gateway/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── user-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── product-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── order-service/
│   ├── src/
│   ├── Dockerfile
│   └── package.json
├── shared/
│   └── src/
├── docker-compose.yml
└── package.json

Step 2: Shared Utilities

Create shared modules that all services will use. This avoids code duplication across services.

RabbitMQ Connection Helper

// shared/src/rabbitmq.js
const amqp = require("amqplib");
 
class RabbitMQClient {
  constructor() {
    this.connection = null;
    this.channel = null;
  }
 
  async connect(url = process.env.RABBITMQ_URL || "amqp://localhost:5672") {
    const maxRetries = 10;
    let retries = 0;
 
    while (retries < maxRetries) {
      try {
        this.connection = await amqp.connect(url);
        this.channel = await this.connection.createChannel();
        console.log("Connected to RabbitMQ");
        return this.channel;
      } catch (error) {
        retries++;
        console.log(
          `RabbitMQ connection attempt ${retries}/${maxRetries} failed. Retrying in 3s...`
        );
        await new Promise((resolve) => setTimeout(resolve, 3000));
      }
    }
    throw new Error("Failed to connect to RabbitMQ after maximum retries");
  }
 
  async publishToQueue(queue, message) {
    if (!this.channel) throw new Error("Channel not initialized");
    await this.channel.assertQueue(queue, { durable: true });
    this.channel.sendToQueue(queue, Buffer.from(JSON.stringify(message)), {
      persistent: true,
    });
  }
 
  async consumeFromQueue(queue, callback) {
    if (!this.channel) throw new Error("Channel not initialized");
    await this.channel.assertQueue(queue, { durable: true });
    this.channel.prefetch(1);
    this.channel.consume(queue, async (msg) => {
      if (msg) {
        const content = JSON.parse(msg.content.toString());
        try {
          await callback(content);
          this.channel.ack(msg);
        } catch (error) {
          console.error("Message processing failed:", error);
          this.channel.nack(msg, false, true);
        }
      }
    });
    console.log(`Consuming from queue: ${queue}`);
  }
 
  async close() {
    if (this.channel) await this.channel.close();
    if (this.connection) await this.connection.close();
  }
}
 
module.exports = { RabbitMQClient };

Shared Response Helper

// shared/src/response.js
function successResponse(res, data, statusCode = 200) {
  return res.status(statusCode).json({
    success: true,
    data,
    timestamp: new Date().toISOString(),
  });
}
 
function errorResponse(res, message, statusCode = 500) {
  return res.status(statusCode).json({
    success: false,
    error: message,
    timestamp: new Date().toISOString(),
  });
}
 
module.exports = { successResponse, errorResponse };

Step 3: User Service

The User Service handles registration and authentication with JWT tokens.

Install Dependencies

cd user-service
npm install express jsonwebtoken bcryptjs uuid cors
cd ..

User Service Implementation

// user-service/src/index.js
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const { v4: uuidv4 } = require("uuid");
const cors = require("cors");
 
const app = express();
app.use(express.json());
app.use(cors());
 
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
const PORT = process.env.PORT || 3001;
 
// In-memory store (replace with a real database in production)
const users = new Map();
 
// Health check
app.get("/health", (req, res) => {
  res.json({ status: "healthy", service: "user-service" });
});
 
// Register
app.post("/users/register", async (req, res) => {
  try {
    const { email, password, name } = req.body;
 
    if (!email || !password || !name) {
      return res.status(400).json({ error: "All fields are required" });
    }
 
    // Check if user exists
    const existingUser = [...users.values()].find((u) => u.email === email);
    if (existingUser) {
      return res.status(409).json({ error: "Email already registered" });
    }
 
    const hashedPassword = await bcrypt.hash(password, 10);
    const user = {
      id: uuidv4(),
      email,
      name,
      password: hashedPassword,
      createdAt: new Date().toISOString(),
    };
 
    users.set(user.id, user);
 
    const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
      expiresIn: "24h",
    });
 
    res.status(201).json({
      success: true,
      data: {
        user: { id: user.id, email: user.email, name: user.name },
        token,
      },
    });
  } catch (error) {
    res.status(500).json({ error: "Registration failed" });
  }
});
 
// Login
app.post("/users/login", async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = [...users.values()].find((u) => u.email === email);
 
    if (!user || !(await bcrypt.compare(password, user.password))) {
      return res.status(401).json({ error: "Invalid credentials" });
    }
 
    const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, {
      expiresIn: "24h",
    });
 
    res.json({
      success: true,
      data: {
        user: { id: user.id, email: user.email, name: user.name },
        token,
      },
    });
  } catch (error) {
    res.status(500).json({ error: "Login failed" });
  }
});
 
// Validate token (used internally by API Gateway)
app.get("/users/validate", (req, res) => {
  try {
    const token = req.headers.authorization?.split(" ")[1];
    if (!token) return res.status(401).json({ error: "No token provided" });
 
    const decoded = jwt.verify(token, JWT_SECRET);
    const user = users.get(decoded.userId);
 
    if (!user) return res.status(404).json({ error: "User not found" });
 
    res.json({
      success: true,
      data: { id: user.id, email: user.email, name: user.name },
    });
  } catch (error) {
    res.status(401).json({ error: "Invalid token" });
  }
});
 
// Get user profile
app.get("/users/:id", (req, res) => {
  const user = users.get(req.params.id);
  if (!user) return res.status(404).json({ error: "User not found" });
 
  res.json({
    success: true,
    data: { id: user.id, email: user.email, name: user.name },
  });
});
 
app.listen(PORT, () => {
  console.log(`User Service running on port ${PORT}`);
});

Step 4: Product Service

The Product Service manages the product catalog and publishes inventory events to RabbitMQ.

Install Dependencies

cd product-service
npm install express uuid cors amqplib
cd ..

Product Service Implementation

// product-service/src/index.js
const express = require("express");
const { v4: uuidv4 } = require("uuid");
const cors = require("cors");
const { RabbitMQClient } = require("../../shared/src/rabbitmq");
 
const app = express();
app.use(express.json());
app.use(cors());
 
const PORT = process.env.PORT || 3002;
const rabbit = new RabbitMQClient();
 
// In-memory product store
const products = new Map();
 
// Seed initial products
function seedProducts() {
  const initial = [
    { name: "Wireless Keyboard", price: 49.99, stock: 100, category: "electronics" },
    { name: "USB-C Hub", price: 29.99, stock: 200, category: "electronics" },
    { name: "Standing Desk Mat", price: 39.99, stock: 50, category: "office" },
    { name: "Noise-Cancelling Headphones", price: 199.99, stock: 75, category: "electronics" },
    { name: "Ergonomic Mouse", price: 59.99, stock: 150, category: "electronics" },
  ];
 
  initial.forEach((p) => {
    const product = { ...p, id: uuidv4(), createdAt: new Date().toISOString() };
    products.set(product.id, product);
  });
}
 
// Health check
app.get("/health", (req, res) => {
  res.json({ status: "healthy", service: "product-service" });
});
 
// List all products
app.get("/products", (req, res) => {
  const { category, minPrice, maxPrice } = req.query;
  let result = [...products.values()];
 
  if (category) result = result.filter((p) => p.category === category);
  if (minPrice) result = result.filter((p) => p.price >= parseFloat(minPrice));
  if (maxPrice) result = result.filter((p) => p.price <= parseFloat(maxPrice));
 
  res.json({ success: true, data: result, total: result.length });
});
 
// Get single product
app.get("/products/:id", (req, res) => {
  const product = products.get(req.params.id);
  if (!product) return res.status(404).json({ error: "Product not found" });
  res.json({ success: true, data: product });
});
 
// Create product
app.post("/products", (req, res) => {
  const { name, price, stock, category } = req.body;
 
  if (!name || !price || stock === undefined || !category) {
    return res.status(400).json({ error: "All fields are required" });
  }
 
  const product = {
    id: uuidv4(),
    name,
    price: parseFloat(price),
    stock: parseInt(stock),
    category,
    createdAt: new Date().toISOString(),
  };
 
  products.set(product.id, product);
  res.status(201).json({ success: true, data: product });
});
 
// Update stock (called when order is confirmed)
app.patch("/products/:id/stock", async (req, res) => {
  const product = products.get(req.params.id);
  if (!product) return res.status(404).json({ error: "Product not found" });
 
  const { quantity } = req.body;
  product.stock += quantity; // negative to decrease
 
  // Publish stock update event
  await rabbit.publishToQueue("stock_updated", {
    productId: product.id,
    newStock: product.stock,
    change: quantity,
    timestamp: new Date().toISOString(),
  });
 
  res.json({ success: true, data: product });
});
 
// Check stock availability (internal endpoint)
app.post("/products/check-stock", (req, res) => {
  const { items } = req.body; // [{ productId, quantity }]
  const results = items.map((item) => {
    const product = products.get(item.productId);
    return {
      productId: item.productId,
      requested: item.quantity,
      available: product ? product.stock : 0,
      sufficient: product ? product.stock >= item.quantity : false,
    };
  });
 
  const allAvailable = results.every((r) => r.sufficient);
  res.json({ success: true, data: { allAvailable, items: results } });
});
 
async function start() {
  seedProducts();
  await rabbit.connect(process.env.RABBITMQ_URL);
  app.listen(PORT, () => {
    console.log(`Product Service running on port ${PORT}`);
  });
}
 
start().catch(console.error);

Step 5: Order Service with Async Messaging

The Order Service is the most interesting — it creates orders and uses RabbitMQ to asynchronously validate inventory and process orders.

Install Dependencies

cd order-service
npm install express uuid cors amqplib node-fetch
cd ..

Order Service Implementation

// order-service/src/index.js
const express = require("express");
const { v4: uuidv4 } = require("uuid");
const cors = require("cors");
const { RabbitMQClient } = require("../../shared/src/rabbitmq");
 
const app = express();
app.use(express.json());
app.use(cors());
 
const PORT = process.env.PORT || 3003;
const PRODUCT_SERVICE_URL =
  process.env.PRODUCT_SERVICE_URL || "http://localhost:3002";
const rabbit = new RabbitMQClient();
 
// In-memory order store
const orders = new Map();
 
// Health check
app.get("/health", (req, res) => {
  res.json({ status: "healthy", service: "order-service" });
});
 
// Create order
app.post("/orders", async (req, res) => {
  try {
    const { userId, items } = req.body;
    // items: [{ productId, quantity, price }]
 
    if (!userId || !items || items.length === 0) {
      return res.status(400).json({ error: "userId and items are required" });
    }
 
    const order = {
      id: uuidv4(),
      userId,
      items,
      total: items.reduce((sum, item) => sum + item.price * item.quantity, 0),
      status: "pending",
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };
 
    orders.set(order.id, order);
 
    // Publish order to RabbitMQ for async processing
    await rabbit.publishToQueue("order_created", {
      orderId: order.id,
      userId: order.userId,
      items: order.items,
      total: order.total,
    });
 
    console.log(`Order ${order.id} created and sent to queue`);
 
    res.status(201).json({
      success: true,
      data: order,
      message: "Order created. Processing inventory validation...",
    });
  } catch (error) {
    console.error("Order creation failed:", error);
    res.status(500).json({ error: "Order creation failed" });
  }
});
 
// Get order by ID
app.get("/orders/:id", (req, res) => {
  const order = orders.get(req.params.id);
  if (!order) return res.status(404).json({ error: "Order not found" });
  res.json({ success: true, data: order });
});
 
// Get orders by user
app.get("/orders/user/:userId", (req, res) => {
  const userOrders = [...orders.values()].filter(
    (o) => o.userId === req.params.userId
  );
  res.json({ success: true, data: userOrders, total: userOrders.length });
});
 
// Process order queue - validate stock and confirm/reject
async function processOrderQueue() {
  await rabbit.consumeFromQueue("order_created", async (orderData) => {
    console.log(`Processing order: ${orderData.orderId}`);
    const order = orders.get(orderData.orderId);
    if (!order) return;
 
    try {
      // Check stock with Product Service
      const response = await fetch(`${PRODUCT_SERVICE_URL}/products/check-stock`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: orderData.items }),
      });
 
      const stockCheck = await response.json();
 
      if (stockCheck.data.allAvailable) {
        // Deduct stock for each item
        for (const item of orderData.items) {
          await fetch(
            `${PRODUCT_SERVICE_URL}/products/${item.productId}/stock`,
            {
              method: "PATCH",
              headers: { "Content-Type": "application/json" },
              body: JSON.stringify({ quantity: -item.quantity }),
            }
          );
        }
 
        order.status = "confirmed";
        order.updatedAt = new Date().toISOString();
 
        // Publish confirmation event
        await rabbit.publishToQueue("order_confirmed", {
          orderId: order.id,
          userId: order.userId,
          total: order.total,
        });
 
        console.log(`Order ${order.id} confirmed`);
      } else {
        order.status = "rejected";
        order.reason = "Insufficient stock";
        order.updatedAt = new Date().toISOString();
 
        await rabbit.publishToQueue("order_rejected", {
          orderId: order.id,
          userId: order.userId,
          reason: "Insufficient stock",
          details: stockCheck.data.items,
        });
 
        console.log(`Order ${order.id} rejected - insufficient stock`);
      }
    } catch (error) {
      console.error(`Failed to process order ${orderData.orderId}:`, error);
      order.status = "failed";
      order.reason = "Processing error";
      order.updatedAt = new Date().toISOString();
    }
  });
}
 
async function start() {
  await rabbit.connect(process.env.RABBITMQ_URL);
  await processOrderQueue();
  app.listen(PORT, () => {
    console.log(`Order Service running on port ${PORT}`);
  });
}
 
start().catch(console.error);

In production, you would use a real database (PostgreSQL, MongoDB) instead of in-memory Maps. The in-memory approach is used here to focus on the microservices architecture patterns without database setup overhead.


Step 6: API Gateway

The API Gateway is the single entry point for all client requests. It routes traffic, validates JWT tokens, and applies rate limiting.

Install Dependencies

cd api-gateway
npm install express http-proxy-middleware cors express-rate-limit jsonwebtoken
cd ..

API Gateway Implementation

// api-gateway/src/index.js
const express = require("express");
const { createProxyMiddleware } = require("http-proxy-middleware");
const cors = require("cors");
const rateLimit = require("express-rate-limit");
const jwt = require("jsonwebtoken");
 
const app = express();
app.use(cors());
app.use(express.json());
 
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
 
// Service URLs
const SERVICES = {
  users: process.env.USER_SERVICE_URL || "http://localhost:3001",
  products: process.env.PRODUCT_SERVICE_URL || "http://localhost:3002",
  orders: process.env.ORDER_SERVICE_URL || "http://localhost:3003",
};
 
// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  message: { error: "Too many requests, please try again later" },
});
 
app.use(limiter);
 
// JWT Authentication middleware
function authenticate(req, res, next) {
  const token = req.headers.authorization?.split(" ")[1];
 
  if (!token) {
    return res.status(401).json({ error: "Authentication required" });
  }
 
  try {
    const decoded = jwt.verify(token, JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(401).json({ error: "Invalid or expired token" });
  }
}
 
// Request logging middleware
app.use((req, res, next) => {
  const start = Date.now();
  res.on("finish", () => {
    const duration = Date.now() - start;
    console.log(
      `${req.method} ${req.originalUrl} -> ${res.statusCode} (${duration}ms)`
    );
  });
  next();
});
 
// Health check for all services
app.get("/health", async (req, res) => {
  const checks = {};
 
  for (const [name, url] of Object.entries(SERVICES)) {
    try {
      const response = await fetch(`${url}/health`);
      const data = await response.json();
      checks[name] = { status: "up", ...data };
    } catch {
      checks[name] = { status: "down" };
    }
  }
 
  const allUp = Object.values(checks).every((c) => c.status === "up");
  res.status(allUp ? 200 : 503).json({
    gateway: "healthy",
    services: checks,
    timestamp: new Date().toISOString(),
  });
});
 
// Public routes (no auth required)
app.use(
  "/api/users/register",
  createProxyMiddleware({
    target: SERVICES.users,
    changeOrigin: true,
    pathRewrite: { "^/api/users/register": "/users/register" },
  })
);
 
app.use(
  "/api/users/login",
  createProxyMiddleware({
    target: SERVICES.users,
    changeOrigin: true,
    pathRewrite: { "^/api/users/login": "/users/login" },
  })
);
 
app.use(
  "/api/products",
  createProxyMiddleware({
    target: SERVICES.products,
    changeOrigin: true,
    pathRewrite: { "^/api/products": "/products" },
  })
);
 
// Protected routes (auth required)
app.use(
  "/api/orders",
  authenticate,
  createProxyMiddleware({
    target: SERVICES.orders,
    changeOrigin: true,
    pathRewrite: { "^/api/orders": "/orders" },
    onProxyReq(proxyReq, req) {
      // Forward user info to downstream service
      proxyReq.setHeader("X-User-Id", req.user.userId);
      proxyReq.setHeader("X-User-Email", req.user.email);
    },
  })
);
 
app.use(
  "/api/users",
  authenticate,
  createProxyMiddleware({
    target: SERVICES.users,
    changeOrigin: true,
    pathRewrite: { "^/api/users": "/users" },
  })
);
 
// 404 handler
app.use((req, res) => {
  res.status(404).json({ error: "Route not found" });
});
 
// Error handler
app.use((err, req, res, next) => {
  console.error("Gateway error:", err.message);
  res.status(500).json({ error: "Internal gateway error" });
});
 
app.listen(PORT, () => {
  console.log(`API Gateway running on port ${PORT}`);
  console.log("Service routes:");
  Object.entries(SERVICES).forEach(([name, url]) => {
    console.log(`  /api/${name} -> ${url}`);
  });
});

Step 7: Dockerize Everything

Service Dockerfile

Create the same Dockerfile for each service (api-gateway, user-service, product-service, order-service):

# api-gateway/Dockerfile (same pattern for all services)
FROM node:20-alpine
 
WORKDIR /app
 
# Copy shared module
COPY shared/ ./shared/
 
# Copy service files
COPY api-gateway/package*.json ./
RUN npm install --production
 
COPY api-gateway/src/ ./src/
 
EXPOSE 3000
 
CMD ["node", "src/index.js"]

Adjust the paths for each service:

# user-service/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY shared/ ./shared/
COPY user-service/package*.json ./
RUN npm install --production
COPY user-service/src/ ./src/
EXPOSE 3001
CMD ["node", "src/index.js"]
# product-service/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY shared/ ./shared/
COPY product-service/package*.json ./
RUN npm install --production
COPY product-service/src/ ./src/
EXPOSE 3002
CMD ["node", "src/index.js"]
# order-service/Dockerfile
FROM node:20-alpine
WORKDIR /app
COPY shared/ ./shared/
COPY order-service/package*.json ./
RUN npm install --production
COPY order-service/src/ ./src/
EXPOSE 3003
CMD ["node", "src/index.js"]

Docker Compose Configuration

# docker-compose.yml
version: "3.8"
 
services:
  rabbitmq:
    image: rabbitmq:3-management-alpine
    ports:
      - "5672:5672"
      - "15672:15672"  # Management UI
    environment:
      RABBITMQ_DEFAULT_USER: admin
      RABBITMQ_DEFAULT_PASS: rabbitmq123
    volumes:
      - rabbitmq_data:/var/lib/rabbitmq
    healthcheck:
      test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  user-service:
    build:
      context: .
      dockerfile: user-service/Dockerfile
    ports:
      - "3001:3001"
    environment:
      PORT: 3001
      JWT_SECRET: my-super-secret-jwt-key-change-in-production
    restart: unless-stopped
 
  product-service:
    build:
      context: .
      dockerfile: product-service/Dockerfile
    ports:
      - "3002:3002"
    environment:
      PORT: 3002
      RABBITMQ_URL: amqp://admin:rabbitmq123@rabbitmq:5672
    depends_on:
      rabbitmq:
        condition: service_healthy
    restart: unless-stopped
 
  order-service:
    build:
      context: .
      dockerfile: order-service/Dockerfile
    ports:
      - "3003:3003"
    environment:
      PORT: 3003
      RABBITMQ_URL: amqp://admin:rabbitmq123@rabbitmq:5672
      PRODUCT_SERVICE_URL: http://product-service:3002
    depends_on:
      rabbitmq:
        condition: service_healthy
      product-service:
        condition: service_started
    restart: unless-stopped
 
  api-gateway:
    build:
      context: .
      dockerfile: api-gateway/Dockerfile
    ports:
      - "3000:3000"
    environment:
      PORT: 3000
      JWT_SECRET: my-super-secret-jwt-key-change-in-production
      USER_SERVICE_URL: http://user-service:3001
      PRODUCT_SERVICE_URL: http://product-service:3002
      ORDER_SERVICE_URL: http://order-service:3003
    depends_on:
      - user-service
      - product-service
      - order-service
    restart: unless-stopped
 
volumes:
  rabbitmq_data:

Step 8: Launch and Test

Start All Services

# Build and start everything
docker compose up --build -d
 
# Check all services are running
docker compose ps
 
# View logs
docker compose logs -f

You should see all five containers running (RabbitMQ + 4 services).

Test the API Gateway

Open a new terminal and test the complete flow:

# 1. Check gateway health
curl http://localhost:3000/health | jq
 
# 2. Register a user
curl -X POST http://localhost:3000/api/users/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "test@example.com",
    "password": "securePassword123",
    "name": "Test User"
  }' | jq

Save the token from the response, then continue:

# 3. Browse products (public - no auth needed)
curl http://localhost:3000/api/products | jq
 
# 4. Create an order (requires auth)
TOKEN="your-jwt-token-here"
 
curl -X POST http://localhost:3000/api/orders \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $TOKEN" \
  -d '{
    "userId": "your-user-id",
    "items": [
      {
        "productId": "product-id-from-step-3",
        "quantity": 2,
        "price": 49.99
      }
    ]
  }' | jq
 
# 5. Check order status (it should change from "pending" to "confirmed")
curl http://localhost:3000/api/orders/ORDER_ID \
  -H "Authorization: Bearer $TOKEN" | jq

Monitor RabbitMQ

Open the RabbitMQ management dashboard at http://localhost:15672 (login: admin / rabbitmq123). You will see:

  • Queues: order_created, order_confirmed, order_rejected, stock_updated
  • Message rates: Messages flowing between services
  • Connections: One connection per service

Step 9: Add Service Resilience

Circuit Breaker Pattern

Add a simple circuit breaker to protect services from cascading failures:

// shared/src/circuit-breaker.js
class CircuitBreaker {
  constructor(options = {}) {
    this.failureThreshold = options.failureThreshold || 5;
    this.resetTimeout = options.resetTimeout || 30000;
    this.state = "CLOSED"; // CLOSED, OPEN, HALF_OPEN
    this.failures = 0;
    this.lastFailureTime = null;
  }
 
  async execute(fn) {
    if (this.state === "OPEN") {
      if (Date.now() - this.lastFailureTime > this.resetTimeout) {
        this.state = "HALF_OPEN";
      } else {
        throw new Error("Circuit breaker is OPEN - service unavailable");
      }
    }
 
    try {
      const result = await fn();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }
 
  onSuccess() {
    this.failures = 0;
    this.state = "CLOSED";
  }
 
  onFailure() {
    this.failures++;
    this.lastFailureTime = Date.now();
    if (this.failures >= this.failureThreshold) {
      this.state = "OPEN";
      console.warn("Circuit breaker OPENED - too many failures");
    }
  }
 
  getState() {
    return {
      state: this.state,
      failures: this.failures,
      threshold: this.failureThreshold,
    };
  }
}
 
module.exports = { CircuitBreaker };

Use it in the Order Service when calling the Product Service:

const { CircuitBreaker } = require("../../shared/src/circuit-breaker");
const productServiceBreaker = new CircuitBreaker({
  failureThreshold: 3,
  resetTimeout: 15000,
});
 
// In the order processing function:
const stockCheck = await productServiceBreaker.execute(async () => {
  const response = await fetch(
    `${PRODUCT_SERVICE_URL}/products/check-stock`,
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ items: orderData.items }),
    }
  );
  if (!response.ok) throw new Error(`Stock check failed: ${response.status}`);
  return response.json();
});

Step 10: Add Structured Logging

Consistent logging across services makes debugging much easier:

// shared/src/logger.js
function createLogger(serviceName) {
  function formatLog(level, message, meta = {}) {
    return JSON.stringify({
      timestamp: new Date().toISOString(),
      service: serviceName,
      level,
      message,
      ...meta,
    });
  }
 
  return {
    info: (message, meta) => console.log(formatLog("info", message, meta)),
    warn: (message, meta) => console.warn(formatLog("warn", message, meta)),
    error: (message, meta) => console.error(formatLog("error", message, meta)),
    debug: (message, meta) => console.debug(formatLog("debug", message, meta)),
  };
}
 
module.exports = { createLogger };

Usage in any service:

const { createLogger } = require("../../shared/src/logger");
const logger = createLogger("order-service");
 
logger.info("Order created", { orderId: order.id, userId: order.userId });
logger.error("Failed to process order", { orderId, error: error.message });

Troubleshooting

RabbitMQ Connection Refused

If services fail to connect to RabbitMQ:

# Check RabbitMQ is running
docker compose logs rabbitmq
 
# Restart RabbitMQ
docker compose restart rabbitmq

The shared RabbitMQ client includes retry logic (10 attempts with 3-second delays), which handles startup ordering.

Service Cannot Reach Another Service

Inside Docker Compose, services reach each other by service name (e.g., http://product-service:3002), not localhost. Verify your environment variables are set correctly.

Port Conflicts

If ports 3000-3003 or 5672/15672 are in use:

# Find what is using the port
lsof -i :3000
 
# Or change ports in docker-compose.yml
ports:
  - "4000:3000"  # Map to different host port

Next Steps

Now that you have a working microservices architecture, here are ways to extend it:

  • Add a real database: Replace in-memory Maps with PostgreSQL using Prisma or Drizzle ORM
  • Add a Notification Service: Consume order_confirmed events to send emails via Resend
  • Implement saga pattern: For multi-step transactions that need rollback
  • Add OpenTelemetry: Distributed tracing across services
  • Deploy to production: Use Kubernetes or Docker Swarm for orchestration
  • Add GraphQL Gateway: Replace REST proxy with a federated GraphQL layer

Conclusion

You have built a complete microservices architecture with:

  • Service decomposition — each domain (users, products, orders) has its own service
  • Async messaging — RabbitMQ decouples order processing from stock validation
  • API Gateway — single entry point with auth, rate limiting, and request routing
  • Circuit breaker — resilience against cascading failures
  • Containerization — Docker Compose for reproducible deployments
  • Structured logging — consistent JSON logs across all services

This architecture scales horizontally: you can run multiple instances of any service behind a load balancer, and RabbitMQ distributes messages across consumers automatically. The patterns demonstrated here — service decomposition, async messaging, API gateway, circuit breaker — are the same patterns used in production by companies processing millions of requests daily.


Want to read more tutorials? Check out our latest tutorial on 6 Laravel 11 Basics: Requests.

Discuss Your Project with Us

We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.

Let's find the best solutions for your needs.

Related Articles

Building REST APIs with Go and Fiber: A Practical Beginner's Guide

Learn how to build fast, production-ready REST APIs using Go and the Fiber web framework. This step-by-step guide covers project setup, routing, JSON handling, database integration with GORM, middleware, error handling, and testing — from zero to a working API.

30 min read·