Skip to main content

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

AspectMonolithMicroservices
DeploymentAll or nothingIndependent per service
ScalingScale entire appScale specific services
TechnologySingle stackBest tool for each job
Team StructureLarge, coordinated teamSmall, autonomous teams
ComplexityIn the codeIn the infrastructure
DataSingle databaseDatabase 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).
npm install amqplib
// 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