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.
| Service | Port | Responsibility | Talks to |
|---|---|---|---|
| Gateway | 8080 | Public entry, routing, auth | user, order (HTTP) |
| User | 3001 | User accounts | broker (publishes) |
| Order | 3002 | Orders | user (HTTP), broker (subscribes) |
| RabbitMQ | 5672 / 15672 | Message 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/assertQueueon startup and usedurable/persistentso 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_ononly 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.createdmay 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.