Scaling with Microservices

When and how to transition from monolith to microservices. Learn the practical strategies, patterns, and pitfalls to avoid when scaling your architecture.

JaziraTech Team
January 5, 2025
9 min read

Scaling with Microservices: The Complete Guide

Microservices have become the go-to architecture for scaling modern applications. But when should you make the switch, and how do you do it right? Let's dive deep into the practical aspects of microservices architecture.

The Monolith vs Microservices Debate

When to Keep Your Monolith

Don't rush to microservices if:

  • ๐Ÿš€ Your startup is in early stages (< 10 engineers)
  • ๐Ÿ“Š You're still finding product-market fit
  • ๐ŸŽฏ Your application has clear boundaries and low complexity
  • ๐Ÿ‘ฅ Your team lacks distributed systems experience
  • ๐Ÿ’ฐ You have limited DevOps resources

The monolith is powerful when:

// Simple, cohesive application
class ECommerceApp {
  products: ProductService;
  orders: OrderService;
  users: UserService;
  payments: PaymentService;

  // All in one codebase, deployed together
  // Easy to develop, test, and deploy
}

When to Consider Microservices

Signs you're ready:

  • โœ… Team size > 20-30 engineers
  • โœ… Deployment bottlenecks (waiting on other teams)
  • โœ… Different services have different scaling needs
  • โœ… Parts of the system need different tech stacks
  • โœ… You want independent service deployments
  • โœ… You have complex domain boundaries

The Migration Strategy

Phase 1: Prepare the Monolith

Before splitting, clean up your monolith:

// Bad: Tightly coupled code
class UserController {
  async createUser(data: UserData) {
    const user = await db.users.create(data);
    await db.orders.update({ userId: user.id }); // Direct DB access
    await emailService.send(user.email); // Tight coupling
    return user;
  }
}

// Good: Loosely coupled with events
class UserController {
  async createUser(data: UserData) {
    const user = await db.users.create(data);

    // Publish event instead of direct coupling
    await eventBus.publish('user.created', { userId: user.id });

    return user;
  }
}

// Other services listen to events
eventBus.subscribe('user.created', async (event) => {
  await emailService.sendWelcomeEmail(event.userId);
});

Phase 2: Identify Service Boundaries

Use Domain-Driven Design (DDD):

# Bad Decomposition (by technical layers)
- database-service
- api-service
- ui-service

# Good Decomposition (by business domains)
- user-service (authentication, profiles)
- product-service (catalog, inventory)
- order-service (cart, checkout, fulfillment)
- payment-service (billing, transactions)
- notification-service (email, SMS, push)

Phase 3: Extract Services Gradually

Strangler Fig Pattern:

// 1. Start with API Gateway
const apiGateway = express();

// 2. Route new features to microservices
apiGateway.use('/api/notifications', proxy('http://notification-service:3001'));

// 3. Keep old routes on monolith
apiGateway.use('/api/users', proxy('http://monolith:3000'));

// 4. Gradually migrate routes
apiGateway.use('/api/products', proxy('http://product-service:3002'));

Microservices Patterns

1. API Gateway Pattern

// api-gateway/src/index.ts
import express from 'express';
import { createProxyMiddleware } from 'http-proxy-middleware';

const app = express();

// Service registry
const services = {
  users: 'http://user-service:3000',
  products: 'http://product-service:3001',
  orders: 'http://order-service:3002',
};

// Route to services
Object.entries(services).forEach(([name, target]) => {
  app.use(`/api/${name}`, createProxyMiddleware({
    target,
    changeOrigin: true,
    pathRewrite: { [`^/api/${name}`]: '' },
  }));
});

// Cross-cutting concerns
app.use(authMiddleware);
app.use(rateLimiter);
app.use(logger);

app.listen(8080);

2. Service Discovery

// Using Consul for service discovery
import Consul from 'consul';

const consul = new Consul();

// Register service
await consul.agent.service.register({
  name: 'product-service',
  address: 'localhost',
  port: 3001,
  check: {
    http: 'http://localhost:3001/health',
    interval: '10s',
  },
});

// Discover service
const getServiceUrl = async (serviceName: string) => {
  const services = await consul.health.service({
    service: serviceName,
    passing: true,
  });

  const service = services[0]?.Service;
  return `http://${service.Address}:${service.Port}`;
};

// Use in client
const productServiceUrl = await getServiceUrl('product-service');
const response = await fetch(`${productServiceUrl}/products`);

3. Event-Driven Communication

// Using RabbitMQ for async communication
import amqp from 'amqplib';

class EventBus {
  private connection: amqp.Connection;
  private channel: amqp.Channel;

  async publish(event: string, data: any) {
    await this.channel.assertExchange('events', 'topic');
    this.channel.publish(
      'events',
      event,
      Buffer.from(JSON.stringify(data))
    );
  }

  async subscribe(pattern: string, handler: (data: any) => Promise<void>) {
    const queue = await this.channel.assertQueue('', { exclusive: true });

    await this.channel.bindQueue(queue.queue, 'events', pattern);

    this.channel.consume(queue.queue, async (msg) => {
      if (msg) {
        const data = JSON.parse(msg.content.toString());
        await handler(data);
        this.channel.ack(msg);
      }
    });
  }
}

// Order Service publishes event
await eventBus.publish('order.created', {
  orderId: '123',
  userId: 'user-456',
  items: [...],
});

// Inventory Service subscribes
await eventBus.subscribe('order.created', async (order) => {
  await decrementInventory(order.items);
});

// Notification Service subscribes
await eventBus.subscribe('order.created', async (order) => {
  await sendOrderConfirmation(order.userId, order.orderId);
});

4. Circuit Breaker Pattern

// Prevent cascading failures
import CircuitBreaker from 'opossum';

const options = {
  timeout: 3000, // If request takes longer than 3s, trigger failure
  errorThresholdPercentage: 50, // Open circuit if 50% fail
  resetTimeout: 30000, // Try again after 30s
};

const breaker = new CircuitBreaker(callExternalService, options);

// Fallback when circuit is open
breaker.fallback(() => ({
  status: 'unavailable',
  message: 'Service temporarily unavailable',
}));

// Use the circuit breaker
app.get('/api/products', async (req, res) => {
  try {
    const products = await breaker.fire();
    res.json(products);
  } catch (error) {
    res.status(503).json({ error: 'Service unavailable' });
  }
});

5. Saga Pattern (Distributed Transactions)

// Orchestration-based saga
class OrderSaga {
  async createOrder(orderData: OrderData) {
    const sagaId = generateId();
    const compensations = [];

    try {
      // Step 1: Reserve inventory
      const inventory = await inventoryService.reserve(orderData.items);
      compensations.push(() => inventoryService.release(inventory.id));

      // Step 2: Process payment
      const payment = await paymentService.charge(orderData.payment);
      compensations.push(() => paymentService.refund(payment.id));

      // Step 3: Create order
      const order = await orderService.create(orderData);

      // Success - commit saga
      await sagaStore.complete(sagaId);
      return order;

    } catch (error) {
      // Failure - run compensations in reverse
      for (const compensate of compensations.reverse()) {
        try {
          await compensate();
        } catch (compError) {
          console.error('Compensation failed:', compError);
        }
      }

      await sagaStore.fail(sagaId, error);
      throw error;
    }
  }
}

Data Management

Database per Service

// โŒ Don't share databases
class OrderService {
  async getOrderWithUser(orderId: string) {
    // Bad: Accessing user database directly
    const order = await orderDb.orders.findOne(orderId);
    const user = await userDb.users.findOne(order.userId); // Cross-DB query
    return { order, user };
  }
}

// โœ… Do communicate via APIs/events
class OrderService {
  async getOrderWithUser(orderId: string) {
    const order = await orderDb.orders.findOne(orderId);

    // Good: Call user service API
    const user = await fetch(`http://user-service/users/${order.userId}`)
      .then(r => r.json());

    return { order, user };
  }
}

CQRS (Command Query Responsibility Segregation)

// Write Model - optimized for commands
class OrderWriteService {
  async createOrder(data: CreateOrderDTO) {
    const order = await orderDb.orders.create(data);
    await eventBus.publish('order.created', order);
    return order;
  }
}

// Read Model - optimized for queries
class OrderReadService {
  async getOrderDetails(orderId: string) {
    // Denormalized data for fast reads
    return await orderReadDb.orderDetails.findOne(orderId);
  }
}

// Event handler updates read model
eventBus.subscribe('order.created', async (order) => {
  const user = await userService.getUser(order.userId);
  const products = await productService.getProducts(order.productIds);

  await orderReadDb.orderDetails.create({
    ...order,
    userName: user.name,
    productNames: products.map(p => p.name),
  });
});

Observability

Distributed Tracing

// Using OpenTelemetry
import { trace } from '@opentelemetry/api';

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

app.post('/orders', async (req, res) => {
  const span = tracer.startSpan('create-order');

  try {
    // Trace database call
    const dbSpan = tracer.startSpan('db-insert', { parent: span });
    const order = await db.orders.create(req.body);
    dbSpan.end();

    // Trace external service call
    const paymentSpan = tracer.startSpan('payment-charge', { parent: span });
    await paymentService.charge(order.id);
    paymentSpan.end();

    span.setStatus({ code: 1 }); // OK
    res.json(order);
  } catch (error) {
    span.setStatus({ code: 2, message: error.message }); // ERROR
    throw error;
  } finally {
    span.end();
  }
});

Centralized Logging

// Structured logging with correlation IDs
import winston from 'winston';

const logger = winston.createLogger({
  format: winston.format.json(),
  defaultMeta: { service: 'order-service' },
  transports: [
    new winston.transports.File({ filename: 'error.log', level: 'error' }),
    new winston.transports.File({ filename: 'combined.log' }),
  ],
});

// Add correlation ID to all logs
app.use((req, res, next) => {
  req.correlationId = req.headers['x-correlation-id'] || generateId();
  res.setHeader('x-correlation-id', req.correlationId);
  next();
});

// Log with context
app.post('/orders', async (req, res) => {
  logger.info('Creating order', {
    correlationId: req.correlationId,
    userId: req.body.userId,
    items: req.body.items.length,
  });

  try {
    const order = await orderService.create(req.body);
    logger.info('Order created successfully', {
      correlationId: req.correlationId,
      orderId: order.id,
    });
    res.json(order);
  } catch (error) {
    logger.error('Order creation failed', {
      correlationId: req.correlationId,
      error: error.message,
      stack: error.stack,
    });
    res.status(500).json({ error: 'Internal server error' });
  }
});

Common Pitfalls to Avoid

1. Premature Decomposition

// โŒ Don't: Creating too many services too early
- auth-service
- user-profile-service
- user-preferences-service
- user-settings-service

// โœ… Do: Start with coarse-grained services
- user-service (handles all user-related functionality)

2. Distributed Monolith

// โŒ Don't: Services that all depend on each other
order-service โ†’ payment-service โ†’ user-service โ†’ order-service // Circular!

// โœ… Do: Minimize direct dependencies
order-service โ†’ events โ†’ payment-service
                      โ†’ notification-service
                      โ†’ inventory-service

3. Ignoring Network Failures

// โŒ Don't: Assume network calls always succeed
const user = await fetch(`${userService}/users/${id}`).then(r => r.json());

// โœ… Do: Handle failures gracefully
const user = await withRetry(
  () => fetch(`${userService}/users/${id}`).then(r => r.json()),
  { attempts: 3, timeout: 5000 }
).catch(error => {
  logger.error('User service unavailable', { error });
  return { id, name: 'Unknown User' }; // Fallback
});

Deployment Strategy

Docker Compose for Development

version: '3.8'

services:
  api-gateway:
    build: ./api-gateway
    ports:
      - "8080:8080"
    environment:
      - NODE_ENV=development

  user-service:
    build: ./user-service
    environment:
      - DATABASE_URL=postgresql://user:pass@user-db:5432/users
    depends_on:
      - user-db

  order-service:
    build: ./order-service
    environment:
      - DATABASE_URL=postgresql://user:pass@order-db:5432/orders
      - RABBITMQ_URL=amqp://rabbitmq:5672
    depends_on:
      - order-db
      - rabbitmq

  rabbitmq:
    image: rabbitmq:3-management
    ports:
      - "15672:15672" # Management UI

  user-db:
    image: postgres:14
    environment:
      POSTGRES_DB: users

  order-db:
    image: postgres:14
    environment:
      POSTGRES_DB: orders

Kubernetes for Production

# order-service-deployment.yaml
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: myregistry/order-service:v1.0.0
        ports:
        - containerPort: 3000
        env:
        - name: DATABASE_URL
          valueFrom:
            secretKeyRef:
              name: order-service-secrets
              key: database-url
        resources:
          limits:
            memory: "512Mi"
            cpu: "500m"
        livenessProbe:
          httpGet:
            path: /health
            port: 3000
          initialDelaySeconds: 30
          periodSeconds: 10
---
apiVersion: v1
kind: Service
metadata:
  name: order-service
spec:
  selector:
    app: order-service
  ports:
  - port: 80
    targetPort: 3000
  type: ClusterIP

Success Metrics

Track these KPIs:

  • Deployment Frequency: How often each service deploys
  • Lead Time: Time from commit to production
  • Mean Time to Recovery (MTTR): How fast you recover from failures
  • Service Availability: 99.9% uptime per service
  • Request Latency: p50, p95, p99 response times
  • Error Rate: 4xx and 5xx responses

Conclusion

Microservices are powerful but complex. Success requires:

  1. Start simple: Begin with a modular monolith
  2. Extract gradually: Use strangler fig pattern
  3. Automate everything: CI/CD, testing, monitoring
  4. Design for failure: Circuit breakers, retries, fallbacks
  5. Observe deeply: Tracing, logging, metrics
  6. Team alignment: Conway's Law is real

The goal isn't microservicesโ€”it's scalability, velocity, and reliability. Choose the architecture that serves your business goals.


Need help scaling your architecture? Contact us for an architecture review and migration strategy.

Tags: Microservices, Architecture, Scalability, DevOps, Distributed Systems, Docker, Kubernetes

Share this article

About the author

JaziraTech Team

Content Team

The technical content team at JaziraTech, sharing insights on API development, AI integration, and modern web technologies.