A
Aghyad Alghazawi
1min read

Essential patterns and practices for designing scalable microservices architecture.

Microservices Architecture: Design Patterns for Scale

Microservices architecture enables independent scaling of development teams and systems. This guide covers essential patterns and practices for building successful microservices-based applications.

Table of Contents

  1. Service Design Principles
  2. Communication Patterns
  3. Data Management
  4. Service Discovery
  5. Deployment Strategies
  6. Monitoring & Observability

Service Design Principles

Domain-Driven Design

Design services around business domains, not technical layers:

// Order Service - handles order lifecycle
class OrderService {
  constructor() {
    this.domain = "order-management";
  }

  async createOrder(orderData) {
    const order = new Order(orderData);
    await order.validate();

    const savedOrder = await this.orderRepository.save(order);

    // Publish domain event
    await this.eventBus.publish("order.created", {
      orderId: savedOrder.id,
      customerId: savedOrder.customerId,
      totalAmount: savedOrder.totalAmount,
    });

    return savedOrder;
  }
}

// Inventory Service - manages stock
class InventoryService {
  constructor() {
    this.domain = "inventory-management";
  }

  async reserveItems(items) {
    for (const item of items) {
      const available = await this.checkAvailability(
        item.productId,
        item.quantity,
      );
      if (!available) {
        throw new Error(`Insufficient stock for product ${item.productId}`);
      }
      await this.reserveStock(item.productId, item.quantity);
    }
  }
}

Service Boundaries

Define clear interfaces and responsibilities:

class ServiceInterface {
  constructor(serviceName, version) {
    this.serviceName = serviceName;
    this.version = version;
    this.endpoints = new Map();
    this.events = new Map();
  }

  defineEndpoint(path, method, handler, schema) {
    this.endpoints.set(`${method}:${path}`, {
      handler,
      schema,
      rateLimit: { windowMs: 15 * 60 * 1000, max: 100 },
      authentication: true,
    });
  }

  defineEvent(eventName, schema) {
    this.events.set(eventName, { schema, version: this.version });
  }
}

// Usage
const userService = new ServiceInterface("user-service", "v1");
userService.defineEndpoint("/users", "POST", createUserHandler, {
  body: {
    type: "object",
    required: ["email", "password"],
    properties: {
      email: { type: "string", format: "email" },
      password: { type: "string", minLength: 8 },
    },
  },
});

Communication Patterns

API Gateway Pattern

Centralize cross-cutting concerns:

class APIGateway {
  constructor() {
    this.services = new Map();
    this.circuitBreakers = new Map();
  }

  registerService(serviceName, instances) {
    this.services.set(serviceName, instances);
    this.circuitBreakers.set(
      serviceName,
      new CircuitBreaker({
        failureThreshold: 5,
        resetTimeout: 30000,
      }),
    );
  }

  async routeRequest(serviceName, path, options) {
    const circuitBreaker = this.circuitBreakers.get(serviceName);

    return await circuitBreaker.execute(async () => {
      const serviceInstance = this.getHealthyInstance(serviceName);
      const url = `${serviceInstance.baseUrl}${path}`;

      const response = await fetch(url, {
        ...options,
        timeout: 5000,
        headers: {
          ...options.headers,
          "X-Request-ID": this.generateRequestId(),
        },
      });

      if (!response.ok) {
        throw new Error(`Service ${serviceName} returned ${response.status}`);
      }

      return response.json();
    });
  }
}

Event-Driven Communication

Use events for loose coupling:

class EventBus {
  constructor() {
    this.subscribers = new Map();
  }

  subscribe(eventType, handler) {
    if (!this.subscribers.has(eventType)) {
      this.subscribers.set(eventType, []);
    }
    this.subscribers.get(eventType).push(handler);
  }

  async publish(eventType, data) {
    const event = {
      id: this.generateEventId(),
      type: eventType,
      data,
      timestamp: new Date().toISOString(),
      version: "1.0",
    };

    const handlers = this.subscribers.get(eventType) || [];
    await Promise.all(handlers.map((handler) => handler(event)));
  }
}

// Usage
const eventBus = new EventBus();

// Order service publishes events
eventBus.subscribe("order.created", async (event) => {
  await inventoryService.reserveItems(event.data.items);
});

eventBus.subscribe("order.created", async (event) => {
  await emailService.sendOrderConfirmation(event.data.customerId);
});

Saga Pattern for Distributed Transactions

class OrderSaga {
  constructor() {
    this.steps = [];
    this.compensations = [];
  }

  async execute(orderData) {
    try {
      // Step 1: Create order
      const order = await this.orderService.createOrder(orderData);
      this.steps.push({ service: "order", action: "create", data: order });

      // Step 2: Reserve inventory
      await this.inventoryService.reserveItems(orderData.items);
      this.steps.push({
        service: "inventory",
        action: "reserve",
        data: orderData.items,
      });

      // Step 3: Process payment
      const payment = await this.paymentService.processPayment(
        orderData.payment,
      );
      this.steps.push({ service: "payment", action: "process", data: payment });

      return { success: true, orderId: order.id };
    } catch (error) {
      await this.compensate();
      throw error;
    }
  }

  async compensate() {
    for (const step of this.steps.reverse()) {
      try {
        await this.executeCompensation(step);
      } catch (compensationError) {
        console.error("Compensation failed:", compensationError);
      }
    }
  }
}

Data Management

Database per Service

Each service owns its data:

class UserService {
  constructor() {
    this.database = new Database("user_db");
  }

  async createUser(userData) {
    return await this.database.users.create(userData);
  }

  async getUserById(id) {
    return await this.database.users.findById(id);
  }
}

class OrderService {
  constructor() {
    this.database = new Database("order_db");
  }

  async createOrder(orderData) {
    // Only store user reference, not user data
    const order = {
      ...orderData,
      userId: orderData.userId, // Reference only
    };
    return await this.database.orders.create(order);
  }
}

CQRS Pattern

Separate read and write models:

// Command side - handles writes
class OrderCommandService {
  async createOrder(command) {
    const order = new Order(command);
    await this.orderRepository.save(order);

    await this.eventStore.append("order.created", {
      orderId: order.id,
      customerId: order.customerId,
      items: order.items,
    });
  }
}

// Query side - handles reads
class OrderQueryService {
  async getOrderById(id) {
    return await this.orderReadModel.findById(id);
  }

  async getOrdersByCustomer(customerId) {
    return await this.orderReadModel.findByCustomerId(customerId);
  }
}

// Event handler updates read model
class OrderProjectionHandler {
  async handle(event) {
    switch (event.type) {
      case "order.created":
        await this.updateOrderProjection(event.data);
        break;
      case "order.updated":
        await this.updateOrderProjection(event.data);
        break;
    }
  }
}

Service Discovery

Service Registry Pattern

class ServiceRegistry {
  constructor() {
    this.services = new Map();
    this.healthChecks = new Map();
  }

  register(serviceName, instance) {
    if (!this.services.has(serviceName)) {
      this.services.set(serviceName, []);
    }

    this.services.get(serviceName).push({
      ...instance,
      registeredAt: new Date(),
      status: "healthy",
    });

    this.startHealthCheck(serviceName, instance);
  }

  discover(serviceName) {
    const instances = this.services.get(serviceName) || [];
    return instances.filter((instance) => instance.status === "healthy");
  }

  async startHealthCheck(serviceName, instance) {
    setInterval(async () => {
      try {
        const response = await fetch(`${instance.baseUrl}/health`);
        instance.status = response.ok ? "healthy" : "unhealthy";
      } catch (error) {
        instance.status = "unhealthy";
      }
    }, 30000); // Check every 30 seconds
  }
}

Load Balancing

class LoadBalancer {
  constructor(strategy = "round-robin") {
    this.strategy = strategy;
    this.counters = new Map();
  }

  selectInstance(serviceName, instances) {
    switch (this.strategy) {
      case "round-robin":
        return this.roundRobin(serviceName, instances);
      case "random":
        return this.random(instances);
      case "least-connections":
        return this.leastConnections(instances);
      default:
        return instances[0];
    }
  }

  roundRobin(serviceName, instances) {
    if (!this.counters.has(serviceName)) {
      this.counters.set(serviceName, 0);
    }

    const counter = this.counters.get(serviceName);
    const instance = instances[counter % instances.length];
    this.counters.set(serviceName, counter + 1);

    return instance;
  }
}

Deployment Strategies

Containerization with Docker

# Dockerfile for Node.js microservice
FROM node:18-alpine

WORKDIR /app

# Copy package files
COPY package*.json ./
RUN npm ci --only=production

# Copy source code
COPY src/ ./src/

# Create non-root user
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nodejs -u 1001
USER nodejs

EXPOSE 3000

CMD ["node", "src/index.js"]

Kubernetes Deployment

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: user-service
  template:
    metadata:
      labels:
        app: user-service
    spec:
      containers:
        - name: user-service
          image: user-service:latest
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: db-secret
                  key: url
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "200m"
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 30
            periodSeconds: 10

Blue-Green Deployment

class DeploymentManager {
  constructor() {
    this.environments = {
      blue: { active: true, version: "v1.0" },
      green: { active: false, version: "v1.1" },
    };
  }

  async deployToGreen(newVersion) {
    // Deploy to green environment
    await this.deployService("green", newVersion);

    // Run health checks
    const healthCheck = await this.runHealthChecks("green");
    if (!healthCheck.passed) {
      throw new Error("Health checks failed");
    }

    // Run smoke tests
    const smokeTests = await this.runSmokeTests("green");
    if (!smokeTests.passed) {
      throw new Error("Smoke tests failed");
    }

    return { ready: true, environment: "green" };
  }

  async switchTraffic() {
    // Switch traffic from blue to green
    this.environments.blue.active = false;
    this.environments.green.active = true;

    await this.updateLoadBalancer();
  }
}

Monitoring & Observability

Distributed Tracing

class TracingMiddleware {
  constructor() {
    this.tracer = new Tracer();
  }

  middleware() {
    return (req, res, next) => {
      const traceId = req.headers["x-trace-id"] || this.generateTraceId();
      const spanId = this.generateSpanId();

      req.tracing = { traceId, spanId };

      const span = this.tracer.startSpan({
        traceId,
        spanId,
        operationName: `${req.method} ${req.path}`,
        startTime: Date.now(),
      });

      res.on("finish", () => {
        span.finish({
          statusCode: res.statusCode,
          duration: Date.now() - span.startTime,
        });
      });

      next();
    };
  }
}

Health Checks

class HealthCheckService {
  constructor() {
    this.checks = new Map();
  }

  addCheck(name, checkFunction) {
    this.checks.set(name, checkFunction);
  }

  async runChecks() {
    const results = {};
    let overallStatus = "healthy";

    for (const [name, checkFunction] of this.checks) {
      try {
        const result = await checkFunction();
        results[name] = { status: "healthy", ...result };
      } catch (error) {
        results[name] = { status: "unhealthy", error: error.message };
        overallStatus = "unhealthy";
      }
    }

    return { status: overallStatus, checks: results };
  }
}

// Usage
const healthCheck = new HealthCheckService();

healthCheck.addCheck("database", async () => {
  await database.ping();
  return { responseTime: "5ms" };
});

healthCheck.addCheck("external-api", async () => {
  const response = await fetch("https://api.external.com/health");
  return { status: response.status };
});

Metrics Collection

class MetricsCollector {
  constructor() {
    this.metrics = new Map();
  }

  increment(name, tags = {}) {
    const key = this.createKey(name, tags);
    const current = this.metrics.get(key) || 0;
    this.metrics.set(key, current + 1);
  }

  gauge(name, value, tags = {}) {
    const key = this.createKey(name, tags);
    this.metrics.set(key, value);
  }

  histogram(name, value, tags = {}) {
    const key = this.createKey(name, tags);
    if (!this.metrics.has(key)) {
      this.metrics.set(key, []);
    }
    this.metrics.get(key).push(value);
  }

  export() {
    const exported = {};
    for (const [key, value] of this.metrics) {
      exported[key] = value;
    }
    return exported;
  }
}

// Usage
const metrics = new MetricsCollector();

// In request handler
metrics.increment("http.requests", { method: "GET", endpoint: "/users" });
metrics.histogram("http.response_time", responseTime, { endpoint: "/users" });

Best Practices

Service Design

  • Keep services small and focused on a single business capability
  • Design for failure - implement circuit breakers and retries
  • Use asynchronous communication where possible
  • Implement proper error handling and logging

Data Management

  • Each service should own its data
  • Use eventual consistency where strong consistency isn't required
  • Implement proper backup and disaster recovery strategies
  • Consider data privacy and compliance requirements

Security

class SecurityMiddleware {
  constructor() {
    this.jwtSecret = process.env.JWT_SECRET;
  }

  authenticate() {
    return (req, res, next) => {
      const token = req.headers.authorization?.replace("Bearer ", "");

      if (!token) {
        return res.status(401).json({ error: "No token provided" });
      }

      try {
        const decoded = jwt.verify(token, this.jwtSecret);
        req.user = decoded;
        next();
      } catch (error) {
        return res.status(401).json({ error: "Invalid token" });
      }
    };
  }

  authorize(permissions) {
    return (req, res, next) => {
      const userPermissions = req.user.permissions || [];
      const hasPermission = permissions.every((p) =>
        userPermissions.includes(p),
      );

      if (!hasPermission) {
        return res.status(403).json({ error: "Insufficient permissions" });
      }

      next();
    };
  }
}

Conclusion

Microservices architecture offers significant benefits for scalable systems but comes with complexity. Focus on proper service boundaries, robust communication patterns, and comprehensive monitoring. Start with a modular monolith and extract services as your understanding of the domain grows.

Key success factors:

  • Clear service boundaries based on business domains
  • Robust inter-service communication
  • Comprehensive monitoring and observability
  • Automated testing and deployment
  • Strong DevOps culture and practices

Remember: microservices are a means to an end, not the end itself. Choose this architecture when the benefits outweigh the operational complexity.