Node.js Advanced
Chapter 7 of 8 · Microservices Patterns
Microservices Patterns
A monolith puts everything in one process; microservices split it into independently deployable services. The split buys independent scaling and deployment, team autonomy, and fault isolation. It costs network latency, distributed transaction complexity, and operational overhead. This chapter isn't an argument for microservices — it's a toolkit for the patterns that make them survivable: how services talk to each other, how to prevent one slow service from cascading into a system-wide outage, how to decouple services through an event bus, and how to expose a coherent API surface to clients through a gateway.
Monolith vs Microservices
| Monolith | Microservices |
| Deployment |
One artifact, all or nothing |
Each service deployed independently |
| Scaling |
Scale everything together |
Scale only the bottleneck service |
| Failures |
One bug can crash everything |
Failures can be isolated to one service |
| Local dev |
Run one process |
Run N services (docker-compose helps) |
| Data |
Single database, ACID transactions |
Each service owns its data; distributed transactions are hard |
| Best for |
Early-stage products, small teams |
Large systems with clear domain boundaries and dedicated teams |
Service Communication — Sync vs Async
Synchronous (HTTP/REST, gRPC)
OrderService ──── POST /payments ────► PaymentService
◄─── 200 OK / error ─────
Caller waits for the response. Simple, familiar. But:
- If PaymentService is slow, OrderService is slow too
- If PaymentService is down, the request fails
- Tight temporal coupling
Asynchronous (Event bus / message queue)
OrderService ──── order.created event ────► [ Event Bus ] ──► PaymentService
└──► InventoryService
└──► NotificationService
Caller fires and continues. Loose coupling. But:
- Harder to get the result back to the caller
- Eventual consistency instead of immediate
- Debugging requires distributed tracing
HTTP Service-to-Service Calls
const PAYMENT_URL = process.env.PAYMENT_SERVICE_URL ?? 'http://payment-service:3001';
const SHIPPING_URL = process.env.SHIPPING_SERVICE_URL ?? 'http://shipping-service:3002';
class ServiceClient {
constructor(baseUrl, { timeoutMs = 5000, name = 'service' } = {}) {
this._base = baseUrl;
this._timeout = timeoutMs;
this._name = name;
}
async _fetch(path, init = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), this._timeout);
try {
const res = await fetch(`${this._base}${path}`, {
...init,
signal: controller.signal,
headers: { 'Content-Type': 'application/json', ...init.headers },
});
if (!res.ok) {
const body = await res.text().catch(() => '');
throw Object.assign(new Error(`${this._name} ${res.status}: ${body}`), { status: res.status });
}
return res.json();
} catch (err) {
if (err.name === 'AbortError') throw new Error(`${this._name} timed out after ${this._timeout}ms`);
throw err;
} finally {
clearTimeout(timer);
}
}
get(path) { return this._fetch(path); }
post(path, body) { return this._fetch(path, { method: 'POST', body: JSON.stringify(body) }); }
patch(path, body) { return this._fetch(path, { method: 'PATCH', body: JSON.stringify(body) }); }
}
const paymentClient = new ServiceClient(PAYMENT_URL, { name: 'PaymentService' });
const result = await paymentClient.post('/charges', { amount: 5000, currency: 'usd' });
Retry with Exponential Backoff and Jitter
async function withRetry(fn, {
maxAttempts = 4,
baseDelayMs = 200,
maxDelayMs = 10_000,
shouldRetry = (err) => !err.status || err.status >= 500,
} = {}) {
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return await fn();
} catch (err) {
lastErr = err;
if (attempt === maxAttempts || !shouldRetry(err)) throw err;
const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1));
const delay = Math.random() * cap;
console.warn(`Attempt ${attempt} failed; retrying in ${delay.toFixed(0)}ms`);
await new Promise(r => setTimeout(r, delay));
}
}
throw lastErr;
}
const charge = await withRetry(() => paymentClient.post('/charges', { amount: 5000 }));
Circuit Breaker — Stopping the Cascade
When a downstream service is failing, retrying just makes things worse — you're sending traffic to a service that can't handle it, which delays its recovery. A circuit breaker tracks the failure rate; when it exceeds a threshold it "opens" and subsequent calls fail immediately (fast-fail) without touching the downstream service. After a cooldown it enters half-open state and lets a test call through. If that succeeds, the circuit closes again.
CLOSED (normal) OPEN (fast-failing) HALF-OPEN (testing)
All calls pass through. │ Calls fail immediately │ One test call allowed.
Failure rate tracked. │ without hitting service. │
│ │
failure rate > threshold─► │ test passes ──► CLOSED
│ reset timeout expires ──►│
│ │ test fails ───► OPEN
const CircuitBreaker = require('opossum');
const breaker = new CircuitBreaker(
(body) => paymentClient.post('/charges', body),
{
timeout: 3000,
errorThresholdPercentage: 50,
resetTimeout: 10_000,
volumeThreshold: 5,
}
);
breaker.fallback((body, err) => ({
queued: true,
message: 'Payment service unavailable — order queued for retry',
}));
breaker.on('open', () => console.warn('Circuit OPENED — PaymentService failing'));
breaker.on('close', () => console.log('Circuit CLOSED — PaymentService recovered'));
breaker.on('halfOpen', () => console.log('Circuit HALF-OPEN — testing PaymentService'));
breaker.on('fallback', (result) => console.warn('Fallback returned:', result));
const result = await breaker.fire({ amount: 5000, currency: 'usd' });
console.log(breaker.stats());
Event Bus — Decoupled Async Communication
An event bus lets services publish domain events without knowing who is listening. Any number of subscribers can react independently. The publisher doesn't wait for them, and adding a new subscriber requires no changes to the publisher.
const Redis = require('ioredis');
class EventBus {
constructor(redisOptions) {
this._pub = new Redis(redisOptions);
this._sub = new Redis(redisOptions);
this._handlers = new Map();
this._sub.on('message', (channel, raw) => {
const handlers = this._handlers.get(channel);
if (!handlers) return;
let payload;
try { payload = JSON.parse(raw); } catch { return; }
for (const fn of handlers) {
fn(payload).catch(err => console.error(`[EventBus] ${channel} handler error:`, err));
}
});
}
async publish(event, payload) {
await this._pub.publish(event, JSON.stringify({
...payload,
_meta: { event, publishedAt: Date.now() },
}));
}
async subscribe(event, handler) {
if (!this._handlers.has(event)) {
this._handlers.set(event, new Set());
await this._sub.subscribe(event);
}
this._handlers.get(event).add(handler);
}
async unsubscribe(event, handler) {
this._handlers.get(event)?.delete(handler);
}
async close() {
await Promise.all([this._pub.quit(), this._sub.quit()]);
}
}
const bus = new EventBus({ host: 'localhost', port: 6379 });
await bus.publish('order.created', { orderId: 123, userId: 42, total: 9900 });
await bus.subscribe('order.created', async ({ orderId, total }) => {
await chargeCard(orderId, total);
});
await bus.subscribe('order.created', async ({ orderId, userId }) => {
await sendConfirmationEmail(userId, orderId);
});
Health Checks — Liveness and Readiness
const checks = {
async database() {
await db.query('SELECT 1');
},
async redis() {
await redis.ping();
},
};
app.get('/health/live', (_req, res) => {
res.json({ status: 'ok', uptime: process.uptime() });
});
app.get('/health/ready', async (_req, res) => {
const results = {};
let allPassed = true;
await Promise.allSettled(
Object.entries(checks).map(async ([name, fn]) => {
try {
await fn();
results[name] = 'ok';
} catch (err) {
results[name] = err.message;
allPassed = false;
}
})
);
res.status(allPassed ? 200 : 503).json({ status: allPassed ? 'ready' : 'not ready', checks: results });
});
API Gateway Pattern
An API gateway is the single entry point for all external traffic. It handles cross-cutting concerns — authentication, rate limiting, request routing, response aggregation — so individual services don't have to duplicate them.
External Clients
│
▼
┌────────────────────────┐
│ API Gateway │ ← auth, rate-limit, SSL termination, logging
└────────────────────────┘
│ │ │
▼ ▼ ▼
User Order Product
Service Service Service
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const app = express();
app.use(rateLimit({ windowMs: 60_000, max: 200 }));
app.use(async (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
req.user = await verifyJwt(token);
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});
const routes = {
'/api/users': process.env.USER_SERVICE_URL,
'/api/orders': process.env.ORDER_SERVICE_URL,
'/api/products': process.env.PRODUCT_SERVICE_URL,
};
for (const [prefix, target] of Object.entries(routes)) {
app.use(prefix, createProxyMiddleware({
target,
changeOrigin: true,
on: {
proxyReq: (proxyReq, req) => {
proxyReq.setHeader('X-User-Id', req.user.id);
proxyReq.setHeader('X-User-Role', req.user.role);
},
},
}));
}
app.listen(8080, () => console.log('Gateway on :8080'));
Correlation IDs
Attach a unique X-Correlation-Id header to every request at the gateway and forward it through every service call. When a request spans 5 services and one fails, you can filter all service logs by that ID and reconstruct the full trace. Generate with crypto.randomUUID() if the client doesn't provide one.
Saga Pattern
For distributed transactions (e.g. place-order spans inventory, payment, and shipping), a Saga sequences steps and defines compensating actions for rollback. If payment fails, the saga publishes a reservation.cancelled event to unblock the inventory. Each step is a separate event; there's no two-phase commit.
Strangler Fig
Incrementally extract services from a monolith. The gateway routes some paths to the monolith and others to the new service. The new service "strangles" the monolith route by route until eventually all traffic goes to services and the monolith can be retired.
Bulkhead Pattern
Isolate failures by giving each downstream dependency its own resource pool (separate HTTP agent with connection limits, separate thread pool). If PaymentService's pool exhausts, OrderService's connection to UserService is unaffected. Named after ship compartments that prevent one breach from sinking the whole hull.
⚠️ Distributed System Truths (Fallacies of Distributed Computing)
The network is not reliable. Every service call can fail or time out — always set a timeout and handle the error.
Latency is not zero. A service call to localhost is ~0.1ms; to another container on the same host ~1ms; across AZs ~5ms. A chain of 10 synchronous service calls accumulates that latency.
Bandwidth is not infinite. Sending large payloads between services is fine in a monolith (in-process); across the network it costs time and money. Keep inter-service payloads minimal.
The network topology can change. Services come and go. DNS names, not hardcoded IPs.
💡 Start with a Modular Monolith
Before reaching for microservices, build a modular monolith: a single process with clear internal module boundaries (each domain: Users, Orders, Products has its own folder, its own DB access layer, communicates with others only through well-defined interfaces). When one module genuinely needs independent scaling or its own deployment cadence, extract it into a service — the boundaries are already clean and the interface is already defined. Microservices first is almost always premature.
Coding Challenges
Challenge 1 — Circuit Breaker from Scratch
Implement a CircuitBreaker class (no opossum) with three states (CLOSED/OPEN/HALF_OPEN), configurable failureThreshold (number of failures to open), resetTimeoutMs (how long to wait before entering HALF_OPEN), and fallback function. The fire(fn) method calls fn when CLOSED or HALF_OPEN, and returns the fallback immediately when OPEN. Expose state, stats() (fires/successes/failures/fallbacks), and events ('open', 'close', 'halfOpen'). Write tests covering all state transitions.
View sample solution ↗
Challenge 2 — Retry with Backoff
Write withRetry(fn, options) that retries on failure with full-jitter exponential backoff, supports a shouldRetry(err) predicate to skip retries for 4xx errors, and collects a structured attempt log: [{ attempt, durationMs, error|null }] returned alongside the final result as { value, attempts }. Write tests that: verify the fetcher is called the correct number of times, verify 4xx errors are not retried, and verify the delay between attempts follows an exponential curve (mock setTimeout with jest.useFakeTimers()).
View sample solution ↗
Challenge 3 — Health Check Aggregator
Build createHealthRouter(checks, { timeoutMs }) that mounts GET /health/live (always 200 if the process is running) and GET /health/ready (runs all checks in parallel, 200 if all pass, 503 with per-check details if any fail). Each check has a name and an async function; failing checks must not block passing ones (use Promise.allSettled). Individual checks must time out after timeoutMs and be reported as failed with reason 'timeout'. Write tests for: all-pass (200), one-fail (503 with correct check name), and timeout (503 with reason 'timeout').
View sample solution ↗