Microservices Architecture Guide 2026 — Design, Communication, and Deployment

Sanjeev SharmaSanjeev Sharma
5 min read

Advertisement

Introduction

Why This Matters

Microservices allow teams to deploy, scale, and fault-isolate independently. But they introduce distributed systems complexity that kills teams unprepared for it. This guide focuses on the patterns that make microservices manageable in 2026 — service boundaries, async messaging, observability, and the reality of when a monolith is the better choice.

When NOT to Use Microservices

Start with a modular monolith unless you have a compelling reason to distribute:

SituationRecommendation
Single team (<= 10 engineers)Modular monolith
Early product, changing requirementsModular monolith
Tight deployment coupling OKModular monolith
Independent scaling requiredMicroservices
Multiple teams on different servicesMicroservices
Different tech stacks per domainMicroservices

Service Design with Domain-Driven Design

Organize services around business capabilities, not technical layers:

Bad (technical layers):       Good (business domains):
├── frontend-service          ├── user-service
├── api-service               ├── order-service
├── database-service          ├── payment-service
└── cache-service             ├── notification-service
                              └── inventory-service

Each service owns its data — no shared databases:

// user-service: owns users table
const user = await prisma.user.findUnique({ where: { id } })
 
// order-service: does NOT query users table directly
// Instead, calls user-service via HTTP or uses cached user data
const user = await userServiceClient.getUser(userId)

Synchronous Communication (REST/gRPC)

// Typed HTTP client with retry and circuit breaking
import axios from 'axios'
import axiosRetry from 'axios-retry'
 
const userServiceClient = axios.create({
  baseURL: process.env.USER_SERVICE_URL,
  timeout: 5000,
  headers: { 'X-Service-Name': 'order-service' },
})
 
axiosRetry(userServiceClient, {
  retries: 3,
  retryDelay: axiosRetry.exponentialDelay,
  retryCondition: (error) => {
    return axiosRetry.isNetworkError(error) || error.response?.status === 503
  },
})
 
export async function getUser(userId: string): Promise<User | null> {
  try {
    const { data } = await userServiceClient.get(`/users/${userId}`)
    return data
  } catch (error) {
    if (axios.isAxiosError(error) && error.response?.status === 404) return null
    throw error
  }
}

Asynchronous Messaging with BullMQ

For operations that can be decoupled, use message queues:

npm install bullmq ioredis
// src/queues/emailQueue.ts
import { Queue, Worker } from 'bullmq'
import { redis } from '../lib/redis'
 
export const emailQueue = new Queue('email', {
  connection: redis,
  defaultJobOptions: {
    attempts: 3,
    backoff: { type: 'exponential', delay: 2000 },
    removeOnComplete: 100,
    removeOnFail: 500,
  },
})
 
// Producer: order-service
await emailQueue.add('order-confirmed', {
  to: user.email,
  orderId: order.id,
  total: order.total,
})
 
// Consumer: notification-service (separate process)
const emailWorker = new Worker(
  'email',
  async (job) => {
    const { to, orderId, total } = job.data
 
    await sendEmail({
      to,
      subject: 'Order Confirmed',
      template: 'order-confirmed',
      data: { orderId, total },
    })
  },
  { connection: redis, concurrency: 10 }
)
 
emailWorker.on('failed', (job, err) => {
  console.error(`Email job ${job?.id} failed:`, err.message)
})

Event-Driven Architecture

// Event sourcing with a simple event bus
interface DomainEvent {
  id: string
  type: string
  aggregateId: string
  payload: unknown
  timestamp: string
  version: number
}
 
// Publish event after successful mutation
async function createOrder(userId: string, items: OrderItem[]) {
  const order = await prisma.order.create({ data: { userId, items } })
 
  // Publish to message broker (Kafka/RabbitMQ/Redis Streams)
  await eventBus.publish('orders', {
    id: crypto.randomUUID(),
    type: 'ORDER_CREATED',
    aggregateId: order.id,
    payload: { userId, items, total: order.total },
    timestamp: new Date().toISOString(),
    version: 1,
  })
 
  return order
}
 
// Subscribe in other services
eventBus.subscribe('orders', async (event: DomainEvent) => {
  switch (event.type) {
    case 'ORDER_CREATED':
      await inventoryService.reserveItems(event.payload)
      await notificationService.sendOrderConfirmation(event.payload)
      break
    case 'ORDER_CANCELLED':
      await inventoryService.releaseItems(event.payload)
      break
  }
})

Distributed Observability

// Structured logging with correlation IDs
import { randomUUID } from 'crypto'
import pino from 'pino'
 
const logger = pino({ level: 'info' })
 
app.addHook('preHandler', (request, _, done) => {
  request.requestId = request.headers['x-request-id'] as string ?? randomUUID()
  request.log = logger.child({
    requestId: request.requestId,
    service: 'order-service',
    userId: request.user?.id,
  })
  done()
})
 
// Propagate correlation ID to downstream services
await userServiceClient.get(`/users/${userId}`, {
  headers: { 'X-Request-ID': request.requestId },
})

Common Mistakes

  • Sharing a database between services — creates invisible coupling and defeats independent deployment
  • Making synchronous calls in a chain deeper than 2 hops — failure propagates and latency compounds
  • Not implementing circuit breakers — a failing downstream service takes down the caller
  • Treating microservices as the default — a modular monolith is simpler and faster to develop initially
  • No distributed tracing — debugging failures across 5 services without trace IDs is nearly impossible

Best Practices

  • Each service owns exactly one domain and its data store — no cross-service database joins
  • Use async messaging (queues, events) for any operation that does not need an immediate response
  • Implement circuit breakers with libraries like opossum to prevent cascade failures
  • Add X-Request-ID headers and propagate them through all downstream calls for tracing
  • Define service contracts with OpenAPI specs and test them with contract testing (Pact)

Key Takeaways

  • A modular monolith is the right starting point for most teams — extract services when boundaries are proven
  • Each microservice must own its own database — shared databases create deployment coupling
  • Async messaging (BullMQ, Kafka) decouples services and provides built-in retry with backoff
  • Event-driven architecture enables loose coupling — services react to events rather than direct calls
  • Distributed tracing requires propagating a requestId through every service in the chain
  • Circuit breakers prevent cascade failures when a downstream service degrades
  • Domain-Driven Design boundaries (bounded contexts) map naturally to service boundaries
  • Kubernetes with health probes, resource limits, and HPA provides production-grade service orchestration

Advertisement

Sanjeev Sharma

Written by

Sanjeev Sharma

Full Stack Engineer · E-mopro

Related reading