Microservices Architecture — Building Scalable Distributed Systems

Microservices Architecture — Building Scalable Distributed Systems

11/3/2025 Architecture By Tech Writers
MicroservicesDistributed SystemsArchitectureDevOpsKubernetesAPI DesignScalabilityCloud Native

Introduction: The Rise of Distributed Systems

Microservices architecture represents a fundamental shift from monolithic applications to distributed systems composed of small, independent services. Each microservice handles a specific business capability and communicates with others through well-defined APIs. This approach enables organizations to scale independently, deploy faster, and organize teams around business domains.

This comprehensive guide covers microservices principles, architecture patterns, communication strategies, and the operational challenges you need to understand before adopting this architecture.

Table of Contents

Monolith vs Microservices

Understanding the differences between monolithic and microservices architectures helps you make informed decisions about which approach suits your project and organization.

Monolithic Architecture

A monolith is a single, unified codebase deployed as one unit. All business logic, database access, and user interfaces are tightly coupled.

Characteristics:

  • Single codebase and deployment unit
  • Shared database for all features
  • Direct function calls between modules
  • Simpler initial development
  • Tightly coupled components

Example Monolith Structure:

user-management/
├── routes/
├── controllers/
├── services/
├── models/
└── database.js

product-catalog/
├── routes/
├── controllers/
├── services/
├── models/
└── database.js

order-processing/
├── routes/
├── controllers/
├── services/
└── models/

Microservices Architecture

Microservices decompose the application into multiple independent services, each with its own codebase, database, and deployment unit.

Characteristics:

  • Multiple codebases and independent deployments
  • Separate database per service
  • Communication via REST APIs or message queues
  • Higher operational complexity
  • Loosely coupled, independently scalable services

Example Microservices Structure:

services/
├── user-service/
│   ├── src/
│   ├── database/
│   └── Dockerfile
├── product-service/
│   ├── src/
│   ├── database/
│   └── Dockerfile
├── order-service/
│   ├── src/
│   ├── database/
│   └── Dockerfile
└── payment-service/
    ├── src/
    ├── database/
    └── Dockerfile

Comparison Table

AspectMonolithMicroservices
DeploymentSingle unitIndependent services
ScalingScale entire applicationScale individual services
TechnologySingle tech stackDifferent stacks per service
DevelopmentFaster initiallyMore upfront investment
Failure ImpactOne bug can crash entire appFailures isolated to service
Data ManagementSingle databaseMultiple databases
ComplexitySimple to understandComplex distributed systems
Team OrganizationShared codebase teamsDomain-oriented teams

Core Microservices Principles

Successful microservices implementation relies on several core principles that guide architecture and design decisions.

Single Responsibility Principle

Each microservice should handle one business capability. This principle ensures services remain focused and maintainable.

❌ Wrong: Multi-responsibility service
UserService {
  createUser()
  updateUser()
  deleteUser()
  processPayment()  // Not related to users
  sendEmail()       // Not related to users
  generateReport()  // Not related to users
}

✅ Correct: Single-responsibility services
UserService {
  createUser()
  updateUser()
  deleteUser()
}

PaymentService {
  processPayment()
  refundPayment()
}

NotificationService {
  sendEmail()
  sendSMS()
  sendPushNotification()
}

Bounded Contexts from Domain-Driven Design

Organize services around business domains, not technical layers. Each service owns its data model and domain logic.

// Example: E-commerce Domain
const domains = {
  userManagement: {
    responsibility: 'User accounts, profiles, authentication',
    service: 'UserService',
    dataOwned: ['users', 'profiles', 'preferences']
  },
  productCatalog: {
    responsibility: 'Product information, inventory',
    service: 'ProductService',
    dataOwned: ['products', 'inventory', 'categories']
  },
  orderProcessing: {
    responsibility: 'Orders, order fulfillment',
    service: 'OrderService',
    dataOwned: ['orders', 'order_items', 'shipments']
  },
  payments: {
    responsibility: 'Payment processing, transactions',
    service: 'PaymentService',
    dataOwned: ['transactions', 'payment_methods']
  }
};

Loose Coupling and High Cohesion

Services should minimize dependencies on other services, enabling independent development and deployment.

❌ Tightly Coupled
UserService -> ProductService -> OrderService -> PaymentService
(Sequential, dependent deployments, change ripple effects)

✅ Loosely Coupled
UserService --[async event]--> EventBus <--[async event]-- ProductService
              --[REST API]-->  OrderService <--[REST API]--> PaymentService
(Independent, parallel deployments, isolated changes)

Service Communication Patterns

Services must communicate efficiently and reliably. Two main patterns exist: synchronous and asynchronous.

Synchronous Communication (REST/gRPC)

Direct API calls where the client waits for a response. Simpler to implement but creates temporal coupling.

REST API Communication:

// OrderService calling UserService
const UserService = require('./user-service-client');

async function createOrder(userId, items) {
  try {
    // Synchronous call - blocks until response
    const user = await UserService.getUser(userId);
    
    if (!user) {
      throw new Error('User not found');
    }
    
    const order = {
      userId,
      items,
      total: calculateTotal(items),
      createdAt: new Date()
    };
    
    return await saveOrder(order);
  } catch (error) {
    console.error('Order creation failed:', error);
    throw error;
  }
}

// ProductService calling InventoryService
async function reserveInventory(items) {
  const reservations = await Promise.all(
    items.map(item => 
      InventoryService.reserve(item.productId, item.quantity)
    )
  );
  
  return reservations;
}

gRPC Communication:

// payment.proto
service PaymentService {
  rpc ProcessPayment (PaymentRequest) returns (PaymentResponse);
}

message PaymentRequest {
  string orderId = 1;
  double amount = 2;
  string currency = 3;
}

message PaymentResponse {
  string transactionId = 1;
  string status = 2;
}

Pros:

  • Simple to implement and understand
  • Synchronous, request-response model
  • Easy debugging and tracing

Cons:

  • Temporal coupling between services
  • Cascading failures
  • Network latency impacts
  • Difficult to scale under high load

Asynchronous Communication (Message Queues)

Services communicate through message brokers, decoupling producer and consumer in time and space.

Event-Driven Architecture:

// OrderService publishes event
class OrderService {
  async createOrder(orderData) {
    const order = await saveOrder(orderData);
    
    // Publish event to message broker
    await messageQueue.publish('order.created', {
      orderId: order.id,
      userId: order.userId,
      items: order.items,
      timestamp: new Date()
    });
    
    return order;
  }
}

// Multiple services subscribe to events independently
class NotificationService {
  subscribe() {
    messageQueue.on('order.created', async (event) => {
      await sendOrderConfirmationEmail(event.userId);
    });
  }
}

class InventoryService {
  subscribe() {
    messageQueue.on('order.created', async (event) => {
      await reserveInventory(event.items);
    });
  }
}

class AnalyticsService {
  subscribe() {
    messageQueue.on('order.created', async (event) => {
      await logOrderMetrics(event);
    });
  }
}

RabbitMQ Example:

const amqp = require('amqplib');

async function publishEvent(exchange, routingKey, message) {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();
  
  await channel.assertExchange(exchange, 'topic', { durable: true });
  
  channel.publish(
    exchange,
    routingKey,
    Buffer.from(JSON.stringify(message)),
    { persistent: true }
  );
  
  await channel.close();
  await connection.close();
}

async function subscribeToEvent(exchange, queue, routingKey, handler) {
  const connection = await amqp.connect('amqp://localhost');
  const channel = await connection.createChannel();
  
  await channel.assertExchange(exchange, 'topic', { durable: true });
  await channel.assertQueue(queue, { durable: true });
  await channel.bindQueue(queue, exchange, routingKey);
  
  channel.consume(queue, async (msg) => {
    if (msg) {
      const content = JSON.parse(msg.content.toString());
      await handler(content);
      channel.ack(msg);
    }
  });
}

// Publish order created event
await publishEvent('orders', 'order.created', {
  orderId: '12345',
  userId: 'user-1',
  amount: 99.99
});

// Subscribe to order events
await subscribeToEvent('orders', 'email-queue', 'order.*', async (event) => {
  console.log('Order event received:', event);
  await sendEmail(event);
});

Pros:

  • Loose coupling in time
  • Eventual consistency
  • Better scalability
  • Services can fail independently

Cons:

  • Complexity of eventual consistency
  • Harder to debug
  • Message ordering challenges
  • Operational complexity

API Gateway Pattern

The API Gateway serves as a single entry point for all client requests, handling routing, authentication, rate limiting, and protocol translation.

Gateway Responsibilities

// API Gateway Implementation
const express = require('express');
const app = express();

// Middleware pipeline
app.use(authenticationMiddleware);
app.use(rateLimitMiddleware);
app.use(loggingMiddleware);

// Route to services
app.get('/api/users/:id', async (req, res) => {
  const user = await userServiceClient.getUser(req.params.id);
  res.json(user);
});

app.get('/api/products', async (req, res) => {
  const products = await productServiceClient.listProducts(req.query);
  res.json(products);
});

app.post('/api/orders', async (req, res) => {
  const order = await orderServiceClient.createOrder(req.body);
  res.json(order);
});

// Authentication middleware
async function authenticationMiddleware(req, res, next) {
  const token = req.headers.authorization?.split(' ')[1];
  
  try {
    const user = await verifyToken(token);
    req.user = user;
    next();
  } catch (error) {
    res.status(401).json({ error: 'Unauthorized' });
  }
}

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

// Logging middleware
app.use((req, res, next) => {
  console.log(`${req.method} ${req.path}`);
  next();
});

Kong API Gateway Example

# kong-deployment.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: kong-config
data:
  kong.conf: |
    database = postgres
    proxy_listen = 0.0.0.0:8000
    admin_listen = 0.0.0.0:8001

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: kong
spec:
  replicas: 2
  selector:
    matchLabels:
      app: kong
  template:
    metadata:
      labels:
        app: kong
    spec:
      containers:
      - name: kong
        image: kong:latest
        ports:
        - containerPort: 8000
        - containerPort: 8001
        env:
        - name: KONG_DATABASE
          value: postgres
        - name: KONG_PG_HOST
          value: postgres

Service Discovery

In dynamic environments like Kubernetes, services appear and disappear. Service discovery automatically registers and discovers services.

Service Discovery Patterns

Client-Side Discovery:

// Client discovers service locations
const ServiceRegistry = require('consul');
const consul = new ServiceRegistry();

async function callUserService(userId) {
  // Discover service location
  const services = await consul.health.service({
    service: 'user-service',
    passing: true
  });
  
  const service = services[Math.floor(Math.random() * services.length)];
  const url = `http://${service.Service.Address}:${service.Service.Port}`;
  
  // Call service
  const response = await fetch(`${url}/api/users/${userId}`);
  return response.json();
}

Server-Side Discovery (Kubernetes):

# Service DNS discovery in Kubernetes
apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-service
  ports:
  - port: 3000
    targetPort: 3000
// Services automatically discoverable by DNS
const userServiceUrl = 'http://user-service:3000';
const response = await fetch(`${userServiceUrl}/api/users/${userId}`);

Data Management in Microservices

Unlike monoliths with single databases, microservices require different data management strategies.

Database per Service Pattern

Each service owns its database, ensuring loose coupling and independent scaling.

UserService
├── users table
├── profiles table
└── PostgreSQL DB

ProductService
├── products table
├── inventory table
└── PostgreSQL DB

OrderService
├── orders table
├── order_items table
└── MongoDB

PaymentService
├── transactions table
├── payment_methods table
└── PostgreSQL DB

Implementation:

// Each service connects to its own database
const userDb = new Database({
  host: 'user-db.default.svc.cluster.local',
  database: 'users',
  user: 'user-service'
});

const productDb = new Database({
  host: 'product-db.default.svc.cluster.local',
  database: 'products',
  user: 'product-service'
});

// No cross-database joins
async function getOrderDetails(orderId) {
  // Get order from order service
  const order = await orderDb.query(
    'SELECT * FROM orders WHERE id = ?', 
    [orderId]
  );
  
  // Call product service API to get product details
  const product = await fetch(
    `http://product-service:3000/api/products/${order.productId}`
  ).then(r => r.json());
  
  return { order, product };
}

Distributed Transactions and Saga Pattern

Implement transactions across services using the Saga pattern, which breaks distributed transactions into local transactions.

// Saga pattern for order processing
class OrderSaga {
  async executeOrderSaga(orderData) {
    const sagaId = generateId();
    
    try {
      // Step 1: Create order
      const order = await this.orderService.createOrder(orderData);
      
      // Step 2: Reserve inventory
      await this.inventoryService.reserveInventory(order.items);
      
      // Step 3: Process payment
      const payment = await this.paymentService.processPayment(
        order.userId,
        order.total
      );
      
      // Step 4: Update order status
      await this.orderService.updateOrderStatus(order.id, 'CONFIRMED');
      
      return order;
      
    } catch (error) {
      // Compensating transaction - undo changes
      await this.compensate(order);
      throw error;
    }
  }
  
  async compensate(order) {
    // Reverse steps in opposite order
    try {
      // Release inventory
      await this.inventoryService.releaseInventory(order.items);
      
      // Refund payment
      await this.paymentService.refund(order.paymentId);
      
      // Cancel order
      await this.orderService.cancelOrder(order.id);
    } catch (error) {
      console.error('Compensation failed:', error);
      // Log for manual intervention
      await this.logCompensationFailure(order, error);
    }
  }
}

Resilience Patterns

Building resilient microservices means handling failures gracefully and preventing cascading failures.

Circuit Breaker Pattern

Prevents cascading failures by stopping calls to failing services:

class CircuitBreaker {
  constructor(service, threshold = 5, timeout = 60000) {
    this.service = service;
    this.failureCount = 0;
    this.threshold = threshold;
    this.timeout = timeout;
    this.state = 'CLOSED'; // CLOSED, OPEN, HALF_OPEN
    this.nextAttempt = Date.now();
  }
  
  async call(method, ...args) {
    if (this.state === 'OPEN') {
      if (Date.now() < this.nextAttempt) {
        throw new Error('Circuit breaker is OPEN');
      }
      this.state = 'HALF_OPEN';
    }
    
    try {
      const result = await this.service[method](...args);
      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;
    }
  }
}

// Usage
const breaker = new CircuitBreaker(userService);

try {
  const user = await breaker.call('getUser', userId);
} catch (error) {
  console.error('Service unavailable:', error);
}

Retry Pattern

Retry failed requests with exponential backoff:

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 100) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries - 1) throw error;
      
      const delay = baseDelay * Math.pow(2, attempt);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

// Usage
const user = await retryWithBackoff(
  () => userService.getUser(userId),
  3,
  100
);

Timeout Pattern

Prevent requests from hanging indefinitely:

async function withTimeout(promise, timeoutMs = 5000) {
  return Promise.race([
    promise,
    new Promise((_, reject) => 
      setTimeout(() => reject(new Error('Timeout')), timeoutMs)
    )
  ]);
}

// Usage
try {
  const user = await withTimeout(
    userService.getUser(userId),
    5000
  );
} catch (error) {
  if (error.message === 'Timeout') {
    console.error('Request timeout');
  }
}

Distributed Tracing

Track requests across multiple services to understand behavior and debug issues:

// Using OpenTelemetry
const { NodeTracerProvider } = require('@opentelemetry/node');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { SimpleSpanProcessor } = require('@opentelemetry/tracing');

const provider = new NodeTracerProvider();
const exporter = new JaegerExporter({
  endpoint: 'http://localhost:14268/api/traces'
});

provider.addSpanProcessor(new SimpleSpanProcessor(exporter));

const tracer = provider.getTracer('order-service');

async function createOrder(orderData) {
  const span = tracer.startSpan('createOrder');
  
  try {
    const order = await saveOrder(orderData);
    span.addEvent('order_created', { orderId: order.id });
    
    // Child span for inventory reservation
    const inventorySpan = tracer.startSpan('reserveInventory', { 
      parent: span 
    });
    await reserveInventory(order.items);
    inventorySpan.end();
    
    return order;
  } finally {
    span.end();
  }
}

Deployment and Orchestration

Microservices require sophisticated orchestration platforms like Kubernetes:

# Kubernetes deployment for UserService
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: myregistry/user-service:1.0.0
        ports:
        - containerPort: 3000
        env:
        - name: DB_HOST
          value: postgres
        - name: DB_PORT
          value: "5432"
        resources:
          requests:
            memory: "256Mi"
            cpu: "250m"
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 10
          periodSeconds: 10

---
apiVersion: v1
kind: Service
metadata:
  name: user-service
spec:
  selector:
    app: user-service
  ports:
  - port: 3000
    targetPort: 3000
  type: ClusterIP

Monitoring and Observability

Comprehensive monitoring is essential for managing complex microservices:

// Prometheus metrics
const prometheus = require('prom-client');

const requestCounter = new prometheus.Counter({
  name: 'http_requests_total',
  help: 'Total HTTP requests',
  labelNames: ['method', 'route', 'status']
});

const responseTime = new prometheus.Histogram({
  name: 'http_request_duration_ms',
  help: 'HTTP request duration in ms',
  labelNames: ['method', 'route'],
  buckets: [0.1, 5, 15, 50, 100, 500]
});

app.use((req, res, next) => {
  const start = Date.now();
  
  res.on('finish', () => {
    const duration = Date.now() - start;
    requestCounter.labels(req.method, req.path, res.statusCode).inc();
    responseTime.labels(req.method, req.path).observe(duration);
  });
  
  next();
});

When NOT to Use Microservices

Microservices aren’t suitable for every project. Understanding when to avoid them prevents unnecessary complexity.

Signs You’re Not Ready for Microservices

  1. Small Team: If you don’t have multiple teams that can own services independently
  2. Simple Application: Monolith is simpler for straightforward, single-domain applications
  3. Tight Deadlines: Microservices add complexity and slow initial development
  4. Limited DevOps: Requires sophisticated deployment and monitoring infrastructure
  5. Distributed System Inexperience: Your team should understand distributed systems
  6. Infrequent Scaling Needs: Monolith scales fine for many applications

Better Alternatives:

  • Start with a modular monolith - organized internal structure, single deployment
  • Use a monolith with separation of concerns - prepare to migrate later if needed
  • Consider serverless architecture for specific workloads

Conclusion: Microservices as an Organizational Decision

Microservices architecture is as much an organizational decision as a technical one. It enables independent teams, faster deployments, and flexible technology choices. However, it demands sophisticated operational infrastructure, excellent monitoring, and deep understanding of distributed systems.

Start with a monolith, understand your scalability needs, and migrate to microservices when complexity justifies the operational overhead. Remember: microservices are an optimization for organizational scaling, not necessarily for technical performance.

Last Updated: January 8, 2026