Documentation Index
Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt
Use this file to discover all available pages before exploring further.
Microservices Architecture
As applications grow, monolithic architectures become difficult to maintain and scale. Microservices offer a way to build applications as a collection of small, independent services that communicate over the network.
But a word of caution upfront: microservices trade one kind of complexity (a tangled codebase) for another kind (a tangled network). If your team is small or your domain boundaries are unclear, a well-structured monolith will outperform a poorly-designed microservices architecture every time. The right question is not “should we use microservices?” but rather “have we outgrown our monolith?”
Monolith vs Microservices
| Aspect | Monolith | Microservices |
|---|
| Deployment | All or nothing | Independent per service |
| Scaling | Scale entire app | Scale specific services |
| Technology | Single stack | Best tool for each job |
| Team Structure | Large, coordinated team | Small, autonomous teams |
| Complexity | In the code | In the infrastructure |
| Data | Single database | Database per service |
When to Use Microservices
✅ Use when:
- Large team (50+ developers)
- Different scaling needs per feature
- Need technology diversity
- High availability requirements
- Complex domain with clear boundaries
❌ Don’t use when:
- Small team or startup MVP
- Simple CRUD application
- No clear service boundaries
- Limited DevOps capabilities
Service Design Principles
Single Responsibility
Each service should do one thing well.
✅ Good Service Boundaries:
- User Service (auth, profiles)
- Order Service (cart, checkout)
- Payment Service (transactions)
- Notification Service (email, SMS, push)
- Inventory Service (stock management)
❌ Poor Boundaries:
- User and Order Service (too coupled)
- Everything Service (just a monolith)
API Gateway Pattern
In a microservices architecture, clients should never call individual services directly. Instead, they talk to a single entry point — the API Gateway — which routes requests to the appropriate backend service. This is like a hotel concierge: guests make all their requests through one person, who knows which department handles what.
The gateway also handles cross-cutting concerns that would otherwise be duplicated in every service: authentication, rate limiting, request logging, and CORS.
// gateway/app.js
const express = require('express');
const httpProxy = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const app = express();
// Rate limiting
app.use(rateLimit({
windowMs: 15 * 60 * 1000,
max: 100
}));
// Authentication middleware
app.use('/api', authMiddleware);
// Route to services
app.use('/api/users', httpProxy.createProxyMiddleware({
target: 'http://user-service:3001',
changeOrigin: true,
pathRewrite: { '^/api/users': '' }
}));
app.use('/api/orders', httpProxy.createProxyMiddleware({
target: 'http://order-service:3002',
changeOrigin: true,
pathRewrite: { '^/api/orders': '' }
}));
app.use('/api/products', httpProxy.createProxyMiddleware({
target: 'http://product-service:3003',
changeOrigin: true,
pathRewrite: { '^/api/products': '' }
}));
app.listen(3000);
Service Communication
Synchronous (HTTP/REST)
// order-service calling user-service
const axios = require('axios');
class UserServiceClient {
constructor() {
this.baseURL = process.env.USER_SERVICE_URL || 'http://user-service:3001';
this.client = axios.create({
baseURL: this.baseURL,
timeout: 5000
});
}
async getUser(userId) {
try {
const { data } = await this.client.get(`/users/${userId}`);
return data;
} catch (error) {
if (error.response?.status === 404) {
return null;
}
throw new Error(`User service error: ${error.message}`);
}
}
async validateUser(userId) {
const user = await this.getUser(userId);
return user !== null;
}
}
module.exports = new UserServiceClient();
Circuit Breaker Pattern
When Service A depends on Service B, and Service B goes down, naive retries from Service A will pile up, consuming threads and memory until Service A also crashes — a cascade failure. The circuit breaker pattern prevents this by acting like an electrical circuit breaker: after a threshold of failures, it “opens” and immediately returns a fallback response without even attempting the network call. After a cooldown period, it allows a test request through to see if the downstream service has recovered.
There are three states: Closed (normal operation, requests pass through), Open (service is down, requests fail fast), and Half-Open (testing if the service has recovered).
const CircuitBreaker = require('opossum');
const options = {
timeout: 3000, // If a single call takes longer than 3s, count it as a failure
errorThresholdPercentage: 50, // If 50% of requests fail, open the circuit
resetTimeout: 30000 // After 30s, try one request to check if the service recovered
};
const breaker = new CircuitBreaker(async (userId) => {
const response = await axios.get(`${USER_SERVICE_URL}/users/${userId}`);
return response.data;
}, options);
breaker.on('open', () => console.log('Circuit breaker opened'));
breaker.on('halfOpen', () => console.log('Circuit breaker half-open'));
breaker.on('close', () => console.log('Circuit breaker closed'));
// Usage
const getUser = async (userId) => {
try {
return await breaker.fire(userId);
} catch (error) {
// Return cached data or default
return { id: userId, name: 'Unknown (service unavailable)' };
}
};
Asynchronous (Message Queue)
Synchronous HTTP calls between services create tight coupling: if the downstream service is slow or down, the caller blocks or fails. Message queues decouple services by introducing a buffer between them. The producer drops a message into the queue and moves on immediately; the consumer processes it whenever it is ready. This is like leaving a note on someone’s desk instead of waiting for them at their office door.
Message queues also provide durability (messages survive a service restart), load leveling (consumers process at their own pace), and fan-out (multiple consumers can process the same event type).
// RabbitMQ message producer/consumer
const amqp = require('amqplib');
class MessageQueue {
async connect() {
this.connection = await amqp.connect(process.env.RABBITMQ_URL);
this.channel = await this.connection.createChannel();
}
async publish(queue, message) {
await this.channel.assertQueue(queue, { durable: true });
this.channel.sendToQueue(
queue,
Buffer.from(JSON.stringify(message)),
{ persistent: true }
);
}
async subscribe(queue, handler) {
await this.channel.assertQueue(queue, { durable: true });
this.channel.consume(queue, async (msg) => {
try {
const content = JSON.parse(msg.content.toString());
await handler(content);
this.channel.ack(msg);
} catch (error) {
console.error('Message handling error:', error);
this.channel.nack(msg, false, false); // Dead letter
}
});
}
}
// Order service - publish event
const orderCreated = async (order) => {
await mq.publish('order.created', {
orderId: order.id,
userId: order.userId,
items: order.items,
total: order.total,
timestamp: new Date().toISOString()
});
};
// Notification service - consume event
mq.subscribe('order.created', async (event) => {
const user = await userService.getUser(event.userId);
await sendEmail(user.email, 'Order Confirmation', {
orderId: event.orderId,
total: event.total
});
});
// Inventory service - consume event
mq.subscribe('order.created', async (event) => {
for (const item of event.items) {
await decreaseStock(item.productId, item.quantity);
}
});
Event-Driven Architecture
// Event types
const EventTypes = {
USER_CREATED: 'user.created',
USER_UPDATED: 'user.updated',
ORDER_CREATED: 'order.created',
ORDER_PAID: 'order.paid',
ORDER_SHIPPED: 'order.shipped',
PAYMENT_RECEIVED: 'payment.received',
PAYMENT_FAILED: 'payment.failed'
};
// Event structure
const createEvent = (type, data, metadata = {}) => ({
id: uuid(),
type,
data,
metadata: {
timestamp: new Date().toISOString(),
version: '1.0',
source: process.env.SERVICE_NAME,
correlationId: metadata.correlationId || uuid(),
...metadata
}
});
// Publish event
await eventBus.publish(createEvent(EventTypes.ORDER_CREATED, {
orderId: order.id,
userId: order.userId,
items: order.items
}));
Service Discovery
With Docker Compose
# docker-compose.yml
version: '3.8'
services:
gateway:
build: ./gateway
ports:
- "3000:3000"
depends_on:
- user-service
- order-service
environment:
- USER_SERVICE_URL=http://user-service:3001
- ORDER_SERVICE_URL=http://order-service:3002
user-service:
build: ./user-service
environment:
- DATABASE_URL=mongodb://mongo:27017/users
depends_on:
- mongo
order-service:
build: ./order-service
environment:
- DATABASE_URL=postgres://postgres:password@postgres:5432/orders
- USER_SERVICE_URL=http://user-service:3001
- RABBITMQ_URL=amqp://rabbitmq:5672
depends_on:
- postgres
- rabbitmq
mongo:
image: mongo:6
postgres:
image: postgres:15
environment:
- POSTGRES_PASSWORD=password
- POSTGRES_DB=orders
rabbitmq:
image: rabbitmq:3-management
ports:
- "15672:15672" # Management UI
With Consul (Production)
const Consul = require('consul');
const consul = new Consul({
host: process.env.CONSUL_HOST || 'localhost'
});
// Register service
const registerService = async () => {
await consul.agent.service.register({
name: 'user-service',
id: `user-service-${process.env.HOSTNAME}`,
address: process.env.HOST_IP,
port: parseInt(process.env.PORT),
check: {
http: `http://${process.env.HOST_IP}:${process.env.PORT}/health`,
interval: '10s'
}
});
};
// Discover service
const getServiceUrl = async (serviceName) => {
const services = await consul.catalog.service.nodes(serviceName);
if (services.length === 0) {
throw new Error(`Service ${serviceName} not found`);
}
// Simple round-robin
const service = services[Math.floor(Math.random() * services.length)];
return `http://${service.ServiceAddress}:${service.ServicePort}`;
};
Data Management
Database Per Service
// User service - MongoDB
const mongoose = require('mongoose');
mongoose.connect(process.env.MONGO_URI);
const UserSchema = new mongoose.Schema({
email: { type: String, unique: true },
name: String,
createdAt: { type: Date, default: Date.now }
});
// Order service - PostgreSQL with Prisma
// prisma/schema.prisma
model Order {
id Int @id @default(autoincrement())
userId String // Reference to user in another service
items Json
total Decimal
status OrderStatus
createdAt DateTime @default(now())
}
// Product service - Redis for fast access
const redis = require('redis');
const client = redis.createClient(process.env.REDIS_URL);
const getProduct = async (id) => {
const cached = await client.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const product = await fetchFromDB(id);
await client.setEx(`product:${id}`, 3600, JSON.stringify(product));
return product;
};
Saga Pattern for Distributed Transactions
In a monolith, you wrap multiple database operations in a single transaction — if anything fails, everything rolls back. In microservices, each service has its own database, so traditional transactions do not work across service boundaries. The saga pattern solves this by breaking a distributed transaction into a sequence of local transactions, each with a compensating action that undoes its work if a later step fails.
Think of it like booking a vacation: you reserve a flight, then a hotel, then a rental car. If the rental car is unavailable, you cancel the hotel and the flight — each cancellation is a “compensating transaction.”
There are two flavors: choreography (each service listens for events and reacts, no central coordinator) and orchestration (a central saga manager tells each service what to do). Choreography is simpler for small sagas; orchestration is easier to reason about for complex multi-step flows.
// Choreography-based saga -- each service reacts to events independently
class OrderSaga {
constructor(eventBus) {
this.eventBus = eventBus;
this.setupListeners();
}
setupListeners() {
// Step 1: Order created -> Reserve inventory
this.eventBus.subscribe('order.created', async (event) => {
try {
await this.reserveInventory(event.data);
await this.eventBus.publish('inventory.reserved', event.data);
} catch (error) {
await this.eventBus.publish('inventory.reservation.failed', event.data);
}
});
// Step 2: Inventory reserved -> Process payment
this.eventBus.subscribe('inventory.reserved', async (event) => {
try {
await this.processPayment(event.data);
await this.eventBus.publish('payment.completed', event.data);
} catch (error) {
// Compensate: release inventory
await this.releaseInventory(event.data);
await this.eventBus.publish('payment.failed', event.data);
}
});
// Step 3: Payment completed -> Confirm order
this.eventBus.subscribe('payment.completed', async (event) => {
await this.confirmOrder(event.data);
await this.eventBus.publish('order.confirmed', event.data);
});
// Compensation handlers
this.eventBus.subscribe('payment.failed', async (event) => {
await this.cancelOrder(event.data);
});
}
}
Health Checks and Monitoring
// Health check endpoint
app.get('/health', async (req, res) => {
const health = {
status: 'healthy',
service: process.env.SERVICE_NAME,
timestamp: new Date().toISOString(),
uptime: process.uptime(),
checks: {}
};
// Database check
try {
await db.query('SELECT 1');
health.checks.database = { status: 'healthy' };
} catch (error) {
health.status = 'unhealthy';
health.checks.database = { status: 'unhealthy', error: error.message };
}
// Message queue check
try {
await mq.checkConnection();
health.checks.messageQueue = { status: 'healthy' };
} catch (error) {
health.status = 'unhealthy';
health.checks.messageQueue = { status: 'unhealthy', error: error.message };
}
// External service check
try {
await userService.ping();
health.checks.userService = { status: 'healthy' };
} catch (error) {
health.status = 'degraded'; // Might still work
health.checks.userService = { status: 'unhealthy', error: error.message };
}
const statusCode = health.status === 'healthy' ? 200 : 503;
res.status(statusCode).json(health);
});
Distributed Tracing
In a monolith, when a request is slow, you look at one set of logs. In microservices, a single user request might flow through 5 different services. Without distributed tracing, debugging “why was this request slow?” becomes a needle-in-a-haystack problem across multiple log streams.
Distributed tracing assigns a unique trace ID to each incoming request and propagates it through every service the request touches. Each service records spans (timed operations), and tracing tools like Jaeger or Zipkin stitch them together into a visual timeline showing exactly where time was spent.
const { trace } = require('@opentelemetry/api');
// Trace an operation
const tracer = trace.getTracer('order-service');
app.post('/orders', async (req, res) => {
const span = tracer.startSpan('createOrder');
try {
span.setAttribute('userId', req.body.userId);
// Create child span for each operation
const userSpan = tracer.startSpan('validateUser', { parent: span });
const user = await userService.getUser(req.body.userId);
userSpan.end();
const orderSpan = tracer.startSpan('saveOrder', { parent: span });
const order = await Order.create(req.body);
orderSpan.end();
span.setAttribute('orderId', order.id);
res.json(order);
} catch (error) {
span.recordException(error);
span.setStatus({ code: 2, message: error.message });
throw error;
} finally {
span.end();
}
});
Summary
- Start with a monolith - Extract services when needed
- Define clear boundaries - One service, one responsibility
- Use API Gateway - Single entry point for clients
- Implement circuit breakers - Handle service failures gracefully
- Prefer async communication - Message queues reduce coupling
- Database per service - Avoid shared databases
- Handle distributed transactions - Use saga pattern
- Monitor everything - Health checks, tracing, logging
- Container orchestration - Use Kubernetes in production