Skip to main content

๐Ÿงฉ 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.yaml with 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.yaml wiring gateway, services, broker, and databases
  • โ˜ Health checks โ€” a /health route on each service and healthcheck-gated depends_on in Compose
  • โ˜ Resilience on inter-service calls โ€” request timeouts, bounded retries with backoff, and a circuit breaker
  • โ˜ One-command boot โ€” docker compose up brings 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.

flowchart TD Client["Web / Mobile Client"] --> Gateway["API Gateway
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.

Synchronous REST is a blocking phone call between two services; asynchronous messaging is a fire-and-forget note dropped into a broker that a consumer picks up later Synchronous REST Gateway asks, then WAITS Caller Service request response (blocks) Asynchronous Events Publish, then move ON Producer Broker Consumer no waiting either way
Reach for synchronous REST when the caller needs the answer to continue; reach for asynchronous events when the work can happen independently and later.

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.

ServiceBusiness capabilityOwns (its private data)Talks to
ProductCatalog & inventoryProducts, stock levelsNobody (leaf); answers REST reads
UserAccounts & authUsers, credentials, addressesNobody (leaf); validates tokens
OrderPlacing & paying for ordersOrders, line items, statusProduct & User (REST); publishes events
NotificationCustomer messagingSent notificationsConsumes 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:

sequenceDiagram participant Client participant Gateway participant Order participant Broker as RabbitMQ participant Notify as Notification Client->>Gateway: Place order Gateway->>Order: Forward create order request Order->>Order: Validate stock and save the order Order-->>Gateway: Return the created order Gateway-->>Client: Respond right away Order->>Broker: Publish an order created event Broker->>Notify: Deliver the order created event Notify->>Notify: Send the confirmation email later

โœ… 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;
  }
}
stateDiagram-v2 [*] --> Closed Closed --> Open: failures reach threshold Open --> HalfOpen: cooldown elapsed HalfOpen --> Closed: trial call succeeds HalfOpen --> Open: trial call fails

โš ๏ธ 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.failed and let Product release the reserved stock. This is how real systems keep distributed data consistent.
  • ๐Ÿงญ Service discovery โ€” replace hardcoded http://product:3000 URLs 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.

AreaMeets 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 --build brings gateway, services, broker, and databases online with one command
  • โ˜ docker compose ps shows 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.yaml with 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