๐งฉ Weekend Project: Build a Microservices-Based Application
This is it โ the final build of the bootcamp. All week you studied how large systems split into independent services; this weekend you construct one. You'll take a small e-commerce domain and break it into a handful of autonomous services, each owning its own data. In front of them you'll place an API gateway โ a single front door the outside world talks to. The gateway will call services with plain synchronous REST when it needs an answer right now, while the services quietly gossip about what happened through an asynchronous message broker when nobody needs to wait. You'll wrap every piece in its own container, wire the whole constellation together in one compose.yaml, and โ because networks fail and services restart โ make every inter-service call resilient with timeouts, retries, and a circuit breaker. When you finish, you won't just have written code; you'll have designed and operated a distributed system.
Week 14 · Weekend Project · Advanced Architecture Capstone (and the final lesson of the course)
๐ฏ Learning Objectives
By completing this project, you will be able to:
- Decompose a business domain into three or four services drawn around business capabilities, each with its own private database
- Stand up an API gateway as the single entry point that authenticates, routes, and aggregates requests to internal services
- Use synchronous REST for request/response calls and asynchronous events over a RabbitMQ broker for cross-service workflows that must not block
- Explain and apply event choreography โ services reacting to each other's published events instead of a central controller commanding them
- Add resilience to every inter-service call: request timeouts, bounded retries with backoff, and a circuit breaker that fails fast when a dependency is down
- Containerize each service with its own Dockerfile and orchestrate gateway, services, broker, and databases in a single
compose.yamlwith health checks
Estimated Time: 8โ12 hours across the weekend (it's the capstone โ pace yourself)
Project: A running e-commerce backend of independent, containerized services behind one gateway, communicating by REST and RabbitMQ events, that boots with a single docker compose up.
In This Project
The Goal
Build the backend of a small online store as a set of independent services rather than one big program. A Product service owns the catalog and inventory. An Order service turns a cart into a paid order. A User service owns accounts and authentication. A Notification service emails customers when things happen. Each runs in its own container, keeps its own database, and could โ in principle โ be written by a different team, deployed on its own schedule, and scaled on its own. In front of them sits an API gateway: the one address a client ever calls. The gateway checks who you are, forwards your request to the right internal service, and sometimes stitches several services' answers into one response.
The two communication styles are the heart of the exercise. When the gateway needs an answer right now โ "show me this product" โ it makes a synchronous REST call and waits. But when an order is placed, the Order service shouldn't sit and wait for an email to be sent, an invoice to be generated, and a warehouse to be pinged. Instead it publishes an event โ "an order was placed" โ to a message broker and moves on. Other services subscribe and react on their own time. That's the difference between a phone call and a group chat, and knowing which to reach for is what separates a distributed system that stays up from one that topples when any single piece hiccups.
๐ Why microservices โ and why not always
Splitting a system into services buys you independent deployability (ship the Product service without redeploying everything), fault isolation (a crashed Notification service doesn't take checkout down), and targeted scaling (run ten Order containers on Black Friday, one of everything else). The price is real: network calls fail, data is spread across databases, and "just add a JOIN" is no longer an option. A monolith is often the right first choice โ you build microservices when the team and the domain have grown enough that the coordination cost of one big codebase outweighs the operational cost of many small ones. This weekend you learn the pattern so that, when the day comes, you'll wield it deliberately rather than cargo-cult it.
Prerequisites
This capstone pulls together nearly everything the course taught โ Express APIs, databases, async JavaScript, Docker, and the architecture ideas from earlier this week. Before you start, make sure you're comfortable with:
- Building an Express REST API โ routes, controllers, JSON request/response, status codes (Weeks 7โ8)
- A database per service โ modeling and querying with something like Mongoose/MongoDB or an SQL client (Week 9)
- Async JavaScript โ
async/await, promises, and error handling around calls that can fail (Week 6) - Docker & Compose โ Dockerfiles, service-name networking, health-gated
depends_on, named volumes (Week 11) - JWT authentication โ issuing and verifying tokens, since the gateway will enforce auth (Week 8)
- This week's architecture lessons โ service boundaries, the API-gateway pattern, and sync-versus-async messaging
You'll need Docker Desktop (or Docker Engine + the Compose v2 plugin) and Node.js 20+ installed locally for iterating on individual services. Verify:
# Confirm your toolchain before you build
docker compose version # => Docker Compose version v2.x.x (a SPACE, not a hyphen)
node --version # => v20.x or newer
npm --version
โ ๏ธ Scope this weekend honestly
A production microservices platform involves service meshes, distributed tracing, centralized config, and more. That's a career, not a weekend. Your goal here is the essential skeleton: three or four real services, a gateway, one broker, resilient calls, and one-command startup. Everything beyond that lives in the Stretch Goals. Build the skeleton solidly before you reach for the extras.
Required Features Checklist
These are the non-negotiables that make this a genuine microservices application rather than a monolith in disguise. Tick each off as you go.
โ Must-have features
- โ Three or four services around business capabilities โ e.g. Product, Order, User (and Notification), each a separate Express app
- โ A database per service โ no shared database; each service owns and hides its own data
- โ An API gateway as the single entry point that authenticates and routes to internal services
- โ Synchronous REST between the gateway and services (and for direct service-to-service reads where needed)
- โ Asynchronous events via RabbitMQ for cross-service workflows โ publish on state change, subscribe to react
- โ Per-service Dockerfiles plus one
compose.yamlwiring gateway, services, broker, and databases - โ Health checks โ a
/healthroute on each service and healthcheck-gateddepends_onin Compose - โ Resilience on inter-service calls โ request timeouts, bounded retries with backoff, and a circuit breaker
- โ One-command boot โ
docker compose upbrings the entire system online
The Architecture
One door in (the gateway), several independent services behind it, each with a private database, and a broker off to the side carrying events between them. The client never talks to a service directly โ it only knows the gateway. The gateway makes synchronous REST calls to services and waits for answers. The services publish and consume asynchronous events through RabbitMQ without waiting on each other.
single entry point"] Gateway -->|"REST"| Product["Product Service"] Gateway -->|"REST"| Order["Order Service"] Gateway -->|"REST"| User["User Service"] Product --> ProductDB[("Product DB")] Order --> OrderDB[("Order DB")] User --> UserDB[("User DB")] Order -->|"publish event"| Broker{{"RabbitMQ Broker"}} Broker -->|"consume event"| Notification["Notification Service"] Notification --> NotificationDB[("Notification DB")]
Two rules give this diagram its shape. First, every service owns its data โ there is no shared database that two services both reach into. If the Order service needs a product's price, it asks the Product service; it never queries the Product database directly. That's what keeps services independently deployable: you can change how Product stores its data without breaking anyone else. Second, the gateway is the only public surface. Product, Order, User, Notification, and the broker publish no ports to the outside world; they're reachable only on the internal Compose network, addressed by service name.
Notice the difference in the arrows. The gateway-to-service arrows are labeled REST: a call that blocks until it gets a response. The Order-to-broker-to-Notification arrows are the event path: Order fires and forgets, Notification reacts whenever it's ready. Choosing correctly between these two is the single most important design decision in the whole project.
Monorepo Structure
Keep all the services in one repository (a monorepo) so a single docker compose up can start everything and you can read the whole system in one place. Each service is a self-contained folder with its own package.json, source, and Dockerfile. The gateway is just another folder. The databases and the broker need no folders โ you pull them ready-made from official images.
shop-microservices/
โโโ compose.yaml # wires gateway + services + broker + databases
โโโ .env.example # template of the vars Compose substitutes
โโโ .gitignore # ignores node_modules, .env
โโโ gateway/ # the single public entry point
โ โโโ Dockerfile
โ โโโ package.json
โ โโโ src/
โ โโโ index.js # routing, auth, aggregation
โ โโโ lib/
โ โโโ httpClient.js # resilient axios wrapper (timeout + retry)
โ โโโ circuitBreaker.js
โโโ services/
โ โโโ product/ # catalog + inventory (owns product-db)
โ โ โโโ Dockerfile
โ โ โโโ package.json
โ โ โโโ src/{ app.js, routes/, controllers/, models/, config/ }
โ โโโ order/ # cart to paid order (owns order-db, PUBLISHES events)
โ โ โโโ Dockerfile
โ โ โโโ src/{ app.js, ..., events/publisher.js }
โ โโโ user/ # accounts + auth (owns user-db)
โ โ โโโ Dockerfile
โ โ โโโ src/{ ... }
โ โโโ notification/ # emails on events (owns notification-db, CONSUMES events)
โ โโโ Dockerfile
โ โโโ src/{ app.js, ..., events/consumer.js }
โโโ README.md
๐ก One repo, many deployables
A monorepo isn't the opposite of microservices โ it's just where the code lives. Each service still builds into its own image, runs in its own container, and owns its own database; they merely share a repository and a Compose file for convenience. Big companies run enormous monorepos containing thousands of independently deployed services. For a learning project it's the sweet spot: everything is visible and one command starts it all.
Stage 1 โ Decompose the Domain
Before a line of code, decide where the seams go. The classic mistake is to split by technical layer โ a "database service," a "validation service" โ which just scatters one feature across many boxes. Instead, split by business capability: each service owns a whole vertical slice of the business, from its API down to its data. Ask, "what are the distinct things this business does?" The answers are your services.
| Service | Business capability | Owns (its private data) | Talks to |
|---|---|---|---|
| Product | Catalog & inventory | Products, stock levels | Nobody (leaf); answers REST reads |
| User | Accounts & auth | Users, credentials, addresses | Nobody (leaf); validates tokens |
| Order | Placing & paying for orders | Orders, line items, status | Product & User (REST); publishes events |
| Notification | Customer messaging | Sent notifications | Consumes events; reads User (REST) |
Two heuristics keep your boundaries honest. First, data ownership: a piece of data has exactly one service that can change it. Products belong to Product; nobody else writes to the product database. Second, change together, deploy together: things that always change for the same business reason belong in the same service. If updating the shipping rules always forces a change in two services, you probably drew the line in the wrong place.
โ ๏ธ The shared-database trap
The single biggest anti-pattern is letting two services read and write the same database tables. It feels efficient, but it secretly couples the services back together: now you can't change a schema without coordinating a joint deploy, and you've rebuilt a monolith with extra network hops. The rule is absolute โ each service owns its data and exposes it only through its API or its events. When Order needs a product's price, it calls Product's REST endpoint. It never touches product-db.
Stage 2 โ Build One Service
Every service follows the same skeleton โ an Express app with routes, controllers, a model, a database connection, and a /health route โ so once you've built one, the rest are variations on a theme. Here's the Product service as the template. Start with its entry point:
// services/product/src/app.js
import 'dotenv/config';
import express from 'express';
import mongoose from 'mongoose';
import productRoutes from './routes/product.routes.js';
const app = express();
app.use(express.json());
// Each service mounts its OWN routes under a clear prefix.
app.use('/products', productRoutes);
// Health route โ the container's healthcheck and Compose's depends_on rely on this.
app.get('/health', (req, res) => {
const dbUp = mongoose.connection.readyState === 1; // 1 === connected
res.status(dbUp ? 200 : 503).json({ service: 'product', status: dbUp ? 'ok' : 'degraded' });
});
const PORT = process.env.PORT || 3000;
async function start() {
// A service owns its OWN database โ note the distinct connection string.
await mongoose.connect(process.env.MONGO_URL);
app.listen(PORT, () => console.log(`product service listening on ${PORT}`));
}
start().catch((err) => {
console.error('product service failed to start:', err);
process.exit(1); // fail fast so the orchestrator can restart us
});
The model and controller are ordinary Express-and-Mongoose code โ the same patterns you built in Weeks 8 and 9. What matters for microservices is the seams: this service exposes a small, deliberate REST surface and nothing else. The checkStock endpoint below exists specifically so the Order service can ask "can I sell this?" without ever seeing the product database.
// services/product/src/controllers/product.controller.js
import Product from '../models/product.model.js';
export async function getProductById(req, res) {
const product = await Product.findById(req.params.id);
if (!product) return res.status(404).json({ message: 'Product not found' });
res.json(product);
}
// Called by the Order service over REST โ the ONLY way anyone learns our stock.
export async function checkStock(req, res) {
const { id, quantity } = req.query;
const product = await Product.findById(id);
if (!product) return res.status(404).json({ message: 'Product not found' });
const qty = Number(quantity) || 0;
res.json({
productId: id,
inStock: product.inventory >= qty,
availableQuantity: product.inventory,
});
}
// Reserve stock when an order is placed. Idempotent-friendly and guarded.
export async function reserveInventory(req, res) {
const { productId, quantity } = req.body;
const product = await Product.findById(productId);
if (!product) return res.status(404).json({ message: 'Product not found' });
if (product.inventory < quantity) {
return res.status(409).json({ message: 'Insufficient inventory' });
}
product.inventory -= quantity;
await product.save();
res.json({ productId, remaining: product.inventory });
}
๐ก The API is the contract โ the data is a secret
Everything another service is allowed to know about Product flows through these endpoints. That's liberating: as long as GET /products/:id and GET /products/check-stock keep their shape, you can rewrite the model, switch databases, or re-partition the collection and no other service notices. Treat your public routes as a promise you keep and your data store as an implementation detail you're free to change.
Stage 3 โ The API Gateway
The gateway is the system's front desk. Clients only ever talk to it; it decides who's allowed in, forwards each request to the right internal service, and โ when a screen needs data from several services โ aggregates their answers into one response. Centralizing these concerns means each service can stay small and trusting: it doesn't re-implement auth, and it doesn't need to be reachable from the public internet.
// gateway/src/index.js
import 'dotenv/config';
import express from 'express';
import jwt from 'jsonwebtoken';
import { get, post } from './lib/httpClient.js'; // resilient wrapper (Stage 5)
const app = express();
app.use(express.json());
// Internal services are addressed by SERVICE NAME on the Compose network.
const SERVICES = {
product: process.env.PRODUCT_URL || 'http://product:3000',
order: process.env.ORDER_URL || 'http://order:3000',
user: process.env.USER_URL || 'http://user:3000',
};
// --- Auth middleware: the gateway is the ONE place tokens are checked. ---
function authenticate(req, res, next) {
const header = req.headers.authorization || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ message: 'Missing token' });
try {
req.user = jwt.verify(token, process.env.JWT_SECRET);
next();
} catch {
return res.status(401).json({ message: 'Invalid token' });
}
}
// --- Public routes: no token needed. ---
app.post('/api/auth/register', (req, res) => forward(res, () => post(`${SERVICES.user}/users/register`, req.body)));
app.post('/api/auth/login', (req, res) => forward(res, () => post(`${SERVICES.user}/users/login`, req.body)));
app.get('/api/products', (req, res) => forward(res, () => get(`${SERVICES.product}/products`)));
app.get('/api/products/:id', (req, res) => forward(res, () => get(`${SERVICES.product}/products/${req.params.id}`)));
// --- Protected routes: token required. ---
app.post('/api/orders', authenticate, (req, res) =>
forward(res, () => post(`${SERVICES.order}/orders`, { ...req.body, userId: req.user.id }))
);
// --- Aggregation: one client call, several service calls stitched together. ---
app.get('/api/orders/:id/detail', authenticate, async (req, res) => {
try {
const order = await get(`${SERVICES.order}/orders/${req.params.id}`);
// Enrich each line item with fresh product data from the Product service.
const items = await Promise.all(
order.items.map(async (item) => ({
...item,
product: await get(`${SERVICES.product}/products/${item.productId}`),
}))
);
res.json({ ...order, items });
} catch (err) {
res.status(502).json({ message: 'Upstream service unavailable', detail: err.message });
}
});
// Small helper: run an upstream call and relay its result or error status.
async function forward(res, call) {
try {
res.json(await call());
} catch (err) {
res.status(err.status || 502).json({ message: err.message });
}
}
app.get('/health', (req, res) => res.json({ service: 'gateway', status: 'ok' }));
app.listen(process.env.PORT || 8080, () => console.log('gateway on 8080'));
๐ Three jobs a gateway does for you
1. Routing โ a single public URL space (/api/...) maps onto many internal services, so clients don't need to know your topology. 2. Cross-cutting concerns โ authentication, rate limiting, and logging live in one place instead of being copy-pasted into every service. 3. Aggregation โ the browser makes one request for an "order detail" screen and the gateway fans out to Order and Product on its behalf, sparing the client a chatty round-trip storm. The services behind it get to be simple and single-purpose.
Stage 4 โ Async Events with RabbitMQ
When an order is placed, several things should happen: the customer gets an email, inventory is adjusted, maybe an analytics counter ticks. If the Order service called each of those synchronously and waited, checkout would be as slow as the slowest downstream step โ and if the email server were down, the customer couldn't even place the order. That's backwards. The fix is to publish an event and let interested services react on their own time. This is event choreography: no central conductor commands the steps; each service knows which events it cares about and dances to them.
The Order service publishes to a RabbitMQ exchange the moment an order is saved:
// services/order/src/events/publisher.js
import amqp from 'amqplib';
const EXCHANGE = 'shop.events'; // a topic exchange all services share
let channel;
export async function initPublisher() {
const conn = await amqp.connect(process.env.RABBITMQ_URL);
channel = await conn.createChannel();
// A durable topic exchange survives broker restarts.
await channel.assertExchange(EXCHANGE, 'topic', { durable: true });
console.log('order publisher connected to RabbitMQ');
}
// Publish an event under a routing key like "order.created".
export function publish(routingKey, payload) {
const body = Buffer.from(JSON.stringify(payload));
// persistent: true asks the broker to keep the message on disk.
channel.publish(EXCHANGE, routingKey, body, { persistent: true });
console.log(`published ${routingKey}`, payload.orderId);
}
// services/order/src/controllers/order.controller.js (excerpt)
import Order from '../models/order.model.js';
import { publish } from '../events/publisher.js';
import { get, post } from '../lib/httpClient.js';
export async function createOrder(req, res) {
const { userId, items } = req.body;
// SYNCHRONOUS calls: we need these answers before we can accept the order.
for (const item of items) {
const stock = await get(
`${process.env.PRODUCT_URL}/products/check-stock?id=${item.productId}&quantity=${item.quantity}`
);
if (!stock.inStock) {
return res.status(409).json({ message: `Out of stock: ${item.productId}` });
}
}
const total = items.reduce((sum, i) => sum + i.price * i.quantity, 0);
const order = await Order.create({ userId, items, total, status: 'pending' });
// Reserve inventory synchronously (must succeed to hold the order).
for (const item of items) {
await post(`${process.env.PRODUCT_URL}/products/reserve`, {
productId: item.productId, quantity: item.quantity,
});
}
// ASYNCHRONOUS: fire the event and return immediately. Email, analytics,
// and anything else happen elsewhere, later, without blocking checkout.
publish('order.created', { orderId: order.id, userId, total });
res.status(201).json(order);
}
The Notification service never knows Order exists. It only knows it wants messages tagged order.*. It binds a queue to those routing keys and processes each message, acknowledging only after success so nothing is lost:
// services/notification/src/events/consumer.js
import amqp from 'amqplib';
import { sendOrderEmail } from '../services/email.service.js';
const EXCHANGE = 'shop.events';
const QUEUE = 'notification.order';
export async function startConsumer() {
const conn = await amqp.connect(process.env.RABBITMQ_URL);
const channel = await conn.createChannel();
await channel.assertExchange(EXCHANGE, 'topic', { durable: true });
// A durable queue that survives restarts; bind it to the events we want.
await channel.assertQueue(QUEUE, { durable: true });
await channel.bindQueue(QUEUE, EXCHANGE, 'order.created');
await channel.bindQueue(QUEUE, EXCHANGE, 'order.updated');
// Process one message at a time so a slow email cannot flood us.
channel.prefetch(1);
channel.consume(QUEUE, async (msg) => {
if (!msg) return;
try {
const event = JSON.parse(msg.content.toString());
await sendOrderEmail(event);
channel.ack(msg); // success: remove from the queue
} catch (err) {
console.error('failed to handle event:', err.message);
// requeue once; in production, route repeat failures to a dead-letter queue
channel.nack(msg, false, false);
}
});
console.log('notification consumer waiting for order events');
}
Here's the flow end to end. Watch how the customer gets their response the instant the order is saved โ the email happens afterward, off the critical path:
โ Why events make the system tougher
Decoupling: Order doesn't import, call, or even know about Notification โ add a new consumer (analytics, warehouse) later and Order never changes. Resilience: if Notification is down, messages wait durably in the queue and are delivered when it returns; checkout still works. Load smoothing: a burst of orders queues up and consumers drain it at a steady pace instead of everyone melting at once. The tradeoff is eventual consistency โ the email arrives a moment after the order, not in the same breath โ which is exactly the right bargain for work the customer isn't waiting on.
Stage 5 โ Resilience
In a monolith, calling another module is a function call: instant and infallible. In a distributed system, every arrow in your diagram is a network call that can be slow, fail, or hang forever. If the Product service stalls, a naive Order service waits with it โ and callers of Order pile up behind that, until the whole system is jammed by one sick component. This is a cascading failure, and three classic patterns prevent it: timeouts, retries with backoff, and the circuit breaker.
Timeouts and retries
A timeout caps how long you'll wait before giving up. Retries paper over transient blips (a dropped packet, a service mid-restart) โ but only a bounded number, with a growing backoff delay so you don't hammer a struggling service. Wrap your HTTP client once and every call inherits both:
// gateway/src/lib/httpClient.js (and copied into each service)
import axios from 'axios';
import { breaker } from './circuitBreaker.js';
const client = axios.create({ timeout: 3000 }); // give up after 3 seconds
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function request(config, { retries = 2, baseDelay = 200 } = {}) {
let lastErr;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
// The circuit breaker decides whether we're even allowed to try.
return await breaker(config.url, async () => {
const res = await client.request(config);
return res.data;
});
} catch (err) {
lastErr = normalize(err);
// Never retry a client error (4xx) โ retrying a 404 just wastes time.
if (lastErr.status && lastErr.status < 500) break;
if (attempt < retries) await sleep(baseDelay * 2 ** attempt); // 200ms, 400ms
}
}
throw lastErr;
}
function normalize(err) {
const e = new Error(err.response?.data?.message || err.message);
e.status = err.response?.status;
return e;
}
export const get = (url) => request({ method: 'get', url });
export const post = (url, data) => request({ method: 'post', url, data });
The circuit breaker
Retrying a service that's genuinely down is worse than useless โ you add load to something already on the floor and make every caller wait through the full timeout. A circuit breaker watches the failure rate and, once it crosses a threshold, trips open: for a cooldown period it rejects calls instantly without even trying, letting the sick service recover. After the cooldown it goes half-open, allows one trial call, and either closes again (recovered) or re-trips (still down).
// gateway/src/lib/circuitBreaker.js โ one breaker per target URL
const circuits = new Map();
function circuitFor(key) {
if (!circuits.has(key)) {
circuits.set(key, { state: 'closed', failures: 0, openedAt: 0 });
}
return circuits.get(key);
}
const THRESHOLD = 5; // trip after this many consecutive failures
const COOLDOWN = 10000; // stay open for 10 seconds before a trial call
export async function breaker(key, fn) {
const c = circuitFor(key);
if (c.state === 'open') {
if (Date.now() - c.openedAt < COOLDOWN) {
// Fail FAST โ do not touch the network while the circuit is open.
const e = new Error('circuit open: upstream unavailable');
e.status = 503;
throw e;
}
c.state = 'half-open'; // cooldown elapsed: allow ONE trial call
}
try {
const result = await fn();
c.failures = 0;
c.state = 'closed'; // success closes the circuit
return result;
} catch (err) {
c.failures++;
if (c.state === 'half-open' || c.failures >= THRESHOLD) {
c.state = 'open';
c.openedAt = Date.now();
}
throw err;
}
}
โ ๏ธ Make retries safe: idempotency
Retrying a GET is harmless โ reading twice returns the same thing. Retrying a POST that charges a card could bill the customer twice if the first call actually succeeded but the response got lost. Guard write operations with an idempotency key (a client-supplied unique id the service records, so a repeat with the same key is a no-op) before you let them retry. When in doubt, only auto-retry reads, and design writes to be safely repeatable. In real projects you'd reach for a battle-tested library like opossum for the breaker and axios-retry for retries โ building them by hand here is to understand what they do for you.
Stage 6 โ Containerize & Compose
Each service ships as its own image. Because every service is a plain Node app, they share one simple Dockerfile โ drop an identical copy into gateway/ and each services/* folder:
# services/product/Dockerfile (identical in every service and the gateway)
FROM node:20-alpine
WORKDIR /app
# Copy manifests first so the dependency layer is cached across code edits.
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
# Run as the built-in unprivileged user, never root.
USER node
EXPOSE 3000
CMD ["node", "src/app.js"]
Now the keystone: one compose.yaml that declares every service, every database, the broker, the private network they share, and the health checks that keep startup orderly. Only the gateway publishes a host port โ everything else is reachable solely by service name on the internal network.
# compose.yaml โ the whole system in one file. No obsolete "version:" key.
name: shop-microservices
services:
gateway:
build: ./gateway
ports:
- "8080:8080" # the ONLY public door
environment:
JWT_SECRET: ${JWT_SECRET}
PRODUCT_URL: http://product:3000
ORDER_URL: http://order:3000
USER_URL: http://user:3000
depends_on:
product: { condition: service_healthy }
order: { condition: service_healthy }
user: { condition: service_healthy }
restart: unless-stopped
product:
build: ./services/product
environment:
MONGO_URL: mongodb://product-db:27017/product
depends_on:
product-db: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
restart: unless-stopped
order:
build: ./services/order
environment:
MONGO_URL: mongodb://order-db:27017/order
PRODUCT_URL: http://product:3000
USER_URL: http://user:3000
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
order-db: { condition: service_healthy }
rabbitmq: { condition: service_healthy }
product: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 20s
restart: unless-stopped
user:
build: ./services/user
environment:
MONGO_URL: mongodb://user-db:27017/user
JWT_SECRET: ${JWT_SECRET}
depends_on:
user-db: { condition: service_healthy }
healthcheck:
test: ["CMD", "wget", "--quiet", "--spider", "http://localhost:3000/health"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
restart: unless-stopped
notification:
build: ./services/notification
environment:
MONGO_URL: mongodb://notification-db:27017/notification
USER_URL: http://user:3000
RABBITMQ_URL: amqp://rabbitmq:5672
depends_on:
notification-db: { condition: service_healthy }
rabbitmq: { condition: service_healthy }
restart: unless-stopped
# --- The message broker (official image with a built-in health check) ---
rabbitmq:
image: rabbitmq:3.13-management-alpine
ports:
- "15672:15672" # management UI at http://localhost:15672 (guest/guest)
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# --- One database PER service. They share an image, not data. ---
product-db:
image: mongo:7
volumes: [ "product-data:/data/db" ]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
order-db:
image: mongo:7
volumes: [ "order-data:/data/db" ]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
user-db:
image: mongo:7
volumes: [ "user-data:/data/db" ]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
notification-db:
image: mongo:7
volumes: [ "notification-data:/data/db" ]
healthcheck:
test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
# Each database gets its own named volume โ durable, private storage.
volumes:
product-data:
order-data:
user-data:
notification-data:
๐ก Health checks are what make startup deterministic
Distributed startup is a race: the Order service will crash if it tries to reach RabbitMQ or its database before they're ready. depends_on with condition: service_healthy turns the race into a chain โ Compose holds each service in the gate until the things it needs report healthy, not merely started. The start_period gives each service grace to boot before its own health check counts against it. The same /health route your Dockerfile probes is the one the gateway and other services can hit to check liveness.
Stage 7 โ Run & Verify
Create your .env from the template, then bring the whole system up with one command:
# .env (copied from .env.example, never committed)
JWT_SECRET: replace-with-a-long-random-string
# From the repo root:
docker compose up --build # build every image and start the system
docker compose up --build -d # ...or detached, then follow logs:
docker compose logs -f
Watch the boot order in the logs โ databases and RabbitMQ report healthy first, then the services, then the gateway. That ordering is your health gates doing their job. Confirm everything is up and healthy at a glance:
docker compose ps
# Every service should read "running", the health-checked ones "(healthy)".
Now exercise the system through the gateway โ the only door. A quick end-to-end walk:
// 1) Register a user (public route) โ returns a JWT
const reg = await fetch('http://localhost:8080/api/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'ada@example.com', password: 'hunter2', name: 'Ada' }),
}).then((r) => r.json());
// 2) Browse products (public route, gateway โ product service)
const products = await fetch('http://localhost:8080/api/products').then((r) => r.json());
// 3) Place an order (PROTECTED โ send the token; gateway verifies it)
const order = await fetch('http://localhost:8080/api/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${reg.token}`,
},
body: JSON.stringify({
items: [{ productId: products[0]._id, quantity: 1, price: products[0].price }],
}),
}).then((r) => r.json());
console.log('order placed:', order.id, order.status);
// Meanwhile, the notification service logs that it sent a confirmation email.
โ Prove the resilience for real
The point of Stage 5 is that failure stays contained. Test it: kill one service and watch the rest survive.
# Stop just the notification consumer
docker compose stop notification
# Place an order โ checkout STILL succeeds; the event waits in the RabbitMQ queue.
# Now bring it back:
docker compose start notification
# It drains the queued events and sends the delayed emails. Nothing was lost.
Then stop the Product service and place an order: after a few failed attempts the gateway's circuit breaker trips and you get an instant 503 instead of a hung request. Restart Product and, after the cooldown, orders flow again. You just watched a distributed system degrade gracefully instead of cascading into rubble.
Peek at the broker itself โ RabbitMQ's management UI shows your exchange, queues, and messages flowing:
# RabbitMQ management UI (default guest/guest on localhost):
# http://localhost:15672
# Watch the "shop.events" exchange and the "notification.order" queue.
Stretch Goals
Skeleton solid with time to spare? These are the moves that take a learning project toward a production platform. Pick whatever excites you โ none are needed to pass the rubric.
- ๐ A saga for a distributed transaction โ checkout spans Order, Product (reserve stock), and a Payment service. There's no cross-service database transaction, so implement a saga: a sequence of local steps, each with a compensating action that undoes it. If payment fails, publish
order.failedand let Product release the reserved stock. This is how real systems keep distributed data consistent. - ๐งญ Service discovery โ replace hardcoded
http://product:3000URLs with a registry (Consul, or Docker's built-in DNS plus a discovery layer) so services find each other dynamically as they scale up and down. - ๐ Centralized logging & tracing โ give every request a correlation id at the gateway and pass it through every call and event, so you can follow one user action across all services. Ship logs to a single place (an ELK stack) and add distributed tracing with OpenTelemetry to see the full call graph and where time goes.
- ๐ฆ Rate limiting & API versioning at the gateway โ protect services from abuse and evolve your API (
/api/v1,/api/v2) without breaking existing clients. - ๐ A dead-letter queue โ route events that fail repeatedly to a DLQ instead of requeuing forever, so one poison message can't block the whole consumer.
- โ๏ธ Deploy it โ push your images to a registry and run the stack on a cloud host, or translate the Compose file into Kubernetes manifests (Deployments, Services, and an Ingress in place of the gateway's public port).
Saga sketch โ the compensation idea
// Conceptual: a saga coordinates local steps + their undo actions.
async function checkoutSaga(order) {
const done = [];
try {
await reserveInventory(order); done.push('inventory');
await chargePayment(order); done.push('payment');
publish('order.confirmed', { id: order.id });
} catch (err) {
// Roll BACK in reverse by running each step's compensating action.
if (done.includes('payment')) await refundPayment(order);
if (done.includes('inventory')) await releaseInventory(order);
publish('order.failed', { id: order.id, reason: err.message });
}
}
Self-Check Rubric
Before you call this done, grade yourself. Aim to answer "yes" to everything in the first two columns โ the stretch column is bonus.
| Area | Meets expectations (required) | Exceeds (stretch) |
|---|---|---|
| Decomposition | 3โ4 services split by business capability; each is a separate Express app | Boundaries documented; clear reasoning for each seam |
| Data ownership | Every service has its own database; no shared tables; data reached only via API/events | Schema changes in one service provably affect no other |
| API gateway | Single public entry point that authenticates and routes; only the gateway publishes a host port | Aggregation endpoint; rate limiting; API versioning |
| Sync communication | Gateway-to-service (and needed service-to-service) calls use REST and handle non-200 responses | Idempotency keys guard retried writes |
| Async events | RabbitMQ exchange/queue; a producer publishes and a consumer reacts to a real workflow | Dead-letter queue; a saga with compensation |
| Resilience | Inter-service calls have timeouts, bounded retries with backoff, and a circuit breaker | Uses opossum/axios-retry; metrics on breaker state |
| Containerization | Per-service Dockerfile; one compose.yaml wires gateway, services, broker, DBs; /health everywhere |
Healthcheck-gated depends_on tuned; images run non-root |
| Operability | docker compose up boots the whole system in the right order |
Centralized logging/tracing with correlation ids; deployed to a host |
๐งช Final testing checklist
- โ
docker compose up --buildbrings gateway, services, broker, and databases online with one command - โ
docker compose psshows the health-checked services as healthy, in dependency order - โ Every request goes through the gateway; internal services publish no public ports
- โ Placing an order returns immediately and the Notification service logs a confirmation afterward
- โ Stopping the Notification service does not break checkout โ events queue and drain on restart
- โ Stopping the Product service makes the gateway's circuit breaker return a fast 503 instead of hanging
- โ Each service's data lives in its own database; nothing shares tables
Summary & Congratulations
๐ What You Built
- An e-commerce backend as independent services โ Product, Order, User, and Notification โ each a separate Express app that owns its own database
- An API gateway that is the system's single public door: it authenticates, routes to internal services, and aggregates their responses
- Synchronous REST for calls that need an answer now, and asynchronous RabbitMQ events for cross-service workflows that shouldn't block โ the difference between a phone call and a group chat
- Event choreography: services reacting to published events with no central controller, so new consumers slot in without touching the producer
- Resilient inter-service calls โ timeouts, bounded retries with backoff, and a circuit breaker that fails fast to stop one sick service from taking down the rest
- Per-service Dockerfiles and a single
compose.yamlwith health-gated startup that boots the entire distributed system with one command
Step back and see what you actually accomplished: you designed a distributed system, chose the right communication style for each interaction, made it survive partial failure, and operated it as one command. The specific services matter less than the shape โ a gateway in front, autonomous services owning their data, synchronous calls where answers are needed and asynchronous events where they aren't, and resilience wrapped around every network hop. That shape is the vocabulary of modern backend architecture, and you can now read it, draw it, and build it.
๐ Congratulations โ you've completed the Full-Stack JavaScript Bootcamp!
Take a breath and look at how far you've come. In Week 1 you wrote your very first <h1> tag and were amazed it showed up in a browser. You learned to style it with CSS, then to make it move with JavaScript โ variables, loops, functions, the DOM. You crossed to the server with Node and Express, learned to persist data in databases, and stitched a real front end to a real back end into working full-stack apps. You added authentication, wrote tests so you could change code without fear, and containerized your work so it runs the same everywhere. And here, in the final weekend, you built and operated a distributed system of cooperating services. From one HTML tag to deploying microservices โ that is an enormous arc, and you walked every step of it.
More important than any single technology is the thing you can't un-learn: how the pieces connect. You understand a request's whole journey now โ from a browser, through a gateway, across services, into a database, back again โ and you know where things can break and how to keep them from breaking. Frameworks will change; that understanding is durable.
Where to go next:
- Build a portfolio project โ take one idea you care about and build it end to end. A finished, deployed app you can demo is worth more than a dozen tutorials, and it's what employers actually want to see.
- Contribute to open source โ find a project whose tools you used this course (Express, a testing library, a component you like) and fix a small bug or improve the docs. You'll learn to read real codebases and collaborate the way professionals do.
- Keep learning deliberately โ pick the next layer that excites you: TypeScript for safer code at scale, a framework like Next.js, cloud deployment and CI/CD, or deeper into system design. Learn it by building, not just reading.
You started this course unable to make a web page. You're finishing it able to design, build, secure, test, containerize, and deploy a distributed application. You're a full-stack developer now. Go build something you're proud of โ and welcome to the craft. ๐
๐ Additional Resources
- Microservices.io โ the microservices pattern language (gateway, saga, and more)
- RabbitMQ โ official tutorials (topic exchanges, work queues, publish/subscribe)
- Martin Fowler โ Microservices, the foundational article
- Microsoft โ the Circuit Breaker pattern
- Docker โ the Compose file specification
- MDN โ HTTP, the protocol under every REST call you made