Microservices Architecture: Best Practices and Common Pitfalls

Ezequiel Godoy
Microservices Architecture DevOps Distributed Systems

Microservices architecture has become the de facto standard for building large-scale applications. However, the journey from monolith to microservices is fraught with challenges. This guide covers essential best practices and common pitfalls to avoid.

Understanding Microservices

Microservices architecture breaks down applications into small, autonomous services that:

  • Own their data and business logic
  • Communicate through well-defined APIs
  • Can be deployed independently
  • Are organized around business capabilities

Best Practices

1. Design for Failure

In distributed systems, failure is inevitable. Design your services to be resilient:

// Implement circuit breaker pattern
class CircuitBreaker {
  constructor(threshold = 5, timeout = 60000) {
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.state = 'CLOSED';
    this.nextAttempt = Date.now();
  }

  async call(fn) {
    if (this.state === 'OPEN') {
      if (Date.now() > this.nextAttempt) {
        this.state = 'HALF_OPEN';
      } else {
        throw new Error('Circuit breaker is OPEN');
      }
    }

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

  onSuccess() {
    this.failureCount = 0;
    this.state = 'CLOSED';
  }

  onFailure() {
    this.failureCount++;
    if (this.failureCount >= this.threshold) {
      this.state = 'OPEN';
      this.nextAttempt = Date.now() + this.timeout;
    }
  }
}

2. Implement Proper Service Boundaries

Services should be:

  • Highly cohesive: Related functionality stays together
  • Loosely coupled: Minimal dependencies between services
  • Business-oriented: Aligned with business capabilities

3. API Gateway Pattern

Use an API gateway to:

  • Provide a single entry point for clients
  • Handle cross-cutting concerns (auth, rate limiting, logging)
  • Aggregate responses from multiple services
# Example Kong API Gateway configuration
services:
  - name: user-service
    url: http://user-service:3000
    routes:
      - name: user-routes
        paths:
          - /api/users
        methods:
          - GET
          - POST
    plugins:
      - name: rate-limiting
        config:
          minute: 100
      - name: jwt

4. Distributed Tracing

Implement distributed tracing to understand request flow:

// OpenTelemetry example
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('user-service');

async function createUser(userData) {
  const span = tracer.startSpan('createUser');
  
  try {
    span.setAttributes({
      'user.email': userData.email,
      'user.role': userData.role
    });
    
    const user = await db.users.create(userData);
    
    span.addEvent('user_created', {
      userId: user.id
    });
    
    return user;
  } catch (error) {
    span.recordException(error);
    throw error;
  } finally {
    span.end();
  }
}

5. Event-Driven Communication

Use events for loose coupling between services:

// Publish domain events
class UserService {
  async createUser(data) {
    const user = await this.repository.create(data);
    
    await this.eventBus.publish('user.created', {
      userId: user.id,
      email: user.email,
      timestamp: new Date()
    });
    
    return user;
  }
}

// Subscribe to events in other services
class EmailService {
  constructor(eventBus) {
    eventBus.subscribe('user.created', this.sendWelcomeEmail);
  }
  
  async sendWelcomeEmail(event) {
    await this.mailer.send({
      to: event.email,
      template: 'welcome',
      data: { userId: event.userId }
    });
  }
}

Common Pitfalls to Avoid

1. Distributed Monolith

Avoid creating a distributed monolith where services are tightly coupled:

  • ❌ Synchronous communication for everything
  • ❌ Shared databases between services
  • ❌ Cascading failures due to tight coupling

2. Premature Optimization

Start with a modular monolith and extract services when:

  • Team size justifies independent development
  • Scaling requirements differ significantly
  • Technology requirements diverge

3. Ignoring Data Consistency

Implement patterns for distributed data management:

  • Saga Pattern for distributed transactions
  • Event Sourcing for audit trails
  • CQRS for read/write separation

4. Poor Service Boundaries

Signs of poor boundaries:

  • Chatty communication between services
  • Frequent coordinated deployments
  • Circular dependencies

Deployment and Operations

Container Orchestration

Use Kubernetes for container orchestration:

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:1.0.0
        ports:
        - containerPort: 3000
        env:
        - name: DB_CONNECTION
          valueFrom:
            secretKeyRef:
              name: db-secret
              key: connection-string
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10

Monitoring and Observability

Implement the three pillars of observability:

  1. Metrics: Track system performance
  2. Logs: Structured logging with correlation IDs
  3. Traces: Distributed request tracing

Conclusion

Microservices architecture offers significant benefits but requires careful planning and implementation. Focus on:

  • Clear service boundaries
  • Resilient communication patterns
  • Comprehensive observability
  • Gradual migration strategies

Remember: microservices are not a silver bullet. They solve specific problems at the cost of increased operational complexity. Choose this architecture when the benefits outweigh the costs for your specific use case.