Profile

The Storyteller

Minimal musings on code, design, and life


Building Scalable Microservices: Lessons from the Trenches

By The Storyteller May 15, 2025 Posted in Architecture
Building Scalable Microservices: Lessons from the Trenches

After five years of building, breaking, and rebuilding microservices architectures, I’ve learned that the devil is truly in the details. While the promise of microservices is compelling - independent deployments, technology diversity, team autonomy - the reality is far more nuanced.

The Journey from Monolith to Microservices

Our journey started like many others: a growing monolithic application that was becoming increasingly difficult to maintain and deploy. The classic symptoms were all there:

The promise of microservices seemed like the perfect solution. And in many ways, it was - but it also introduced an entirely new class of problems.

Lesson 1: Start with the Data

The biggest mistake we made early on was focusing on service boundaries based on technical concerns rather than data ownership. We ended up with services that were tightly coupled through shared databases.

The Wrong Way

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   User      │    │   Order     │    │   Payment   │
│   Service   │    │   Service   │    │   Service   │
└─────────────┘    └─────────────┘    └─────────────┘
        │                  │                  │
        └──────────────────┼──────────────────┘

                   ┌─────────────┐
                   │   Shared    │
                   │  Database   │
                   └─────────────┘

The Right Way

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│   User      │    │   Order     │    │   Payment   │
│   Service   │    │   Service   │    │   Service   │
│     +       │    │     +       │    │     +       │
│   User DB   │    │  Order DB   │    │ Payment DB  │
└─────────────┘    └─────────────┘    └─────────────┘

Key Insight: Each service should own its data completely. Cross-service data access should happen through APIs, never through direct database connections.

Domain-Driven Design in Practice

We learned to apply Domain-Driven Design (DDD) principles to identify proper service boundaries:

// Bounded Context: User Management
class UserService {
  async createUser(userData) {
    // Only owns user profile, authentication, preferences
    const user = await this.userRepository.create(userData)
    await this.eventBus.publish("user.created", { userId: user.id })
    return user
  }
}

// Bounded Context: Order Management
class OrderService {
  async createOrder(orderData) {
    // Owns order lifecycle, items, fulfillment
    // Gets user data via API, not direct DB access
    const userProfile = await this.userServiceClient.getUser(orderData.userId)
    const order = await this.orderRepository.create(orderData)
    return order
  }
}

Lesson 2: Embrace Eventual Consistency

Coming from a monolithic background, we initially tried to maintain ACID transactions across services. This led to overly complex choreography and tight coupling.

Event-Driven Architecture

We moved to an event-driven approach using message queues:

// Order Service publishes events
class OrderService {
  async createOrder(orderData) {
    const order = await this.orderRepository.create(orderData)

    // Publish event instead of direct service calls
    await this.eventBus.publish("order.created", {
      orderId: order.id,
      userId: order.userId,
      items: order.items,
      totalAmount: order.totalAmount,
    })

    return order
  }
}

// Inventory Service reacts to events
class InventoryService {
  @EventHandler("order.created")
  async handleOrderCreated(event) {
    try {
      await this.reserveInventory(event.items)
      await this.eventBus.publish("inventory.reserved", {
        orderId: event.orderId,
        items: event.items,
      })
    } catch (error) {
      await this.eventBus.publish("inventory.reservation.failed", {
        orderId: event.orderId,
        reason: error.message,
      })
    }
  }
}

Saga Pattern for Complex Workflows

For complex business processes that span multiple services, we implemented the Saga pattern:

class OrderProcessingSaga {
  constructor() {
    this.steps = [
      { service: "payment", action: "charge", compensate: "refund" },
      { service: "inventory", action: "reserve", compensate: "release" },
      { service: "shipping", action: "schedule", compensate: "cancel" },
    ]
  }

  async execute(orderData) {
    const completedSteps = []

    try {
      for (const step of this.steps) {
        await this.executeStep(step, orderData)
        completedSteps.push(step)
      }

      await this.eventBus.publish("order.completed", { orderId: orderData.id })
    } catch (error) {
      // Compensate in reverse order
      for (const step of completedSteps.reverse()) {
        await this.compensateStep(step, orderData)
      }

      await this.eventBus.publish("order.failed", {
        orderId: orderData.id,
        reason: error.message,
      })
    }
  }
}

Lesson 3: Observability is Non-Negotiable

With a monolith, debugging was straightforward - look at the logs and stack traces. With microservices, a single user request might touch 10+ services. We learned that observability isn’t optional.

Distributed Tracing

const opentelemetry = require("@opentelemetry/api")

class OrderService {
  async createOrder(orderData) {
    const span = opentelemetry.trace.getActiveSpan()
    span?.setAttributes({
      "order.user_id": orderData.userId,
      "order.item_count": orderData.items.length,
    })

    try {
      const order = await this.orderRepository.create(orderData)
      span?.setStatus({ code: opentelemetry.SpanStatusCode.OK })
      return order
    } catch (error) {
      span?.setStatus({
        code: opentelemetry.SpanStatusCode.ERROR,
        message: error.message,
      })
      throw error
    }
  }
}

Structured Logging

const logger = require("./logger")

class PaymentService {
  async processPayment(paymentData) {
    const correlationId = this.getCorrelationId()

    logger.info("Processing payment", {
      correlationId,
      userId: paymentData.userId,
      amount: paymentData.amount,
      currency: paymentData.currency,
    })

    try {
      const result = await this.paymentGateway.charge(paymentData)

      logger.info("Payment processed successfully", {
        correlationId,
        transactionId: result.transactionId,
        amount: paymentData.amount,
      })

      return result
    } catch (error) {
      logger.error("Payment processing failed", {
        correlationId,
        error: error.message,
        amount: paymentData.amount,
      })
      throw error
    }
  }
}

Circuit Breaker Pattern

const CircuitBreaker = require("opossum")

class UserServiceClient {
  constructor() {
    this.circuitBreaker = new CircuitBreaker(this.makeRequest.bind(this), {
      timeout: 3000,
      errorThresholdPercentage: 50,
      resetTimeout: 30000,
    })

    this.circuitBreaker.on("open", () => {
      logger.warn("Circuit breaker opened for UserService")
    })

    this.circuitBreaker.on("halfOpen", () => {
      logger.info("Circuit breaker half-open for UserService")
    })
  }

  async getUser(userId) {
    try {
      return await this.circuitBreaker.fire(userId)
    } catch (error) {
      // Fallback to cached data or default response
      return this.getFallbackUser(userId)
    }
  }

  async makeRequest(userId) {
    const response = await fetch(`${this.baseUrl}/users/${userId}`)
    if (!response.ok) {
      throw new Error(`Failed to fetch user: ${response.status}`)
    }
    return response.json()
  }
}

Lesson 4: API Gateway as the Front Door

Early on, our frontend applications had to know about every service and handle authentication, rate limiting, and routing themselves. This created tight coupling and security concerns.

Centralized Cross-Cutting Concerns

// Kong, Nginx, or custom Express.js gateway
class APIGateway {
  constructor() {
    this.app = express()
    this.setupMiddleware()
    this.setupRoutes()
  }

  setupMiddleware() {
    // Authentication
    this.app.use("/api", this.authenticateToken)

    // Rate limiting
    this.app.use(
      "/api",
      this.rateLimiter({
        windowMs: 15 * 60 * 1000, // 15 minutes
        max: 100, // limit each IP to 100 requests per windowMs
      })
    )

    // Request logging
    this.app.use("/api", this.requestLogger)
  }

  setupRoutes() {
    // Route to microservices
    this.app.use("/api/users", this.proxyTo("http://user-service:3001"))
    this.app.use("/api/orders", this.proxyTo("http://order-service:3002"))
    this.app.use("/api/payments", this.proxyTo("http://payment-service:3003"))
  }

  proxyTo(serviceUrl) {
    return createProxyMiddleware({
      target: serviceUrl,
      changeOrigin: true,
      pathRewrite: {
        "^/api/": "/", // remove /api prefix when forwarding
      },
      onError: this.handleProxyError,
    })
  }
}

Service Discovery Integration

class ServiceRegistry {
  constructor() {
    this.services = new Map()
    this.setupHealthChecks()
  }

  register(serviceName, serviceUrl) {
    this.services.set(serviceName, {
      url: serviceUrl,
      healthy: true,
      lastCheck: Date.now(),
    })
  }

  async getService(serviceName) {
    const service = this.services.get(serviceName)
    if (!service || !service.healthy) {
      throw new Error(`Service ${serviceName} is not available`)
    }
    return service.url
  }

  setupHealthChecks() {
    setInterval(async () => {
      for (const [name, service] of this.services) {
        try {
          await fetch(`${service.url}/health`)
          service.healthy = true
        } catch (error) {
          service.healthy = false
          logger.warn(`Health check failed for ${name}: ${error.message}`)
        }
        service.lastCheck = Date.now()
      }
    }, 30000) // Check every 30 seconds
  }
}

Lesson 5: Data Consistency Strategies

Managing data consistency across services is one of the hardest problems in microservices. We learned to embrace different consistency models for different use cases.

Strong Consistency (When Needed)

// For critical operations like financial transactions
class PaymentService {
  async processPayment(paymentData) {
    const transaction = await this.db.beginTransaction()

    try {
      // Charge the customer
      const charge = await this.createCharge(paymentData, transaction)

      // Update account balance
      await this.updateAccountBalance(paymentData.accountId, -paymentData.amount, transaction)

      // Synchronously call critical downstream services
      await this.notifyFraudService(charge)

      await transaction.commit()
      return charge
    } catch (error) {
      await transaction.rollback()
      throw error
    }
  }
}

Eventual Consistency (For Most Cases)

// For operations that can be eventually consistent
class OrderService {
  async createOrder(orderData) {
    // Create order immediately
    const order = await this.orderRepository.create({
      ...orderData,
      status: "pending",
    })

    // Trigger async processes
    await this.eventBus.publish("order.created", {
      orderId: order.id,
      userId: order.userId,
    })

    return order
  }
}

// Separate service handles notifications asynchronously
class NotificationService {
  @EventHandler("order.created")
  async handleOrderCreated(event) {
    // This can fail and retry without affecting order creation
    await this.sendOrderConfirmationEmail(event.userId, event.orderId)
    await this.sendPushNotification(event.userId, "Order confirmed!")
  }
}

Lesson 6: Testing Strategies

Testing microservices is fundamentally different from testing monoliths. We learned to implement a comprehensive testing strategy.

Contract Testing

// Using Pact for contract testing
describe("Order Service Contract", () => {
  beforeEach(() => {
    provider.addInteraction({
      state: "user exists",
      uponReceiving: "a request for user details",
      withRequest: {
        method: "GET",
        path: "/users/123",
        headers: { Authorization: "Bearer token" },
      },
      willRespondWith: {
        status: 200,
        body: {
          id: "123",
          name: "John Doe",
          email: "john@example.com",
        },
      },
    })
  })

  test("should get user details", async () => {
    const user = await orderService.getUserDetails("123")
    expect(user.name).toBe("John Doe")
  })
})

Integration Testing with Test Containers

const { GenericContainer } = require("testcontainers")

describe("Order Service Integration Tests", () => {
  let postgresContainer, redisContainer

  beforeAll(async () => {
    // Start test dependencies
    postgresContainer = await new GenericContainer("postgres:13")
      .withEnvironment({
        POSTGRES_DB: "test",
        POSTGRES_USER: "test",
        POSTGRES_PASSWORD: "test",
      })
      .withExposedPorts(5432)
      .start()

    redisContainer = await new GenericContainer("redis:6").withExposedPorts(6379).start()

    // Configure service with test containers
    process.env.DATABASE_URL = `postgresql://test:test@localhost:${postgresContainer.getMappedPort(5432)}/test`
    process.env.REDIS_URL = `redis://localhost:${redisContainer.getMappedPort(6379)}`
  })

  afterAll(async () => {
    await postgresContainer.stop()
    await redisContainer.stop()
  })

  test("should create order with inventory check", async () => {
    // Test actual service integration
    const order = await orderService.createOrder({
      userId: "123",
      items: [{ productId: "abc", quantity: 2 }],
    })

    expect(order.status).toBe("pending")

    // Verify inventory was updated
    const inventory = await inventoryService.getInventory("abc")
    expect(inventory.reserved).toBe(2)
  })
})

Lesson 7: Deployment and DevOps

Microservices multiply your deployment complexity. What was once a single deployment becomes dozens of coordinated deployments.

Infrastructure as Code

# docker-compose.yml for local development
version: "3.8"
services:
  user-service:
    build: ./services/user-service
    ports:
      - "3001:3000"
    environment:
      - DATABASE_URL=postgresql://user:password@user-db:5432/users
      - REDIS_URL=redis://redis:6379
    depends_on:
      - user-db
      - redis

  user-db:
    image: postgres:13
    environment:
      POSTGRES_DB: users
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
    volumes:
      - user-db-data:/var/lib/postgresql/data

  order-service:
    build: ./services/order-service
    ports:
      - "3002:3000"
    environment:
      - DATABASE_URL=postgresql://order:password@order-db:5432/orders
      - USER_SERVICE_URL=http://user-service:3000
    depends_on:
      - order-db
      - user-service

volumes:
  user-db-data:
  order-db-data:

Kubernetes Deployment

# 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: order-service:v1.2.3
          ports:
            - containerPort: 3000
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: order-db-secret
                  key: url
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 30
          readinessProbe:
            httpGet:
              path: /ready
              port: 3000
            initialDelaySeconds: 5
          resources:
            requests:
              memory: "256Mi"
              cpu: "250m"
            limits:
              memory: "512Mi"
              cpu: "500m"

CI/CD Pipeline

# .github/workflows/deploy.yml
name: Deploy Services
on:
  push:
    branches: [main]

jobs:
  detect-changes:
    runs-on: ubuntu-latest
    outputs:
      user-service: ${{ steps.changes.outputs.user-service }}
      order-service: ${{ steps.changes.outputs.order-service }}
    steps:
      - uses: actions/checkout@v2
      - uses: dorny/paths-filter@v2
        id: changes
        with:
          filters: |
            user-service:
              - 'services/user-service/**'
            order-service:
              - 'services/order-service/**'

  deploy-user-service:
    needs: detect-changes
    if: needs.detect-changes.outputs.user-service == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - name: Build and Deploy
        run: |
          docker build -t user-service:${{ github.sha }} ./services/user-service
          kubectl set image deployment/user-service user-service=user-service:${{ github.sha }}

Common Pitfalls and How to Avoid Them

1. Distributed Monolith

Problem: Services that are too tightly coupled, requiring coordinated deployments.

Solution: Design for independence. Each service should be deployable without affecting others.

2. Chatty Interfaces

Problem: Too many network calls between services, creating performance bottlenecks.

Solution: Design coarser-grained APIs. Batch operations where possible.

// Bad: Multiple calls
const user = await userService.getUser(userId)
const preferences = await userService.getPreferences(userId)
const subscriptions = await userService.getSubscriptions(userId)

// Good: Single call
const userProfile = await userService.getUserProfile(userId, {
  include: ["preferences", "subscriptions"],
})

3. Shared Database Anti-Pattern

Problem: Multiple services accessing the same database directly.

Solution: Each service owns its data. Cross-service access through APIs only.

4. Premature Decomposition

Problem: Breaking down services too early, before understanding domain boundaries.

Solution: Start with a well-structured monolith. Extract services when you understand the domain better.

When NOT to Use Microservices

Microservices aren’t always the right choice:

Key Takeaways

  1. Data ownership is crucial: Each service should own its data completely
  2. Embrace eventual consistency: Not everything needs to be immediately consistent
  3. Invest in observability: You can’t manage what you can’t see
  4. Start with the monolith: Understand your domain before decomposing
  5. Automate everything: Manual processes don’t scale with dozens of services
  6. Design for failure: Services will fail; build resilience from day one
  7. Team alignment: Service boundaries should align with team boundaries

Building scalable microservices is as much about organizational design as it is about technical architecture. The most successful microservices architectures I’ve seen are those where the technology serves the team structure, not the other way around.

The journey isn’t easy, but when done right, microservices can unlock tremendous productivity and scalability. Just remember: complexity never disappears, it only moves around. Make sure you’re moving it to where your team can best manage it.