Microservices Patterns

Node.js Advanced — Microservices Patterns

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

MonolithMicroservices
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

// Service base URLs come from environment — never hardcode const PAYMENT_URL = process.env.PAYMENT_SERVICE_URL ?? 'http://payment-service:3001'; const SHIPPING_URL = process.env.SHIPPING_SERVICE_URL ?? 'http://shipping-service:3002'; // A thin service client — handles JSON, propagates errors cleanly 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

// Transient failures (network blip, brief overload) can be recovered by retrying. // Exponential backoff avoids hammering a struggling service. // Jitter randomises the delay so all retrying clients don't hit at the same moment. async function withRetry(fn, { maxAttempts = 4, baseDelayMs = 200, maxDelayMs = 10_000, shouldRetry = (err) => !err.status || err.status >= 500, // don't retry 4xx } = {}) { let lastErr; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (err) { lastErr = err; if (attempt === maxAttempts || !shouldRetry(err)) throw err; // Exponential backoff with full jitter const cap = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1)); const delay = Math.random() * cap; // random in [0, cap] console.warn(`Attempt ${attempt} failed; retrying in ${delay.toFixed(0)}ms`); await new Promise(r => setTimeout(r, delay)); } } throw lastErr; } // Usage 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
// npm install opossum (the standard Node.js circuit breaker) const CircuitBreaker = require('opossum'); const breaker = new CircuitBreaker( (body) => paymentClient.post('/charges', body), { timeout: 3000, // call fails if it takes longer than 3s errorThresholdPercentage: 50, // open when ≥50% of calls fail resetTimeout: 10_000, // try half-open after 10s volumeThreshold: 5, // need at least 5 calls before opening } ); // Fallback: what to return when the circuit is open breaker.fallback((body, err) => ({ queued: true, message: 'Payment service unavailable — order queued for retry', })); // Observe state transitions for monitoring 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)); // Fire through the breaker instead of calling the service directly const result = await breaker.fire({ amount: 5000, currency: 'usd' }); // Runtime stats console.log(breaker.stats()); // { fires: 10, successes: 8, failures: 2, fallbacks: 2, rejects: 0, ... }

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.

// event-bus.js — Redis Pub/Sub as a cross-service event bus const Redis = require('ioredis'); class EventBus { constructor(redisOptions) { this._pub = new Redis(redisOptions); this._sub = new Redis(redisOptions); this._handlers = new Map(); // event → Set<fn> 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()]); } } // order-service.js — publishes the event, doesn't know who listens const bus = new EventBus({ host: 'localhost', port: 6379 }); await bus.publish('order.created', { orderId: 123, userId: 42, total: 9900 }); // payment-service.js — subscribes and reacts await bus.subscribe('order.created', async ({ orderId, total }) => { await chargeCard(orderId, total); }); // notification-service.js — also subscribes to the same event independently await bus.subscribe('order.created', async ({ orderId, userId }) => { await sendConfirmationEmail(userId, orderId); });

Health Checks — Liveness and Readiness

// /health/live — is the process itself alive? (Kubernetes liveness probe) // /health/ready — is the service ready to receive traffic? (readiness probe) // Kubernetes restarts pods that fail liveness; removes them from load balancer on readiness fail. const checks = { async database() { await db.query('SELECT 1'); }, async redis() { await redis.ping(); }, }; app.get('/health/live', (_req, res) => { // Liveness: if we can respond, we're alive 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
// Minimal API gateway with http-proxy-middleware // npm install http-proxy-middleware express-rate-limit const express = require('express'); const { createProxyMiddleware } = require('http-proxy-middleware'); const rateLimit = require('express-rate-limit'); const app = express(); // Global rate limit app.use(rateLimit({ windowMs: 60_000, max: 200 })); // Auth middleware — runs before every upstream proxy 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' }); } }); // Route each prefix to the right upstream service 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) => { // Forward the verified user identity as a trusted header 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 ↗