Skip to content
Express.js projects 4 min read

Project: Microservices System

Monoliths are simple to start with, but as a product grows, teams want to deploy, scale, and reason about pieces independently. In this project you will build a small but realistic microservices system in Express: an API gateway that fronts the public, a user service and an order service that own their own data, synchronous HTTP calls between them, and asynchronous events over a message broker (RabbitMQ). The whole thing runs locally with a single docker compose up.

Architecture overview

The gateway is the only service exposed to the outside world. It routes incoming requests to the right internal service, so clients never need to know how many services exist or where they live. Services talk to each other in two ways: synchronously over HTTP when the caller needs an immediate answer, and asynchronously over a broker when the caller just wants to announce that something happened.

ServicePortResponsibilityTalks to
Gateway8080Public entry, routing, authuser, order (HTTP)
User3001User accountsbroker (publishes)
Order3002Ordersuser (HTTP), broker (subscribes)
RabbitMQ5672 / 15672Message broker + UI

The order service calls the user service over HTTP to validate a customer before creating an order. When an order is placed, it publishes an order.created event; the user service subscribes so it can update aggregates without blocking the request.

The user service

Each service is its own Express app with its own dependencies. The user service exposes a lookup endpoint and publishes a welcome event on registration.

// user-service/index.js
import express from "express";
import { connectBroker, publish } from "./broker.js";

const app = express();
app.use(express.json());

const users = new Map([["u1", { id: "u1", name: "Ada", email: "[email protected]" }]]);

app.get("/users/:id", async (req, res) => {
  const user = users.get(req.params.id);
  if (!user) return res.status(404).json({ error: "user not found" });
  res.json(user);
});

app.post("/users", async (req, res) => {
  const id = `u${users.size + 1}`;
  const user = { id, ...req.body };
  users.set(id, user);
  await publish("user.created", user);
  res.status(201).json(user);
});

await connectBroker();
app.listen(3001, () => console.log("user-service on :3001"));

Messaging with RabbitMQ

The broker decouples producers from consumers. A small helper wraps amqplib so any service can publish or subscribe to a named topic on a shared exchange.

// shared/broker.js
import amqp from "amqplib";

let channel;
const EXCHANGE = "events";

export async function connectBroker() {
  const conn = await amqp.connect(process.env.AMQP_URL ?? "amqp://rabbitmq");
  channel = await conn.createChannel();
  await channel.assertExchange(EXCHANGE, "topic", { durable: true });
}

export async function publish(routingKey, payload) {
  channel.publish(EXCHANGE, routingKey, Buffer.from(JSON.stringify(payload)), {
    persistent: true,
  });
}

export async function subscribe(routingKey, handler) {
  const q = await channel.assertQueue("", { exclusive: true });
  await channel.bindQueue(q.queue, EXCHANGE, routingKey);
  channel.consume(q.queue, (msg) => {
    if (!msg) return;
    handler(JSON.parse(msg.content.toString()));
    channel.ack(msg);
  });
}

Always assertExchange/assertQueue on startup and use durable/persistent so messages survive a broker restart. Acknowledge (channel.ack) only after you have processed a message, or you risk losing it on a crash.

The order service

The order service validates the customer synchronously, then emits an event. It uses the native fetch available in Node 18+.

// order-service/index.js
import express from "express";
import { connectBroker, publish, subscribe } from "./broker.js";

const app = express();
app.use(express.json());

const USER_URL = process.env.USER_URL ?? "http://user-service:3001";

app.post("/orders", async (req, res) => {
  const { userId, items } = req.body;
  const r = await fetch(`${USER_URL}/users/${userId}`);
  if (!r.ok) return res.status(400).json({ error: "invalid user" });

  const order = { id: crypto.randomUUID(), userId, items, status: "created" };
  await publish("order.created", order);
  res.status(201).json(order);
});

await connectBroker();
await subscribe("user.created", (u) => console.log("new user seen:", u.id));
app.listen(3002, () => console.log("order-service on :3002"));

The API gateway

The gateway proxies public paths to internal services. http-proxy-middleware keeps it declarative: each app.use mounts a proxy for one path prefix.

// gateway/index.js
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";

const app = express();

app.use("/api/users", createProxyMiddleware({
  target: "http://user-service:3001",
  changeOrigin: true,
  pathRewrite: { "^/api/users": "/users" },
}));

app.use("/api/orders", createProxyMiddleware({
  target: "http://order-service:3002",
  changeOrigin: true,
  pathRewrite: { "^/api/orders": "/orders" },
}));

app.listen(8080, () => console.log("gateway on :8080"));

A request and its response look like this:

curl -X POST http://localhost:8080/api/orders \
  -H "Content-Type: application/json" \
  -d '{"userId":"u1","items":["book"]}'

Output:

{
  "id": "b1f2c3d4-...",
  "userId": "u1",
  "items": ["book"],
  "status": "created"
}

Wiring it together with Docker Compose

Compose gives every service a DNS name (the service key), so http://user-service:3001 resolves automatically on the shared network.

# docker-compose.yml
services:
  rabbitmq:
    image: rabbitmq:3-management
    ports: ["15672:15672"]
  user-service:
    build: ./user-service
    environment: { AMQP_URL: amqp://rabbitmq }
    depends_on: [rabbitmq]
  order-service:
    build: ./order-service
    environment:
      AMQP_URL: amqp://rabbitmq
      USER_URL: http://user-service:3001
    depends_on: [rabbitmq, user-service]
  gateway:
    build: ./gateway
    ports: ["8080:8080"]
    depends_on: [user-service, order-service]
docker compose up --build

depends_on only waits for a container to start, not to be ready. For RabbitMQ, add a healthcheck or let services retry their broker connection on startup so they don’t crash during the race.

Best practices

  • Keep one database per service. Shared databases recreate the coupling microservices are meant to remove.
  • Use HTTP for queries that need an immediate answer; use events for “this happened” notifications that other services react to.
  • Make event consumers idempotent — brokers deliver at-least-once, so the same order.created may arrive twice.
  • Put cross-cutting concerns (auth, rate limiting, CORS) in the gateway so individual services stay focused on business logic.
  • Always set timeouts and retries on inter-service HTTP calls; a slow dependency should fail fast, not hang the caller.
  • Treat service URLs and broker credentials as configuration (env vars), never hard-code hostnames.
  • In Express 5.x, async route handlers that reject are forwarded to error middleware automatically — lean on a centralized error handler instead of wrapping every handler in try/catch.
Last updated June 14, 2026
Was this helpful?