Microservices Architecture: Patterns, Pitfalls, and Best Practices

C
CodeNex EngineeringSoftware Engineering Experts
August 27, 2025
16 min read
#Microservices#Architecture#Distributed Systems#Design Patterns

Microservices Architecture: Patterns, Pitfalls, and Best Practices

Microservices aren't a silver bullet. This guide helps you decide if microservices are right for your organization and how to implement them successfully.

Should You Use Microservices?

When Microservices Make Sense

Team size >20 engineers: Multiple teams can work independently ✅ Different scaling needs: Some services need more resources ✅ Technology diversity: Use best tool for each job ✅ Frequent deployments: Deploy services independently ✅ Domain complexity: Clear bounded contexts exist

When to Stick with a Monolith

Small team (<10 engineers): Overhead outweighs benefits ❌ Simple domain: No clear service boundaries ❌ Startup/MVP: Speed matters more than scalability ❌ Limited DevOps maturity: Need strong infrastructure skills ❌ Low traffic: Single server handles load easily

Our recommendation: Start with a modular monolith, extract services only when needed.

Microservices Design Patterns

1. Service Boundary Design

Bad: Database-Driven Boundaries

UserService → Users table
OrderService → Orders table
ProductService → Products table

This leads to tight coupling and shared databases.

Good: Domain-Driven Boundaries

User Identity Service (Authentication, Profile)
Order Management Service (Cart, Checkout, Order History)
Product Catalog Service (Search, Inventory, Recommendations)
Payment Service (Processing, Refunds)
Notification Service (Email, SMS, Push)

Each service owns its domain logic and data.

2. Communication Patterns

Synchronous: REST/gRPC

Use for: Real-time queries, user-facing operations

// API Gateway pattern
app.get('/api/order/:id', async (req, res) => {
  // Orchestrate multiple services
  const order = await orderService.getOrder(req.params.id);
  const user = await userService.getUser(order.userId);
  const products = await productService.getProducts(order.productIds);

  res.json({
    order,
    user: { id: user.id, name: user.name },
    products
  });
});

Pros: Simple, immediate response Cons: Tight coupling, cascading failures

Asynchronous: Message Queues

Use for: Background tasks, eventual consistency

// Event-driven pattern
// Order Service publishes event
await messageQueue.publish('order.created', {
  orderId: order.id,
  userId: order.userId,
  amount: order.total
});

// Inventory Service subscribes
messageQueue.subscribe('order.created', async (event) => {
  await inventoryService.reserveItems(event.orderId);
});

// Email Service subscribes
messageQueue.subscribe('order.created', async (event) => {
  await emailService.sendOrderConfirmation(event);
});

Pros: Loose coupling, fault tolerance Cons: Complex debugging, eventual consistency

3. Data Management Patterns

Database per Service

Each service has its own database:

# docker-compose.yml
services:
  user-service:
    image: user-service
    environment:
      DATABASE_URL: postgresql://localhost/users_db

  order-service:
    image: order-service
    environment:
      DATABASE_URL: postgresql://localhost/orders_db

  product-service:
    image: product-service
    environment:
      DATABASE_URL: postgresql://localhost/products_db

Saga Pattern (Distributed Transactions)

Choreography-based:

// Order Service
async function createOrder(orderData) {
  const order = await db.orders.create(orderData);

  // Publish event
  await events.publish('OrderCreated', {
    orderId: order.id,
    userId: orderData.userId,
    items: orderData.items
  });

  return order;
}

// Inventory Service
events.subscribe('OrderCreated', async (event) => {
  try {
    await reserveInventory(event.items);
    await events.publish('InventoryReserved', event);
  } catch (error) {
    await events.publish('InventoryReservationFailed', event);
  }
});

// Payment Service
events.subscribe('InventoryReserved', async (event) => {
  try {
    await chargePayment(event.userId, event.amount);
    await events.publish('PaymentCompleted', event);
  } catch (error) {
    await events.publish('PaymentFailed', event);
  }
});

// Order Service - Handle failures
events.subscribe('PaymentFailed', async (event) => {
  await db.orders.update(event.orderId, { status: 'FAILED' });
  await events.publish('OrderCancelled', event);
});

// Inventory Service - Compensating transaction
events.subscribe('OrderCancelled', async (event) => {
  await releaseInventory(event.items);
});

4. API Gateway Pattern

Centralized entry point for clients:

// api-gateway/routes.js
const express = require('express');
const axios = require('axios');

const app = express();

// Aggregation: Combine multiple services
app.get('/api/dashboard', async (req, res) => {
  const [user, orders, recommendations] = await Promise.all([
    axios.get('http://user-service/users/' + req.userId),
    axios.get('http://order-service/orders?userId=' + req.userId),
    axios.get('http://recommendation-service/recommendations/' + req.userId)
  ]);

  res.json({
    user: user.data,
    recentOrders: orders.data.slice(0, 5),
    recommendations: recommendations.data
  });
});

// Rate limiting
const rateLimit = require('express-rate-limit');
app.use(rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100 // limit each IP to 100 requests per windowMs
}));

// Authentication
app.use(async (req, res, next) => {
  const token = req.headers.authorization;
  const user = await verifyToken(token);
  req.userId = user.id;
  next();
});

5. Circuit Breaker Pattern

Prevent cascading failures:

const CircuitBreaker = require('opossum');

const options = {
  timeout: 3000, // 3 seconds
  errorThresholdPercentage: 50,
  resetTimeout: 30000 // 30 seconds
};

const breaker = new CircuitBreaker(async (userId) => {
  return await axios.get(`http://user-service/users/${userId}`);
}, options);

// Fallback when circuit is open
breaker.fallback((userId) => ({
  id: userId,
  name: 'Unknown User',
  cached: true
}));

// Usage
app.get('/api/user/:id', async (req, res) => {
  try {
    const user = await breaker.fire(req.params.id);
    res.json(user);
  } catch (error) {
    res.status(503).json({ error: 'Service temporarily unavailable' });
  }
});

// Monitor circuit state
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'));

Service Discovery

Client-Side Discovery (Consul)

const consul = require('consul')();

// Service registration
await consul.agent.service.register({
  name: 'order-service',
  address: '10.0.1.5',
  port: 3000,
  check: {
    http: 'http://10.0.1.5:3000/health',
    interval: '10s'
  }
});

// Service discovery
const services = await consul.catalog.service.nodes('order-service');
const instance = services[Math.floor(Math.random() * services.length)];
const response = await axios.get(`http://${instance.ServiceAddress}:${instance.ServicePort}/orders`);

Server-Side Discovery (Kubernetes)

# order-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - port: 80
      targetPort: 3000
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
    spec:
      containers:
      - name: order-service
        image: order-service:latest
        ports:
        - containerPort: 3000

Access via DNS: http://order-service/orders

Observability

Distributed Tracing (Jaeger)

const { initTracer } = require('jaeger-client');

const tracer = initTracer({
  serviceName: 'order-service',
  sampler: {
    type: 'const',
    param: 1
  },
  reporter: {
    agentHost: 'jaeger-agent',
    agentPort: 6832
  }
});

// Trace a request
app.get('/api/order/:id', async (req, res) => {
  const span = tracer.startSpan('get_order');
  span.setTag('order_id', req.params.id);

  try {
    const order = await getOrder(req.params.id, span);
    span.setTag('http.status_code', 200);
    res.json(order);
  } catch (error) {
    span.setTag('error', true);
    span.log({ event: 'error', message: error.message });
    res.status(500).json({ error: error.message });
  } finally {
    span.finish();
  }
});

async function getOrder(orderId, parentSpan) {
  const span = tracer.startSpan('db_query', { childOf: parentSpan });
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [orderId]);
  span.finish();
  return order;
}

Centralized Logging (ELK Stack)

const winston = require('winston');
const { ElasticsearchTransport } = require('winston-elasticsearch');

const logger = winston.createLogger({
  transports: [
    new ElasticsearchTransport({
      level: 'info',
      clientOpts: { node: 'http://elasticsearch:9200' },
      index: 'logs'
    })
  ],
  format: winston.format.json()
});

// Structured logging
logger.info('Order created', {
  orderId: order.id,
  userId: order.userId,
  amount: order.total,
  service: 'order-service',
  traceId: req.traceId
});

Common Pitfalls

1. Distributed Monolith

Symptom: Services can't be deployed independently

Cause: Tight coupling via shared databases or synchronous calls

Fix: Use events, maintain service autonomy

2. Chatty Services

Symptom: Multiple round-trips for single operation

Fix: Use API Gateway aggregation, implement caching

3. Data Consistency Issues

Symptom: Different services show different data

Fix: Implement eventual consistency with Saga pattern

4. Deployment Complexity

Symptom: Deployments take hours, frequent failures

Fix: Automate everything, use Kubernetes, implement CI/CD

Migration Strategy

Strangler Fig Pattern

Gradually replace monolith:

Phase 1: Extract low-risk service (e.g., Notifications)
Phase 2: Extract bounded context (e.g., Product Catalog)
Phase 3: Extract core domain (e.g., Orders)
Phase 4: Decompose remaining monolith

Routing strategy:

# nginx.conf
location /api/notifications {
    proxy_pass http://notification-service;
}

location /api {
    proxy_pass http://monolith;
}

Real-World Case Study

Client: SaaS platform with 100K users

Before:

  • Monolith: 500K lines of code
  • Deploy time: 2 hours
  • Downtime for every deploy
  • Team of 30 engineers stepping on each other

After (12-month migration):

  • 8 microservices
  • Deploy time: 15 minutes per service
  • Zero-downtime deployments
  • Teams work independently

Architecture:

  • API Gateway (Kong)
  • Service mesh (Istio)
  • Message queue (RabbitMQ)
  • Container orchestration (Kubernetes)
  • Distributed tracing (Jaeger)
  • Centralized logging (ELK)

Results:

  • 85% reduction in deployment time
  • 60% reduction in incidents
  • 3x faster feature delivery

Technology Stack Recommendations

API Gateway: Kong, AWS API Gateway Service Mesh: Istio, Linkerd Message Queue: RabbitMQ, Apache Kafka, AWS SQS Service Discovery: Consul, Kubernetes DNS Tracing: Jaeger, AWS X-Ray Logging: ELK Stack, Datadog Orchestration: Kubernetes, AWS ECS

Conclusion

Microservices are a powerful architectural pattern, but they come with significant complexity. Key takeaways:

  • Start with a monolith, extract services when needed
  • Design services around business domains, not databases
  • Invest heavily in observability and automation
  • Embrace eventual consistency
  • Use patterns like Circuit Breaker, Saga, API Gateway

Ready to discuss your architecture? Schedule a consultation or download our microservices assessment checklist.