Architecture

Essential Microservices Patterns for Modern Applications

·5 min read
Essential Microservices Patterns for Modern Applications

Essential Microservices Patterns for Modern Applications

Throughout my career building distributed systems, I've learned that successful microservices architecture relies on implementing the right patterns. Here's a comprehensive guide to essential microservices patterns that will help you build robust, scalable systems.

Service Communication Patterns

1. API Gateway Pattern

Implement a centralized entry point for clients:

// api-gateway/src/gateway.ts
import express from "express";
import { createProxyMiddleware } from "http-proxy-middleware";

class APIGateway {
  private app: express.Application;
  private serviceRegistry: ServiceRegistry;

  constructor() {
    this.app = express();
    this.serviceRegistry = new ServiceRegistry();
    this.setupRoutes();
  }

  private setupRoutes() {
    // Authentication middleware
    this.app.use(this.authenticate);

    // Route to services
    this.app.use("/users", this.createServiceProxy("user-service"));
    this.app.use("/orders", this.createServiceProxy("order-service"));
    this.app.use("/products", this.createServiceProxy("product-service"));
  }

  private createServiceProxy(serviceName: string) {
    return createProxyMiddleware({
      target: this.serviceRegistry.getServiceUrl(serviceName),
      changeOrigin: true,
      pathRewrite: {
        [`^/${serviceName}`]: "",
      },
    });
  }

  private authenticate(
    req: express.Request,
    res: express.Response,
    next: express.NextFunction
  ) {
    const token = req.headers.authorization;
    if (!token) {
      return res.status(401).json({ error: "Unauthorized" });
    }
    // Validate token
    next();
  }
}

2. Event-Driven Communication

Implement asynchronous communication between services:

// lib/event-bus.ts
interface Event {
  type: string;
  data: any;
  timestamp: Date;
}

class EventBus {
  private subscribers: Map<string, Function[]>;

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

  async publish(event: Event): Promise<void> {
    const subscribers = this.subscribers.get(event.type) || [];
    const promises = subscribers.map((callback) => callback(event));
    await Promise.all(promises);
  }

  subscribe(eventType: string, callback: Function): void {
    const subscribers = this.subscribers.get(eventType) || [];
    subscribers.push(callback);
    this.subscribers.set(eventType, subscribers);
  }
}

// Usage in a service
class OrderService {
  private eventBus: EventBus;

  constructor(eventBus: EventBus) {
    this.eventBus = eventBus;
    this.setupSubscriptions();
  }

  private setupSubscriptions() {
    this.eventBus.subscribe("PaymentCompleted", this.handlePaymentCompleted);
  }

  async createOrder(orderData: OrderData): Promise<void> {
    const order = await this.orderRepository.create(orderData);
    await this.eventBus.publish({
      type: "OrderCreated",
      data: order,
      timestamp: new Date(),
    });
  }
}

Data Management Patterns

1. Database per Service

Implement isolated data storage:

// order-service/src/database.ts
interface DatabaseConfig {
  host: string;
  port: number;
  database: string;
  credentials: {
    username: string;
    password: string;
  };
}

class ServiceDatabase {
  private connection: Connection;
  private config: DatabaseConfig;

  constructor(config: DatabaseConfig) {
    this.config = config;
  }

  async connect(): Promise<void> {
    this.connection = await createConnection({
      type: "postgres",
      host: this.config.host,
      port: this.config.port,
      database: this.config.database,
      username: this.config.credentials.username,
      password: this.config.credentials.password,
      entities: ["src/entities/**/*.ts"],
      migrations: ["src/migrations/**/*.ts"],
    });
  }

  async disconnect(): Promise<void> {
    await this.connection.close();
  }
}

2. CQRS Pattern

Separate read and write operations:

// lib/cqrs.ts
interface Command {
  type: string;
  payload: any;
}

interface Query {
  type: string;
  parameters: any;
}

class CommandBus {
  private handlers: Map<string, CommandHandler>;

  async dispatch(command: Command): Promise<void> {
    const handler = this.handlers.get(command.type);
    if (!handler) {
      throw new Error(`No handler for command: ${command.type}`);
    }
    await handler.execute(command.payload);
  }
}

class QueryBus {
  private handlers: Map<string, QueryHandler>;

  async query<T>(query: Query): Promise<T> {
    const handler = this.handlers.get(query.type);
    if (!handler) {
      throw new Error(`No handler for query: ${query.type}`);
    }
    return handler.execute(query.parameters);
  }
}

// Usage in a service
class OrderService {
  constructor(private commandBus: CommandBus, private queryBus: QueryBus) {}

  async createOrder(orderData: OrderData): Promise<void> {
    await this.commandBus.dispatch({
      type: "CreateOrder",
      payload: orderData,
    });
  }

  async getOrder(orderId: string): Promise<Order> {
    return this.queryBus.query({
      type: "GetOrder",
      parameters: { orderId },
    });
  }
}

Resilience Patterns

1. Circuit Breaker

Implement fault tolerance:

// lib/circuit-breaker.ts
enum CircuitState {
  CLOSED,
  OPEN,
  HALF_OPEN,
}

class CircuitBreaker {
  private state: CircuitState = CircuitState.CLOSED;
  private failureCount: number = 0;
  private lastFailureTime: number = 0;

  constructor(private failureThreshold: number, private resetTimeout: number) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === CircuitState.OPEN) {
      if (Date.now() - this.lastFailureTime >= this.resetTimeout) {
        this.state = CircuitState.HALF_OPEN;
      } else {
        throw new Error("Circuit breaker is OPEN");
      }
    }

    try {
      const result = await operation();
      this.onSuccess();
      return result;
    } catch (error) {
      this.onFailure();
      throw error;
    }
  }

  private onSuccess(): void {
    this.failureCount = 0;
    this.state = CircuitState.CLOSED;
  }

  private onFailure(): void {
    this.failureCount++;
    this.lastFailureTime = Date.now();

    if (this.failureCount >= this.failureThreshold) {
      this.state = CircuitState.OPEN;
    }
  }
}

// Usage
class ServiceClient {
  private circuitBreaker: CircuitBreaker;

  constructor() {
    this.circuitBreaker = new CircuitBreaker(5, 60000);
  }

  async makeRequest(): Promise<Response> {
    return this.circuitBreaker.execute(async () => {
      const response = await fetch("http://api.example.com");
      if (!response.ok) throw new Error("Request failed");
      return response;
    });
  }
}

2. Bulkhead Pattern

Isolate resources:

// lib/bulkhead.ts
class Bulkhead {
  private executing: number = 0;
  private queue: Array<() => Promise<void>> = [];

  constructor(private maxConcurrent: number, private maxQueue: number) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.executing >= this.maxConcurrent) {
      if (this.queue.length >= this.maxQueue) {
        throw new Error("Bulkhead overflow");
      }
      await new Promise<void>((resolve) => this.queue.push(resolve));
    }

    this.executing++;
    try {
      return await operation();
    } finally {
      this.executing--;
      if (this.queue.length > 0) {
        const next = this.queue.shift();
        next?.();
      }
    }
  }
}

Service Discovery and Configuration

1. Service Registry

Implement service discovery:

// lib/service-registry.ts
interface ServiceInstance {
  id: string;
  name: string;
  url: string;
  health: string;
  metadata: Record<string, any>;
}

class ServiceRegistry {
  private services: Map<string, ServiceInstance[]>;

  register(instance: ServiceInstance): void {
    const instances = this.services.get(instance.name) || [];
    instances.push(instance);
    this.services.set(instance.name, instances);
  }

  unregister(instance: ServiceInstance): void {
    const instances = this.services.get(instance.name) || [];
    const index = instances.findIndex((i) => i.id === instance.id);
    if (index !== -1) {
      instances.splice(index, 1);
    }
  }

  getInstance(serviceName: string): ServiceInstance {
    const instances = this.services.get(serviceName) || [];
    if (instances.length === 0) {
      throw new Error(`No instances found for service: ${serviceName}`);
    }
    // Simple round-robin load balancing
    const instance = instances.shift();
    instances.push(instance!);
    return instance!;
  }
}

Best Practices

  1. Design for Failure: Implement resilience patterns
  2. Keep Services Independent: Minimize service coupling
  3. Smart Endpoints, Dumb Pipes: Keep communication simple
  4. Decentralize Everything: Avoid single points of failure
  5. Monitor Everything: Implement comprehensive observability

Implementation Steps

  1. Define service boundaries
  2. Choose communication patterns
  3. Implement data management
  4. Add resilience patterns
  5. Set up service discovery
  6. Implement monitoring
  7. Test thoroughly
  8. Deploy and scale

Conclusion

Building microservices requires careful consideration of various patterns and practices. By implementing these patterns appropriately, you can create resilient, scalable, and maintainable distributed systems.

Resources