Published on

Tight Coupling Between Services — When Changing One Service Breaks Five Others

Authors

Introduction

Microservices are supposed to be independently deployable. If you have to deploy three services together to ship a feature, you don't have microservices — you have a distributed monolith with all the downsides of distribution and none of the benefits. Tight coupling between services is the most common mistake teams make after splitting a monolith.

What Tight Coupling Looks Like

Distributed monolith:

User API → synchronously calls → Order Service
Order Service → synchronously calls → Inventory Service
Order Service → synchronously calls → Payment Service
Payment Service → synchronously calls → User API (circular!)

Problems:
- Chain of synchronous calls: one service down = all down
- Deploy order matters (Payment must deploy before Order)
- API contract changes in User API break Order and Payment
- Latency stacks: 50ms + 80ms + 60ms + 40ms = 230ms minimum
- Circular dependency makes reasoning about the system impossible

Fix 1: Async Events Instead of Sync Calls

// ❌ Synchronous chain — fragile
class OrderService {
  async createOrder(data: CreateOrderDto) {
    // Synchronously call 3 other services
    const user = await userServiceClient.getUser(data.userId)     // 50ms
    const inventory = await inventoryClient.reserve(data.items)   // 80ms
    const payment = await paymentClient.charge(data.total)        // 200ms

    // If ANY of these fail, the whole order fails
    // If payment service is slow, user waits 330ms+ just for coordination overhead
    return this.ordersRepo.create({ ...data, payment })
  }
}

// ✅ Async events — resilient
class OrderService {
  async createOrder(data: CreateOrderDto) {
    // Only do what ORDER service owns
    const order = await this.ordersRepo.create({
      ...data,
      status: 'pending',
    })

    // Publish event — don't wait for downstream services
    await eventBus.publish('order.created', {
      orderId: order.id,
      userId: data.userId,
      items: data.items,
      total: data.total,
    })

    return order  // Return immediately, 5ms response time
    // Inventory, payment, notifications handle asynchronously
  }
}

Fix 2: Define Stable Service Contracts

// ❌ Services share internal data structures — any field rename breaks everyone
// In user-service: returns full internal User model
interface User {
  id: string
  internalDbId: number    // internal detail leaked!
  firstName: string
  lastName: string
  emailAddress: string    // later renamed to email
  passwordHash: string    // security sensitive!
  createdTimestamp: Date  // later renamed to createdAt
}

// ✅ Publish a stable public API contract (versioned)
// user-service public API v1 — never changes without versioning
interface UserPublicV1 {
  id: string
  fullName: string   // stable name
  email: string      // stable name
  createdAt: string  // ISO 8601 — stable format
  // Internal fields never exposed
}

// API versioning
app.get('/v1/users/:id', getV1User)  // stable forever
app.get('/v2/users/:id', getV2User)  // new version when contract must change

// Old version deprecated, not removed, for 6+ months
app.get('/v1/users/:id', async (req, res) => {
  res.setHeader('Deprecation', 'version="v1", date="2026-09-01"')
  res.setHeader('Sunset', 'Tue, 01 Sep 2026 00:00:00 GMT')
  return getV1User(req, res)
})

Fix 3: Circuit Breakers on All Service Calls

import CircuitBreaker from 'opossum'

// Every service-to-service call gets a circuit breaker
const userServiceBreaker = new CircuitBreaker(
  (userId: string) => userServiceClient.getUser(userId),
  {
    timeout: 3000,          // fail after 3s
    errorThresholdPercentage: 50,  // open after 50% error rate
    resetTimeout: 30000,    // try again after 30s
  }
)

userServiceBreaker.fallback((userId: string) => {
  // Return cached data or a degraded response — don't fail the whole request
  return { id: userId, fullName: 'Unknown User', email: '' }
})

// Now order-service can degrade gracefully if user-service is down
const user = await userServiceBreaker.fire(userId)

Fix 4: Avoid Circular Dependencies with Dependency Inversion

Circular:
  Order Service → calls → User Service → calls → Order Service

Fix via events:
  Order Service → publishes 'order.created' event
  User Service → subscribes to 'order.created', updates own data
  No circular call — both services are autonomous

Service Coupling Checklist

  • ✅ Services communicate via events/messages for non-query operations
  • ✅ Each service owns its own data — no service reads another's database directly
  • ✅ Service APIs are versioned — breaking changes need a new version, not an edit
  • ✅ All inter-service calls have circuit breakers with fallback behavior
  • ✅ No deploy requires coordinating multiple services at the same time
  • ✅ Any service can be deployed, rolled back, or restarted independently

Conclusion

Tight coupling in microservices is worse than tight coupling in a monolith — at least the monolith is in the same process and shares memory. Distributed tight coupling combines the complexity of a distributed system with the rigidity of a monolith. The fix is to communicate through events for operations, define stable versioned contracts for queries, and add circuit breakers so one service's failure doesn't cascade to all callers. Each service should be deployable and restartable without touching any other.