Skip to content

Microservices

Microservices are the architecture where everything is someone else's problem until it's your problem at 3AM because service A can't reach service B and service C is silently dropping messages and nobody can figure out why because each team only owns their piece The microservices cheat code: don't start with them. Start with a well-structured monolith , find the pain points , split those out. Premature microservices are an anti-pattern that multiplies complexity without delivering value

Monolith vs Microservices — The Honest Comparison

Monolith:

flowchart TB
    subgraph Monolith_App[Monolith Application]
        Auth[Auth Module]
        Users[Users Module]
        Payments[Payments Module]
        Blog[Blog Module]
        Notify[Notify Module]
        Search[Search Module]
    end
    DB[(Single Database)]
    Monolith_App --> DB

Microservices:

flowchart TB
    Auth[Auth Service<br/>DB: auth]
    Users[Users Service<br/>DB: users]
    Payments[Payments Service<br/>DB: pay]
    MQ[Message Queue<br/>RabbitMQ / Kafka]
    Blog[Blog Service<br/>DB: blog]
    Notify[Notify Service<br/>DB: notify]
    Search[Search Service<br/>DB: es]

    Auth --> MQ
    Users --> MQ
    Payments --> MQ
    MQ --> Blog
    MQ --> Notify
    MQ --> Search

When monolith wins: * Team of 1-5 developers * Single domain with clear boundaries * No independent scaling needs * You want to ship fast without 10 services to start

When microservices win: * Team of 20+ across multiple squads * Different services need different scaling (payments needs more than auth) * Polyglot persistence (different DB types per service) * Independent deploy cycles per team

When to Break Things Apart

You don't decompose a monolith — you extract services from it Signals it's time:

  • Deploy coupling — a change to any feature requires full redeploy , risking the entire app
  • Team bottlenecks — 15 people PRing into the same codebase , merge conflicts every day
  • Database contention — one table locks everything , the reporting query runs for 30 seconds and blocks writes
  • Different scaling needs — search needs 10 instances , auth needs 2 , but they're all in the same deploy
  • Tech stack divergence — you want to use a different language or framework for one feature

The extraction pattern:

Phase 1: Identify the bounded context → auth is separate from blog
Phase 2: Extract data ownership → auth owns user table , blog reads via API
Phase 3: Extract service layer → standalone auth service with its own DB
Phase 4: Split deployment → auth deploys independently

Inter-Service Communication — How Services Talk

REST (simple , request-response):

// Service A calls Service B via HTTP
const response = await fetch('http://user-service:3001/api/users/123')
const user = await response.json()

  • Pros: Simple , everyone knows HTTP , easy to debug
  • Cons: Synchronous , latency adds up (A calls B calls C calls D) , cascading failures

gRPC (fast , typed , binary):

// user.proto
service UserService {
  rpc GetUser (GetUserRequest) returns (User);
  rpc ListUsers (ListUsersRequest) returns (ListUsersResponse);
}

message GetUserRequest {
  string user_id = 1;
}

message User {
  string id = 1;
  string name = 2;
  string email = 3;
}

// Node.js gRPC client
const client = new UserService('user-service:50051', grpc.credentials.createInsecure())

client.getUser({ userId: '123' }, (err, user) => {
  console.log(user.name)
})
  • Pros: Fast (binary protocol) , strongly typed (protobuf) , streaming support
  • Cons: More complex setup , tooling for debugging is limited , versioning is hard

Message Queues (async , decoupled):

// Service A publishes an event
await channel.publish('exchange', 'user.created', Buffer.from(JSON.stringify({
  userId: '123',
  email: 'user@example.com'
})))

// Service B consumes the event
channel.consume('queue.notifications', (msg) => {
  const event = JSON.parse(msg.content.toString())
  sendWelcomeEmail(event.email)
  channel.ack(msg)
})

  • Pros: Decoupled , resilient (queue buffers if consumer is down) , supports fan-out patterns
  • Cons: Eventual consistency , harder to debug , message ordering challenges

Service Discovery — How Services Find Each Other

In a monolith , function calls just work. In microservices , Service A needs to find Service B's IP address — and with auto-scaling , IPs change constantly

DNS-based discovery (simple):

# Kubernetes handles this — service names resolve to active pods
user-service: The name "user-service" resolves to one of the healthy pods

Consul/HashiCorp (dedicated service registry):

# Register service
consul services register -name=user-service -address=10.0.1.5 -port=3001

# Discover service
dig user-service.service.consul
# Returns 10.0.1.5 — or any healthy instance

Distributed Tracing — Following Requests Across Services

Without tracing , debugging a "request failed" error across 5 services is like finding a specific cell in a spreadsheet by printing it out and using a magnifying glass

// Every service propagates trace context in headers
const response = await fetch('http://user-service:3001/api/users', {
  headers: {
    'x-request-id': req.id,
    'x-trace-id': currentTrace.id,
    'x-span-id': currentSpan.id
  }
})

Jaeger or Zipkin visualize the trace:

flowchart LR
    A[Service A - 100ms] --> B[Service B - 200ms]
    B --> C[Service C - 500ms]
    B --> D[Service D - 300ms]

Eventual Consistency — Data Across Services

Each service owns its data. There's no JOIN across services. If you need data from multiple services , you either:

API composition (request-time joining):

// Backend-for-frontend (BFF) aggregates
async function getUserProfile(userId) {
  const [user, orders, notifications] = await Promise.all([
    userService.getUser(userId),
    orderService.getOrders(userId),
    notifyService.getNotifications(userId)
  ])

  return { user, orders, notifications }
}

Eventual consistency (data replication via events):

// User service publishes event
await messageQueue.publish('user.updated', { userId: '123', name: 'New Name' })

// Blog service subscribes and updates its local copy
messageQueue.subscribe('user.updated', (event) => {
  await blogDb.query('UPDATE authors SET name = $1 WHERE user_id = $2',
    [event.name, event.userId])
})

The Saga Pattern — Distributed Transactions

A saga is a sequence of local transactions where each step publishes an event that triggers the next. If a step fails , compensating transactions undo previous steps

Order processing saga:

sequenceDiagram
    participant Order as Order Service
    participant Payment as Payment Service
    participant Inventory as Inventory Service
    participant Shipping as Shipping Service

    Order->>Payment: Reserve inventory
    Payment->>Inventory: Process payment
    Inventory->>Shipping: Deduct stock
    Shipping-->>Inventory: Schedule shipment - FAILED
    Inventory-->>Payment: Restore stock (compensating)
    Payment-->>Order: Refund (compensating)
    Order-->>Order: Cancel order (compensating)
// Choreography-based saga (services react to events)
// Order Service
async function createOrder(order) {
  await db.query('INSERT INTO orders ...')
  await messageQueue.publish('order.created', { orderId: order.id })
}

// Payment Service
messageQueue.subscribe('order.created', async (event) => {
  try {
    await processPayment(event.orderId)
    await messageQueue.publish('payment.processed', event)
  } catch (err) {
    await messageQueue.publish('payment.failed', event)
  }
})

// Inventory Service
messageQueue.subscribe('payment.processed', async (event) => {
  try {
    await deductInventory(event.orderId)
    await messageQueue.publish('inventory.deducted', event)
  } catch (err) {
    // Refund payment — compensating action
    await messageQueue.publish('payment.refund', event)
  }
})

next → upcoming_04_serverless.md