Gateway Users Orders Payment Notify
Architecture & Design

Building Microservices Architecture

Mayur Dabhi
Mayur Dabhi
March 15, 2026
25 min read

Microservices architecture has revolutionized how we design, build, and deploy modern applications. By breaking down monolithic systems into smaller, independently deployable services, organizations can achieve unprecedented levels of scalability, flexibility, and resilience. Companies like Netflix, Amazon, and Uber have successfully adopted microservices to handle millions of users and transactions daily.

In this comprehensive guide, we'll explore everything you need to know about building microservices architecture—from fundamental concepts and design principles to practical implementation patterns and deployment strategies. Whether you're modernizing a legacy monolith or starting a new project, this guide will equip you with the knowledge to make informed architectural decisions.

What You'll Learn
  • Core principles and characteristics of microservices
  • Service decomposition strategies and domain-driven design
  • Inter-service communication patterns (sync vs async)
  • Data management and the database-per-service pattern
  • API Gateway, service discovery, and load balancing
  • Deployment, monitoring, and observability best practices

What Are Microservices?

Microservices architecture is an approach to building applications as a collection of small, autonomous services that work together. Each service is:

Independently Deployable

Deploy, update, and scale each service without affecting others

Single Responsibility

Each service focuses on one specific business capability

Own Its Data

Services manage their own database and data model

Technology Agnostic

Choose the best tech stack for each service's needs

Microservices vs Monolithic Architecture

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

Aspect Monolithic Microservices
Deployment Deploy entire application Deploy individual services
Scaling Scale entire application Scale specific services
Technology Single tech stack Multiple tech stacks
Team Structure Large, centralized teams Small, autonomous teams
Complexity Simpler initially Higher operational complexity
Failure Impact Entire system fails Isolated failures

Microservices Architecture Overview

A typical microservices architecture consists of several key components working together. Let's visualize the overall architecture:

Microservices Architecture Diagram CLIENTS Web App Mobile App IoT Device 3rd Party API API Gateway Authentication • Rate Limiting • Routing SERVICES User Service Node.js Order Service Java Spring Payment Service Go Notification Python Message Broker (RabbitMQ / Kafka) DATABASES (per service) PostgreSQL MongoDB MySQL Redis

A typical microservices architecture with API Gateway, multiple services, message broker, and database per service

Service Decomposition Strategies

One of the most challenging aspects of microservices is deciding how to decompose your system into services. There are several strategies you can use:

1. Decompose by Business Capability

Align services with business capabilities—what the business does rather than how it does it. This approach creates services that are stable over time because business capabilities rarely change.

E-commerce Business Capabilities
┌─────────────────────────────────────────────────────────────┐
│                    E-Commerce Platform                       │
├─────────────────────────────────────────────────────────────┤
│                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │   Customer   │  │   Catalog    │  │   Inventory  │       │
│  │  Management  │  │  Management  │  │  Management  │       │
│  └──────────────┘  └──────────────┘  └──────────────┘       │
│                                                              │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │    Order     │  │   Payment    │  │   Shipping   │       │
│  │  Processing  │  │  Processing  │  │   Delivery   │       │
│  └──────────────┘  └──────────────┘  └──────────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

2. Decompose by Subdomain (Domain-Driven Design)

Use Domain-Driven Design (DDD) to identify bounded contexts within your domain. Each bounded context becomes a potential microservice.

Key DDD Concepts
  • Bounded Context: A logical boundary within which a domain model is defined
  • Aggregate: A cluster of domain objects treated as a single unit
  • Entity: An object defined by its identity rather than attributes
  • Value Object: An object defined by its attributes, immutable
order-service/models/Order.js
// Order Aggregate Root
class Order {
  constructor(id, customerId, items = []) {
    this.id = id;
    this.customerId = customerId;
    this.items = items;
    this.status = 'PENDING';
    this.totalAmount = this.calculateTotal();
    this.createdAt = new Date();
  }

  // Business logic belongs in the aggregate
  addItem(productId, quantity, price) {
    const existingItem = this.items.find(i => i.productId === productId);
    
    if (existingItem) {
      existingItem.quantity += quantity;
    } else {
      this.items.push(new OrderItem(productId, quantity, price));
    }
    
    this.totalAmount = this.calculateTotal();
    return this;
  }

  removeItem(productId) {
    this.items = this.items.filter(i => i.productId !== productId);
    this.totalAmount = this.calculateTotal();
    return this;
  }

  confirm() {
    if (this.items.length === 0) {
      throw new Error('Cannot confirm empty order');
    }
    this.status = 'CONFIRMED';
    this.confirmedAt = new Date();
    return this;
  }

  calculateTotal() {
    return this.items.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    );
  }
}

// Value Object
class OrderItem {
  constructor(productId, quantity, price) {
    this.productId = productId;
    this.quantity = quantity;
    this.price = price;
  }
}

module.exports = { Order, OrderItem };

Inter-Service Communication

Services need to communicate with each other. There are two primary patterns: synchronous and asynchronous communication.

REST API Communication

Services communicate directly via HTTP requests. Simple but creates tight coupling.

// Order Service calling User Service via REST
const axios = require('axios');

class OrderService {
  async createOrder(userId, items) {
    // Synchronous call to User Service
    const userResponse = await axios.get(
      `http://user-service:3001/api/users/${userId}`
    );
    
    if (!userResponse.data.active) {
      throw new Error('User account is inactive');
    }

    // Synchronous call to Inventory Service
    for (const item of items) {
      const inventoryResponse = await axios.post(
        'http://inventory-service:3002/api/inventory/reserve',
        { productId: item.productId, quantity: item.quantity }
      );
      
      if (!inventoryResponse.data.success) {
        throw new Error(`Insufficient inventory for ${item.productId}`);
      }
    }

    // Create the order
    const order = await Order.create({
      userId,
      items,
      status: 'PENDING'
    });

    return order;
  }
}

Event-Driven Communication

Services communicate via message brokers. Decoupled and resilient to failures.

// Order Service publishing events
const amqp = require('amqplib');

class OrderEventPublisher {
  async connect() {
    this.connection = await amqp.connect('amqp://rabbitmq:5672');
    this.channel = await this.connection.createChannel();
    await this.channel.assertExchange('orders', 'topic', { durable: true });
  }

  async publishOrderCreated(order) {
    const event = {
      type: 'ORDER_CREATED',
      timestamp: new Date().toISOString(),
      data: {
        orderId: order.id,
        userId: order.userId,
        items: order.items,
        totalAmount: order.totalAmount
      }
    };

    this.channel.publish(
      'orders',
      'order.created',
      Buffer.from(JSON.stringify(event))
    );
    
    console.log(`Published ORDER_CREATED event for order ${order.id}`);
  }
}

// Notification Service subscribing to events
class NotificationEventHandler {
  async start() {
    const connection = await amqp.connect('amqp://rabbitmq:5672');
    const channel = await connection.createChannel();
    
    await channel.assertExchange('orders', 'topic', { durable: true });
    const queue = await channel.assertQueue('notifications.orders', { durable: true });
    
    await channel.bindQueue(queue.queue, 'orders', 'order.*');
    
    channel.consume(queue.queue, async (msg) => {
      const event = JSON.parse(msg.content.toString());
      
      switch (event.type) {
        case 'ORDER_CREATED':
          await this.sendOrderConfirmation(event.data);
          break;
        case 'ORDER_SHIPPED':
          await this.sendShippingNotification(event.data);
          break;
      }
      
      channel.ack(msg);
    });
  }

  async sendOrderConfirmation(orderData) {
    // Send email, SMS, push notification, etc.
    console.log(`Sending confirmation for order ${orderData.orderId}`);
  }
}

Communication Patterns Comparison

Synchronous Service A Service B Request Response ✓ Simple to implement ✓ Easy to debug ✗ Tight coupling ✗ Cascading failures ✗ Lower availability Asynchronous Service A Message Broker Service B ✓ Loose coupling ✓ Better resilience ✓ Higher availability ✗ Eventual consistency ✗ Complex debugging

API Gateway Pattern

An API Gateway serves as the single entry point for all client requests. It handles cross-cutting concerns like authentication, rate limiting, and request routing.

1

Request Routing

Routes incoming requests to the appropriate microservice based on the URL path or headers.

2

Authentication & Authorization

Validates JWT tokens or API keys before forwarding requests to backend services.

3

Rate Limiting

Protects services from abuse by limiting the number of requests per client.

4

Response Aggregation

Combines responses from multiple services into a single response for the client.

api-gateway/src/gateway.js (Express + http-proxy-middleware)
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');

const app = express();

// Rate limiting
const limiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100, // limit each IP to 100 requests per windowMs
  message: { error: 'Too many requests, please try again later' }
});

app.use(limiter);

// JWT Authentication middleware
const authenticate = (req, res, next) => {
  const token = req.headers.authorization?.split(' ')[1];
  
  if (!token) {
    return res.status(401).json({ error: 'No token provided' });
  }
  
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    return res.status(403).json({ error: 'Invalid token' });
  }
};

// Service routes configuration
const services = {
  users: {
    target: 'http://user-service:3001',
    pathRewrite: { '^/api/users': '/users' }
  },
  orders: {
    target: 'http://order-service:3002',
    pathRewrite: { '^/api/orders': '/orders' }
  },
  products: {
    target: 'http://product-service:3003',
    pathRewrite: { '^/api/products': '/products' }
  },
  payments: {
    target: 'http://payment-service:3004',
    pathRewrite: { '^/api/payments': '/payments' }
  }
};

// Create proxy middleware for each service
Object.entries(services).forEach(([name, config]) => {
  app.use(
    `/api/${name}`,
    authenticate, // Require auth for all service routes
    createProxyMiddleware({
      target: config.target,
      changeOrigin: true,
      pathRewrite: config.pathRewrite,
      onProxyReq: (proxyReq, req) => {
        // Forward user info to backend services
        proxyReq.setHeader('X-User-Id', req.user.id);
        proxyReq.setHeader('X-User-Role', req.user.role);
      },
      onError: (err, req, res) => {
        console.error(`Proxy error for ${name}:`, err);
        res.status(503).json({ 
          error: 'Service temporarily unavailable',
          service: name
        });
      }
    })
  );
});

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Response aggregation example
app.get('/api/dashboard', authenticate, async (req, res) => {
  try {
    const [userProfile, recentOrders, notifications] = await Promise.all([
      fetch(`http://user-service:3001/users/${req.user.id}`).then(r => r.json()),
      fetch(`http://order-service:3002/orders?userId=${req.user.id}&limit=5`).then(r => r.json()),
      fetch(`http://notification-service:3005/notifications?userId=${req.user.id}`).then(r => r.json())
    ]);

    res.json({
      user: userProfile,
      recentOrders,
      notifications
    });
  } catch (error) {
    res.status(500).json({ error: 'Failed to aggregate dashboard data' });
  }
});

app.listen(3000, () => {
  console.log('API Gateway running on port 3000');
});

Data Management Strategies

Each microservice should own its data. This principle, known as Database per Service, ensures loose coupling but introduces challenges around data consistency.

The Challenge

In a monolith, you use ACID transactions to maintain consistency. In microservices, you need different patterns because services have separate databases.

Saga Pattern for Distributed Transactions

The Saga pattern manages transactions that span multiple services by breaking them into a sequence of local transactions, each publishing an event that triggers the next step.

Saga Pattern: Order Creation Flow SUCCESS PATH 1. Create Order Order Service 2. Reserve Items Inventory Service 3. Process Payment Payment Service 4. Confirm Order Order Service COMPENSATION (on failure at step 3) Release Items Inventory Service Cancel Order Order Service

The Saga pattern with compensating transactions for rollback on failure

order-service/sagas/CreateOrderSaga.js
const { Saga, SagaBuilder } = require('./saga-framework');

class CreateOrderSaga {
  constructor(orderRepository, eventBus) {
    this.orderRepository = orderRepository;
    this.eventBus = eventBus;
  }

  build(orderData) {
    return new SagaBuilder()
      // Step 1: Create order in PENDING state
      .step('createOrder')
        .invoke(async (context) => {
          const order = await this.orderRepository.create({
            ...orderData,
            status: 'PENDING'
          });
          context.orderId = order.id;
          return order;
        })
        .withCompensation(async (context) => {
          await this.orderRepository.updateStatus(context.orderId, 'CANCELLED');
        })
      
      // Step 2: Reserve inventory
      .step('reserveInventory')
        .invoke(async (context) => {
          const response = await this.eventBus.requestReply(
            'inventory.reserve',
            {
              orderId: context.orderId,
              items: orderData.items
            },
            { timeout: 30000 }
          );
          
          if (!response.success) {
            throw new Error('Inventory reservation failed');
          }
          
          context.reservationId = response.reservationId;
          return response;
        })
        .withCompensation(async (context) => {
          await this.eventBus.publish('inventory.release', {
            reservationId: context.reservationId
          });
        })
      
      // Step 3: Process payment
      .step('processPayment')
        .invoke(async (context) => {
          const response = await this.eventBus.requestReply(
            'payment.process',
            {
              orderId: context.orderId,
              amount: orderData.totalAmount,
              customerId: orderData.customerId
            },
            { timeout: 60000 }
          );
          
          if (!response.success) {
            throw new Error(`Payment failed: ${response.error}`);
          }
          
          context.paymentId = response.paymentId;
          return response;
        })
        .withCompensation(async (context) => {
          await this.eventBus.publish('payment.refund', {
            paymentId: context.paymentId
          });
        })
      
      // Step 4: Confirm order
      .step('confirmOrder')
        .invoke(async (context) => {
          await this.orderRepository.updateStatus(context.orderId, 'CONFIRMED');
          
          // Publish success event
          await this.eventBus.publish('order.confirmed', {
            orderId: context.orderId,
            customerId: orderData.customerId
          });
          
          return { orderId: context.orderId, status: 'CONFIRMED' };
        })
      
      .build();
  }

  async execute(orderData) {
    const saga = this.build(orderData);
    
    try {
      const result = await saga.execute();
      console.log(`Order saga completed successfully:`, result);
      return result;
    } catch (error) {
      console.error(`Order saga failed:`, error);
      throw error;
    }
  }
}

module.exports = CreateOrderSaga;

Service Discovery

In a dynamic microservices environment, services need to find each other. Service discovery handles this by maintaining a registry of available service instances.

Client-Side Discovery

Client queries the service registry and selects an instance. Example: Netflix Eureka with Ribbon.

Server-Side Discovery

Load balancer queries the registry and forwards requests. Example: AWS ALB, Kubernetes Services.

docker-compose.yml (with Consul for Service Discovery)
version: '3.8'

services:
  consul:
    image: consul:1.15
    ports:
      - "8500:8500"
    command: agent -server -bootstrap-expect=1 -ui -client=0.0.0.0
    
  user-service:
    build: ./user-service
    environment:
      - SERVICE_NAME=user-service
      - CONSUL_HOST=consul
    depends_on:
      - consul
    deploy:
      replicas: 3
      
  order-service:
    build: ./order-service
    environment:
      - SERVICE_NAME=order-service
      - CONSUL_HOST=consul
    depends_on:
      - consul
    deploy:
      replicas: 2

  api-gateway:
    build: ./api-gateway
    ports:
      - "3000:3000"
    environment:
      - CONSUL_HOST=consul
    depends_on:
      - consul
      - user-service
      - order-service

Resilience Patterns

Microservices must handle failures gracefully. Here are essential resilience patterns:

Circuit Breaker Pattern

Prevents cascading failures by stopping requests to a failing service. After a timeout, it allows test requests to check if the service has recovered.

const CircuitBreaker = require('opossum');

const options = {
  timeout: 3000,           // If function takes longer, trigger failure
  errorThresholdPercentage: 50,  // Open circuit at 50% failure rate
  resetTimeout: 30000      // After 30s, try again
};

const breaker = new CircuitBreaker(callExternalService, options);

breaker.on('open', () => console.log('Circuit opened!'));
breaker.on('halfOpen', () => console.log('Circuit half-open, testing...'));
breaker.on('close', () => console.log('Circuit closed, back to normal'));

// Use the circuit breaker
async function getUserData(userId) {
  try {
    return await breaker.fire(userId);
  } catch (error) {
    // Return cached data or default response
    return getCachedUser(userId) || { id: userId, name: 'Unknown' };
  }
}

Retry with Exponential Backoff

Automatically retries failed requests with increasing delays between attempts.

async function retryWithBackoff(fn, maxRetries = 3, baseDelay = 1000) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (error) {
      if (attempt === maxRetries) {
        throw error;
      }
      
      const delay = baseDelay * Math.pow(2, attempt);
      const jitter = Math.random() * 1000;
      
      console.log(`Attempt ${attempt + 1} failed, retrying in ${delay + jitter}ms`);
      await new Promise(resolve => setTimeout(resolve, delay + jitter));
    }
  }
}

// Usage
const result = await retryWithBackoff(
  () => axios.get('http://order-service/orders/123'),
  3,
  1000
);

Bulkhead Pattern

Isolates components to prevent failures from spreading. Limits concurrent calls to a service.

const Bottleneck = require('bottleneck');

// Create separate limiters for different services
const userServiceLimiter = new Bottleneck({
  maxConcurrent: 10,  // Max 10 concurrent requests
  minTime: 100        // Min 100ms between requests
});

const orderServiceLimiter = new Bottleneck({
  maxConcurrent: 20,
  minTime: 50
});

// Wrap service calls with limiters
async function getUser(userId) {
  return userServiceLimiter.schedule(() => 
    axios.get(`http://user-service/users/${userId}`)
  );
}

async function getOrders(userId) {
  return orderServiceLimiter.schedule(() => 
    axios.get(`http://order-service/orders?userId=${userId}`)
  );
}

Observability: Logs, Metrics, Traces

With dozens of services running, observability becomes crucial. Implement the three pillars: logging, metrics, and distributed tracing.

Three Pillars of Observability 📝 Logging What happened? • Structured logs (JSON) • Correlation IDs • ELK Stack / Loki 📊 Metrics How is it performing? • Request rates • Error percentages • Prometheus/Grafana 🔍 Tracing Where did time go? • Request flow across services • Latency breakdown • Jaeger / Zipkin
Distributed Tracing with OpenTelemetry
const { NodeTracerProvider } = require('@opentelemetry/sdk-trace-node');
const { SimpleSpanProcessor } = require('@opentelemetry/sdk-trace-base');
const { JaegerExporter } = require('@opentelemetry/exporter-jaeger');
const { registerInstrumentations } = require('@opentelemetry/instrumentation');
const { HttpInstrumentation } = require('@opentelemetry/instrumentation-http');
const { ExpressInstrumentation } = require('@opentelemetry/instrumentation-express');

// Initialize tracing
const provider = new NodeTracerProvider();

provider.addSpanProcessor(
  new SimpleSpanProcessor(
    new JaegerExporter({
      endpoint: 'http://jaeger:14268/api/traces'
    })
  )
);

provider.register();

registerInstrumentations({
  instrumentations: [
    new HttpInstrumentation(),
    new ExpressInstrumentation()
  ]
});

// Custom span for business logic
const { trace } = require('@opentelemetry/api');
const tracer = trace.getTracer('order-service');

async function processOrder(orderData) {
  return tracer.startActiveSpan('processOrder', async (span) => {
    try {
      span.setAttribute('order.customer_id', orderData.customerId);
      span.setAttribute('order.items_count', orderData.items.length);
      
      // Validate order
      const validationSpan = tracer.startSpan('validateOrder');
      await validateOrder(orderData);
      validationSpan.end();
      
      // Save to database
      const dbSpan = tracer.startSpan('saveToDatabase');
      const order = await orderRepository.save(orderData);
      dbSpan.setAttribute('order.id', order.id);
      dbSpan.end();
      
      span.setStatus({ code: SpanStatusCode.OK });
      return order;
    } catch (error) {
      span.setStatus({ code: SpanStatusCode.ERROR, message: error.message });
      throw error;
    } finally {
      span.end();
    }
  });
}

Deployment with Kubernetes

Kubernetes is the de facto standard for deploying microservices. It handles orchestration, scaling, and self-healing.

k8s/order-service-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
  labels:
    app: order-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: order-service
  template:
    metadata:
      labels:
        app: order-service
      annotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "3002"
    spec:
      containers:
        - name: order-service
          image: myregistry/order-service:v1.2.0
          ports:
            - containerPort: 3002
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: order-db-secret
                  key: url
            - name: RABBITMQ_URL
              value: "amqp://rabbitmq:5672"
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health/live
              port: 3002
            initialDelaySeconds: 15
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 3002
            initialDelaySeconds: 5
            periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
    - port: 80
      targetPort: 3002
  type: ClusterIP
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: order-service-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

Best Practices Summary

Key Takeaways

  • Start with a monolith — Extract microservices as you understand domain boundaries
  • Design for failure — Implement circuit breakers, retries, and timeouts
  • Embrace eventual consistency — Use sagas and event sourcing for distributed transactions
  • Automate everything — CI/CD pipelines, infrastructure as code, automated testing
  • Invest in observability — You can't fix what you can't see
  • Keep services small — If you can't rewrite it in 2 weeks, it's too big
  • Use API versioning — Allow services to evolve independently
  • Implement proper security — Zero trust, encrypt in transit, secure secrets
When NOT to Use Microservices

Microservices aren't always the answer. Consider sticking with a monolith if:

  • Your team is small (<10 developers)
  • You're building an MVP or prototype
  • Domain boundaries are unclear
  • You lack DevOps expertise and tooling

Conclusion

Building microservices architecture is a journey, not a destination. It requires careful consideration of service boundaries, communication patterns, data management, and operational practices. While the complexity is higher than a monolith, the benefits—scalability, team autonomy, technology flexibility, and resilience—make it worthwhile for organizations with the right scale and complexity.

Start small, learn from failures, and continuously refine your architecture. Remember that the best architecture is one that evolves with your business needs. Whether you're breaking apart a monolith or starting fresh, the patterns and practices covered in this guide will help you build robust, scalable microservices.

Happy architecting! 🏗️

Microservices Architecture Scalability Distributed Systems API Gateway Docker Kubernetes
Mayur Dabhi

Mayur Dabhi

Full-stack developer passionate about building scalable applications and sharing knowledge with the developer community.