Gateway Users Orders Payment Notify
Architecture & Design

Building Microservices Architecture

Mayur Dabhi
Mayur Dabhi
March 15, 2026
25 min read

At some point every growing app hits the same wall. A one-line fix to the checkout flow means redeploying the whole thing, a slow reporting query drags down the login page, and three teams keep stepping on each other's migrations. Splitting the app into separate services sounds like the fix, and sometimes it is. Other times you end up with a distributed monolith that's harder to run than what you started with.

This post walks through the parts that decide which way it goes: where to draw service boundaries, how services talk to each other, how to keep data consistent without shared transactions, and what you need in place (gateway, discovery, resilience, tracing, Kubernetes) before running this in production. The examples are in Node.js, but the patterns carry over to Laravel or anything else.

What's Covered
  • 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 tracing requests across services

What Are Microservices?

Strip away the buzzwords and you get an app built from small services that each run as their own process and talk over the network. For that split to actually pay off, each service should be:

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

Pick the stack that fits each service (though fewer stacks is usually easier to maintain)

Microservices vs Monolithic Architecture

Neither column is "better". Each side of this table trades one kind of pain for another:

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 One bad bug can take down everything Isolated, if you build for it

Microservices Architecture Overview

Most setups end up with the same layers: clients, a gateway in front, the services themselves, a message broker for async work, and one database per service.

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

Getting the boundaries wrong is the most expensive mistake here, because moving them later means migrating data between databases. Two approaches work well in practice.

1. Decompose by Business Capability

Split along what the business does, not how the code happens to be organized. "Billing" and "shipping" stay around for years, while your current controller layout probably won't, so services built around capabilities tend to stay stable.

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)

Domain-Driven Design (DDD) gives you a vocabulary for finding boundaries. A good hint: if "Customer" means something different to the sales team than to the support team, you're probably looking at two bounded contexts, and each one is a candidate service.

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

Once the code lives in separate processes, every function call that crossed a boundary becomes a network call. You've got two options: synchronous requests or asynchronous events.

REST API Communication

Services call each other directly over HTTP. Easy to write and easy to follow, but if the User Service is down, so is order creation.

// 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 publish events to a broker and don't wait for anyone. If a consumer is down, messages queue up until it comes back. The cost is that you lose the immediate answer.

// 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
Two Things the Examples Above Don't Handle
  • axios has no timeout by default (timeout: 0). If the Inventory Service hangs instead of failing, the Order Service request hangs with it, and so does every worker waiting on it. Always set an explicit timeout on service-to-service calls.
  • Unacked RabbitMQ messages pile up. If sendOrderConfirmation throws, channel.ack(msg) never runs and the message sits unacknowledged until the channel closes. Wrap the handler in try/catch, nack on failure (ideally to a dead letter queue), and set channel.prefetch(n) so one consumer doesn't pull the whole queue into memory.

API Gateway Pattern

You don't want mobile apps knowing the internal hostname of every service, and you don't want each service re-implementing JWT checks. The gateway sits in front, gives clients one entry point, and handles authentication, rate limiting, and routing in one place.

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

If two services read and write the same tables, they aren't really separate: a schema change in one breaks the other. So each service owns its data (Database per Service), and nobody else touches those tables directly. That keeps services decoupled, but it makes consistency your problem.

The Catch

In a monolith, you wrap "create order, reserve stock, charge card" in one ACID transaction. Once those live in three databases, there's no single transaction to roll back, so you need a different approach.

Saga Pattern for Distributed Transactions

A saga breaks the big transaction into a chain of local ones. Each step commits in its own service and triggers the next. If a step fails, the saga runs compensating actions to undo what already happened.

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

Containers come and go, and their IPs change every time. Hardcoding addresses stops working fast, so something has to keep track of which instances are alive and where they are.

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.

If you're on Kubernetes, its built-in Services and DNS already cover this, and you probably don't need Consul. The Compose example below is for setups without an orchestrator.

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

With enough services and network hops, something is always slow or down. These three patterns keep one bad service from dragging the rest along with it.

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

Retries failed requests with growing delays between attempts. The random jitter matters: without it, every client retries at the same moment and hits the recovering service all at once. Only retry idempotent calls, or you'll charge someone twice.

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

In a monolith, a stack trace tells you where things broke. Across a dozen services, a slow request might touch five of them, and the log lines are spread over five places. You need logging, metrics, and distributed tracing, all sharing a correlation or trace ID so you can follow one request end to end.

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

Most teams running more than a handful of services end up on Kubernetes. It restarts crashed containers, scales replicas, and gives you service discovery for free. The manifest below covers a Deployment, a Service, and an autoscaler. Note the two separate probes: liveness restarts a stuck pod, while readiness just pulls it out of the load balancer until it's ready.

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

Mistakes I See Most Often

Where Microservice Projects Go Wrong

  • Splitting before the boundaries are clear. If you don't know the domain yet, you'll guess wrong, and fixing a bad boundary between two databases is far harder than moving a class between folders. A modular monolith first, then extracting services, is usually the safer path.
  • Chains of synchronous calls. If A calls B calls C calls D over HTTP, your availability is roughly the product of all four, and latency adds up. That's a distributed monolith. Push anything that doesn't need an immediate answer onto events.
  • A shared database "just for now". It never stays temporary. Once two services read the same table, you can't change that schema without coordinating deploys.
  • No timeouts, no idempotency. Retries without idempotency keys turn one failed payment call into two charges. Timeouts should be shorter the further down the call chain you go.
  • Adding tracing after the first outage. Correlation IDs and tracing are cheap on day one and painful to retrofit across services owned by different people.
  • Breaking API changes without versioning. Services deploy independently, so old and new versions will run side by side. Add fields; don't rename or remove them without a version bump.
When I Wouldn't Use Microservices

For a lot of projects, a well-structured monolith is the better call. I'd stay with one if:

  • The team is small enough that everyone works on the same codebase without blocking each other (roughly under 10 developers)
  • You're building an MVP and the product will change shape a few times
  • Domain boundaries are still fuzzy
  • Nobody on the team owns CI/CD, monitoring, and on-call for a dozen deployables

Where they do earn their keep: several teams shipping on different schedules, parts of the system with very different scaling needs (a video encoder vs. a settings page), or a component that needs to stay up when the rest is being deployed.

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.