Skip to content
All essays
ArchitectureMarch 18, 202415 min

Microservices Architecture Patterns and Best Practices

Explore essential patterns and best practices for building scalable microservices architectures that stand the test of time

Ü
Ümit Uz
Mobile & Full Stack Developer

Microservices architecture has revolutionized how we build and deploy applications. By breaking down monolithic applications into smaller, independent services, organizations can achieve greater scalability, flexibility, and development velocity. However, this approach comes with its own set of challenges and complexities. Let's explore the essential patterns and best practices for building successful microservices architectures.

Understanding Microservices Fundamentals

Microservices are an architectural style that structures an application as a collection of loosely coupled, independently deployable services. Each service runs in its own process and communicates through lightweight mechanisms, typically HTTP APIs.

Key Characteristics

Successful microservices share several essential characteristics: single responsibility, decentralized governance, independent deployment, technological diversity, and failure isolation.

typescript
// Monolithic approach (before)
class MonolithicApp {
  handleUserRequest() { /* ... */ }
  handleOrderRequest() { /* ... */ }
  handlePaymentRequest() { /* ... */ }
  handleInventoryRequest() { /* ... */ }
  // All functionality tightly coupled
}

// Microservices approach (after)
class UserService {
  handleRequest() { /* User-specific logic */ }
}

class OrderService {
  handleRequest() { /* Order-specific logic */ }
}

class PaymentService {
  handleRequest() { /* Payment-specific logic */ }
}

class InventoryService {
  handleRequest() { /* Inventory-specific logic */ }
}
// Each service independent and focused

Service Decomposition Patterns

Breaking down a monolith into microservices is an art that requires careful consideration of business domains and team structures.

Domain-Driven Design (DDD)

Use DDD to identify bounded contexts that naturally map to services.

typescript
// Identify bounded contexts
const boundedContexts = {
  UserContext: {
    entities: ['User', 'Profile', 'Preferences'],
    services: ['UserService', 'ProfileService'],
    database: 'users_db'
  },
  OrderContext: {
    entities: ['Order', 'OrderItem', 'Cart'],
    services: ['OrderService', 'CartService'],
    database: 'orders_db'
  },
  CatalogContext: {
    entities: ['Product', 'Category', 'Inventory'],
    services: ['CatalogService', 'InventoryService'],
    database: 'catalog_db'
  }
};

// Each bounded context becomes a microservice

Strangler Fig Pattern

Gradually replace monolithic functionality with microservices.

typescript
// Strangler Fig implementation
class APIGateway {
  private monolith: MonolithHandler;
  private services: Map<string, ServiceHandler>;

  async handleRequest(request: Request) {
    const route = this.extractRoute(request);

    // Check if route has been migrated
    if (this.services.has(route)) {
      return this.services.get(route).handle(request);
    }

    // Default to monolith
    return this.monolith.handle(request);
  }

  migrateRoute(route: string, service: ServiceHandler) {
    this.services.set(route, service);
  }
}

Database per Service

Each service should own its database schema.

typescript
// User Service Database
interface UserDatabase {
  users: {
    id: string;
    email: string;
    name: string;
    createdAt: Date;
  };
  profiles: {
    userId: string;
    bio: string;
    avatar: string;
  };
}

// Order Service Database
interface OrderDatabase {
  orders: {
    id: string;
    userId: string;
    items: OrderItem[];
    status: string;
    total: number;
  };
  payments: {
    orderId: string;
    amount: number;
    status: string;
  };
}

// Each service has its own database schema

Communication Patterns

Services need to communicate effectively while maintaining loose coupling.

Synchronous Communication

REST or GraphQL for direct service-to-service calls.

typescript
class OrderService {
  constructor(
    private inventoryClient: InventoryClient,
    private paymentClient: PaymentClient,
    private notificationClient: NotificationClient
  ) {}

  async createOrder(orderData: OrderData): Promise<Order> {
    // Check inventory
    const inventoryAvailable = await this.inventoryClient.checkAvailability(
      orderData.items
    );
    if (!inventoryAvailable) {
      throw new Error('Items not available');
    }

    // Create order
    const order = await this.orderRepository.create(orderData);

    // Process payment
    const payment = await this.paymentClient.processPayment({
      orderId: order.id,
      amount: order.total
    });

    // Update inventory
    await this.inventoryClient.reserveItems(orderData.items);

    // Send notification
    await this.notificationClient.sendOrderConfirmation(order.id);

    return order;
  }
}

Asynchronous Communication

Event-driven architecture for decoupled services.

typescript
// Event Bus Implementation
class EventBus {
  constructor(private messageBroker: MessageBroker) {}

  async publish(event: DomainEvent) {
    await this.messageBroker.publish(
      event.type,
      JSON.stringify(event.payload)
    );
  }

  subscribe(eventType: string, handler: EventHandler) {
    this.messageBroker.subscribe(eventType, async (message) => {
      const event = JSON.parse(message);
      await handler.handle(event);
    });
  }
}

// Service that publishes events
class OrderService {
  constructor(private eventBus: EventBus) {}

  async createOrder(orderData: OrderData): Promise<Order> {
    const order = await this.orderRepository.create(orderData);

    // Publish event instead of calling other services directly
    await this.eventBus.publish({
      type: 'OrderCreated',
      payload: {
        orderId: order.id,
        userId: order.userId,
        items: order.items,
        total: order.total
      }
    });

    return order;
  }
}

// Service that subscribes to events
class InventoryService {
  constructor(private eventBus: EventBus) {
    this.eventBus.subscribe('OrderCreated', this.handleOrderCreated);
  }

  private async handleOrderCreated(event: OrderCreatedEvent) {
    await this.reserveItems(event.payload.items);
  }
}

API Gateway Pattern

An API Gateway provides a single entry point for clients and handles cross-cutting concerns.

typescript
class APIGateway {
  private services: Map<string, ServiceClient>;
  private loadBalancer: LoadBalancer;
  private authenticator: Authenticator;
  private rateLimiter: RateLimiter;

  constructor() {
    this.services = new Map();
    this.services.set('users', new UserServiceClient());
    this.services.set('orders', new OrderServiceClient());
    this.services.set('products', new ProductServiceClient());
  }

  async handleRequest(request: Request): Promise<Response> {
    // Authentication
    const user = await this.authenticator.authenticate(request);
    if (!user) {
      return { status: 401, body: 'Unauthorized' };
    }

    // Rate limiting
    const allowed = await this.rateLimiter.checkLimit(user.id);
    if (!allowed) {
      return { status: 429, body: 'Too many requests' };
    }

    // Route to appropriate service
    const serviceName = this.extractService(request);
    const service = this.services.get(serviceName);

    if (!service) {
      return { status: 404, body: 'Service not found' };
    }

    // Load balancing
    const instance = await this.loadBalancer.selectInstance(service);
    return await instance.forward(request);
  }
}

Service Discovery

Services need to find and communicate with each other dynamically.

Client-Side Discovery

Clients query a service registry to find service instances.

typescript
class ServiceRegistry {
  private services: Map<string, ServiceInstance[]> = new Map();

  register(serviceName: string, instance: ServiceInstance) {
    if (!this.services.has(serviceName)) {
      this.services.set(serviceName, []);
    }
    this.services.get(serviceName)!.push(instance);
  }

  deregister(serviceName: string, instanceId: string) {
    const instances = this.services.get(serviceName);
    if (instances) {
      const filtered = instances.filter(i => i.id !== instanceId);
      this.services.set(serviceName, filtered);
    }
  }

  discover(serviceName: string): ServiceInstance[] {
    return this.services.get(serviceName) || [];
  }
}

class ServiceClient {
  constructor(
    private registry: ServiceRegistry,
    private loadBalancer: LoadBalancer
  ) {}

  async call(serviceName: string, request: Request): Promise<Response> {
    const instances = await this.registry.discover(serviceName);

    if (instances.length === 0) {
      throw new Error(`No instances found for ${serviceName}`);
    }

    const instance = this.loadBalancer.select(instances);
    return await this.makeRequest(instance, request);
  }
}

Server-Side Discovery

Clients route through a load balancer that queries the service registry.

typescript
class LoadBalancer {
  constructor(private registry: ServiceRegistry) {}

  async route(serviceName: string, request: Request): Promise<Response> {
    const instances = await this.registry.discover(serviceName);

    if (instances.length === 0) {
      return { status: 503, body: 'Service unavailable' };
    }

    const instance = this.selectInstance(instances);
    return await this.forwardRequest(instance, request);
  }

  private selectInstance(instances: ServiceInstance[]): ServiceInstance {
    // Round-robin, least connections, or IP hash
    return instances[Math.floor(Math.random() * instances.length)];
  }
}

Data Consistency Patterns

Managing data consistency across services is challenging.

Saga Pattern

Coordinate transactions across multiple services.

typescript
// Choreography-based Saga
class OrderSaga {
  async execute(orderData: OrderData): Promise<Order> {
    const saga = new SagaLog();

    try {
      // Step 1: Create order
      const order = await this.createOrder(orderData);
      saga.addStep('CreateOrder', order.id);

      // Step 2: Reserve inventory
      const reservation = await this.reserveInventory(order.items);
      saga.addStep('ReserveInventory', reservation.id);

      // Step 3: Process payment
      const payment = await this.processPayment(order);
      saga.addStep('ProcessPayment', payment.id);

      // Step 4: Confirm inventory reservation
      await this.confirmInventoryReservation(reservation.id);
      saga.addStep('ConfirmInventory', reservation.id);

      return order;
    } catch (error) {
      // Execute compensating transactions
      await this.rollback(saga);
      throw error;
    }
  }

  private async rollback(saga: SagaLog) {
    const steps = saga.getCompletedSteps();
    for (const step of steps.reverse()) {
      switch (step.action) {
        case 'ConfirmInventory':
          await this.cancelInventoryReservation(step.entityId);
          break;
        case 'ProcessPayment':
          await this.refundPayment(step.entityId);
          break;
        case 'ReserveInventory':
          await this.releaseInventory(step.entityId);
          break;
        case 'CreateOrder':
          await this.cancelOrder(step.entityId);
          break;
      }
    }
  }
}

// Orchestration-based Saga
class OrderOrchestrator {
  async execute(orderData: OrderData): Promise<Order> {
    const saga = new SagaDefinition();

    saga
      .addStep('createOrder', this.orderService.createOrder(orderData))
      .addStep('reserveInventory', this.inventoryService.reserve(orderData.items))
      .addStep('processPayment', this.paymentService.charge(orderData.total))
      .addStep('confirmReservation', this.inventoryService.confirm())
      .onRollback('cancelReservation', this.inventoryService.cancel())
      .onRollback('refundPayment', this.paymentService.refund())
      .onRollback('cancelOrder', this.orderService.cancel());

    return await saga.execute();
  }
}

Event Sourcing and CQRS

Store events as the source of truth and use separate models for reads.

typescript
// Event Sourcing
class OrderEventStore {
  async saveEvents(aggregateId: string, events: DomainEvent[]) {
    await this.db.collection('events').insertMany(
      events.map(event => ({
        aggregateId,
        type: event.type,
        payload: event.payload,
        timestamp: new Date(),
        version: event.version
      }))
    );
  }

  async getEvents(aggregateId: string): Promise<DomainEvent[]> {
    return await this.db.collection('events')
      .find({ aggregateId })
      .sort({ timestamp: 1 })
      .toArray();
  }
}

// CQRS - Separate read model
class OrderReadModel {
  async rebuildFromEvents(events: DomainEvent[]) {
    for (const event of events) {
      switch (event.type) {
        case 'OrderCreated':
          await this.createOrderView(event.payload);
          break;
        case 'OrderItemAdded':
          await this.addItemToOrderView(event.payload);
          break;
        case 'OrderStatusChanged':
          await this.updateOrderStatus(event.payload);
          break;
      }
    }
  }

  async getOrderView(orderId: string): Promise<OrderView> {
    return await this.db.collection('order_views')
      .findOne({ orderId });
  }
}

Resilience Patterns

Building resilient systems that can handle failures gracefully.

Circuit Breaker

Prevent cascading failures by failing fast when a service is down.

typescript
class CircuitBreaker {
  private state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
  private failureCount: number = 0;
  private lastFailureTime: number = 0;
  private successCount: number = 0;

  constructor(
    private threshold: number = 5,
    private timeout: number = 60000,
    private halfOpenMaxCalls: number = 3
  ) {}

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    if (this.state === 'OPEN') {
      if (this.shouldAttemptReset()) {
        this.state = 'HALF_OPEN';
        this.successCount = 0;
      } 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() {
    if (this.state === 'HALF_OPEN') {
      this.successCount++;
      if (this.successCount >= this.halfOpenMaxCalls) {
        this.state = 'CLOSED';
        this.failureCount = 0;
      }
    } else {
      this.failureCount = 0;
    }
  }

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

    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
    }
  }

  private shouldAttemptReset(): boolean {
    return Date.now() - this.lastFailureTime > this.timeout;
  }
}

Retry Pattern

Automatically retry failed operations with exponential backoff.

typescript
class RetryPolicy {
  async execute<T>(
    operation: () => Promise<T>,
    options: {
      maxRetries: number;
      initialDelay: number;
      maxDelay: number;
      backoffMultiplier: number;
    }
  ): Promise<T> {
    let delay = options.initialDelay;
    let lastError: Error;

    for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
      try {
        return await operation();
      } catch (error) {
        lastError = error;

        if (attempt === options.maxRetries) {
          throw lastError;
        }

        await this.sleep(delay);
        delay = Math.min(
          delay * options.backoffMultiplier,
          options.maxDelay
        );
      }
    }

    throw lastError!;
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Bulkhead Pattern

Isolate resources to prevent cascading failures.

typescript
class Bulkhead {
  private semaphore: Semaphore;

  constructor(
    private maxConcurrent: number,
    private maxQueueSize: number
  ) {
    this.semaphore = new Semaphore(maxConcurrent);
  }

  async execute<T>(operation: () => Promise<T>): Promise<T> {
    const acquired = await this.semaphore.tryAcquire();

    if (!acquired) {
      throw new Error('Bulkhead limit reached');
    }

    try {
      return await operation();
    } finally {
      this.semaphore.release();
    }
  }
}

class Semaphore {
  private available: number;
  private waitQueue: Array<(value: boolean) => void> = [];

  constructor(count: number) {
    this.available = count;
  }

  async tryAcquire(): Promise<boolean> {
    if (this.available > 0) {
      this.available--;
      return true;
    }

    return new Promise<boolean>((resolve) => {
      this.waitQueue.push(resolve);
    });
  }

  release() {
    if (this.waitQueue.length > 0) {
      const next = this.waitQueue.shift()!;
      next(true);
    } else {
      this.available++;
    }
  }
}

Deployment Strategies

Strategies for deploying microservices with minimal downtime.

Blue-Green Deployment

Run two identical production environments.

typescript
class BlueGreenDeployment {
  async deploy(serviceName: string, newVersion: string) {
    const currentEnv = await this.getCurrentEnvironment(serviceName);
    const targetEnv = currentEnv === 'blue' ? 'green' : 'blue';

    // Deploy to inactive environment
    await this.deployToEnvironment(serviceName, targetEnv, newVersion);

    // Run smoke tests
    const healthy = await this.runSmokeTests(targetEnv);
    if (!healthy) {
      await this.rollback(serviceName, targetEnv);
      throw new Error('Smoke tests failed');
    }

    // Switch traffic
    await this.switchTraffic(serviceName, targetEnv);

    // Monitor for issues
    const stable = await this.monitor(serviceName, targetEnv);
    if (!stable) {
      await this.switchTraffic(serviceName, currentEnv);
      await this.rollback(serviceName, targetEnv);
    }
  }
}

Canary Deployment

Gradually roll out new versions to a subset of users.

typescript
class CanaryDeployment {
  async deploy(serviceName: string, newVersion: string) {
    let canaryPercentage = 5; // Start with 5%

    while (canaryPercentage <= 100) {
      // Route percentage of traffic to new version
      await this.updateTrafficDistribution(serviceName, {
        stable: 100 - canaryPercentage,
        canary: canaryPercentage
      });

      // Monitor metrics
      const metrics = await this.collectMetrics(serviceName, 'canary');

      if (metrics.errorRate > this.thresholds.maxErrorRate) {
        await this.rollbackCanary(serviceName);
        throw new Error('Canary deployment failed');
      }

      if (metrics.latency > this.thresholds.maxLatency) {
        await this.rollbackCanary(serviceName);
        throw new Error('Canary latency too high');
      }

      // Gradually increase canary percentage
      canaryPercentage = Math.min(100, canaryPercentage + 10);
      await this.sleep(60000); // Wait before next increase
    }

    // Full rollout complete
    await this.markVersionAsStable(serviceName, newVersion);
  }
}

Monitoring and Observability

Comprehensive monitoring is essential for distributed systems.

Distributed Tracing

Trace requests as they flow through multiple services.

typescript
class DistributedTracer {
  startSpan(name: string, parentSpan?: Span): Span {
    const span = {
      id: this.generateSpanId(),
      traceId: parentSpan?.traceId || this.generateTraceId(),
      parentId: parentSpan?.id,
      name,
      startTime: Date.now(),
      tags: {},
      logs: []
    };

    return span;
  }

  finishSpan(span: Span) {
    span.endTime = Date.now();
    span.duration = span.endTime - span.startTime;

    this.reportSpan(span);
  }

  private reportSpan(span: Span) {
    // Send to tracing backend (Jaeger, Zipkin, etc.)
    this.tracingBackend.send({
      traceId: span.traceId,
      id: span.id,
      parentId: span.parentId,
      name: span.name,
      duration: span.duration,
      tags: span.tags,
      logs: span.logs
    });
  }
}

Conclusion

Microservices architecture offers significant benefits but requires careful planning and implementation. Focus on service boundaries, communication patterns, data consistency, resilience, and observability. Start with a solid foundation, iterate based on real-world usage, and continuously refine your approach.

Remember that microservices aren't a silver bullet—they introduce complexity and operational overhead. Choose this architecture when the benefits outweigh the costs for your specific use case.

Related essays

Next essay
Implementing Effective API Rate Limiting Strategies