Microservices Architecture Guide 2026 — Design, Communication, and Deployment
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:
| Situation | Recommendation |
|---|---|
| Single team (<= 10 engineers) | Modular monolith |
| Early product, changing requirements | Modular monolith |
| Tight deployment coupling OK | Modular monolith |
| Independent scaling required | Microservices |
| Multiple teams on different services | Microservices |
| Different tech stacks per domain | Microservices |
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-serviceEach 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
opossumto prevent cascade failures - Add
X-Request-IDheaders 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
requestIdthrough 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
Related reading
Apache Kafka with Node.js and TypeScript — Production Guide 20265 min readMicroservices with Node.js and TypeScript — Complete Guide 20266 min readOverengineering with Microservices Too Early — When Complexity Kills Speed4 min readTight Coupling Between Services — When Changing One Service Breaks Five Others4 min readMessage Queues in Node.js — BullMQ and RabbitMQ Guide 20265 min readMLOps Guide 2026 — Deploy ML Models to Production at Scale5 min read