Microservices Communication Patterns: The Complete 2026 Engineering Guide
Microservices Communication Patterns: The Complete 2026 Engineering Guide
When you break a monolith into dozens — or hundreds — of independently deployable services, the first question that hits you is deceptively simple: how do these services talk to each other? Microservices communication patterns determine everything from latency budgets to fault tolerance, from team autonomy to deployment safety. Choose the wrong pattern and you'll trade your monolith headache for a distributed-systems migraine.
This guide walks through every major microservices communication pattern used in production today, with real code examples, architecture diagrams, and battle-tested recommendations. Whether you're building greenfield microservices or refactoring an existing system, you'll leave with a clear mental model for choosing the right pattern for each service-to-service interaction.
Table of Contents
- Synchronous vs Asynchronous Communication
- REST / HTTP — The Default Choice
- gRPC — High-Performance RPC
- Message Queues (RabbitMQ & Kafka)
- Event-Driven Architecture
- Service Mesh (Istio & Linkerd)
- API Gateway Pattern
- Saga Pattern for Distributed Transactions
- Circuit Breaker Pattern
- Comparison Matrix & Decision Framework
Synchronous vs Asynchronous Communication
Every microservices communication strategy falls into one of two fundamental categories. Understanding the trade-offs between them is the foundation of every architectural decision you'll make.
Synchronous Communication
The caller sends a request and blocks until a response arrives. Think HTTP REST, gRPC, GraphQL — the classic request/response cycle.
Pros:
- Straightforward mental model (request → response)
- Easy to debug and trace
- Natural fit for operations that must complete before proceeding
- Works well for read-heavy workloads
Cons:
- Tight coupling between services at runtime
- Cascading failures when a downstream service is slow or down
- Harder to scale — every hop adds latency to the critical path
- Couples the availability of the caller to the callee
Asynchronous Communication
The caller sends a message and immediately continues without waiting. Think message queues, event streams, pub/sub — fire-and-forget semantics.
Pros:
- Loose coupling — services don't need each other to be available simultaneously
- Natural buffering during traffic spikes
- Better fault tolerance and resilience
- Enables event-driven architecture patterns
Cons:
- Harder to debug (no single call stack to trace)
- Eventual consistency instead of immediate results
- Infrastructure overhead (message broker management)
- Message ordering and idempotency challenges
┌─────────────────────────────────────────────────────────┐
│ Communication Style Decision Tree │
├─────────────────────────────────────────────────────────┤
│ │
│ Does the caller need the result immediately? │
│ │ │
│ ├── YES ──► Synchronous (REST / gRPC) │
│ │ │ │
│ │ ├── Low latency? ──► gRPC │
│ │ └── Standard? ──► REST/HTTP │
│ │ │
│ └── NO ────► Asynchronous (Message / Event) │
│ │ │
│ ├── Simple queue? ──► RabbitMQ │
│ ├── High throughput? ──► Kafka │
│ └── Complex topology? ──► Event Mesh │
│ │
└─────────────────────────────────────────────────────────┘
Rule of thumb: Use synchronous communication for reads and user-facing operations. Use asynchronous communication for writes, notifications, and anything that can tolerate eventual consistency.
REST / HTTP — The Default Choice
REST over HTTP remains the most widely adopted microservices communication pattern, and for good reason. It's universally understood, has a massive tooling ecosystem, and works everywhere.
When to Use REST
- Public-facing APIs consumed by browsers or mobile clients
- Internal service calls where simplicity matters more than raw performance
- CRUD-dominated domains
- Teams that want the broadest hiring pool (everyone knows HTTP)
Example: REST Service in Python (FastAPI)
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from typing import List
app = FastAPI()
class Order(BaseModel):
id: str
customer_id: str
items: List[str]
total: float
# In-memory store for demonstration
orders: dict[str, Order] = {}
@app.get("/orders/{order_id}", response_model=Order)
async def get_order(order_id: str):
if order_id not in orders:
raise HTTPException(status_code=404, detail="Order not found")
return orders[order_id]
@app.post("/orders", response_model=Order, status_code=201)
async def create_order(order: Order):
orders[order.id] = order
return order
@app.get("/orders", response_model=List[Order])
async def list_orders():
return list(orders.values())
Calling Another Service via REST
import httpx
async def get_customer_details(customer_id: str) -> dict:
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(
f"http://customer-service:8000/customers/{customer_id}"
)
response.raise_for_status()
return response.json()
REST Best Practices for Microservices
| Practice | Why It Matters |
|---|---|
| Use proper HTTP status codes | 200, 201, 204, 400, 404, 409, 500 — don't return 200 for errors |
| Version your APIs (/v1/orders) | Enables independent service evolution |
| Set aggressive timeouts | 3–5s max for internal calls; never block indefinitely |
| Use bulkhead-style connection pools | Prevent one slow callee from exhausting all connections |
| Implement retry with jitter | Transient failures are common; retry with exponential backoff |
The hidden cost of REST: JSON serialization is expensive at scale. If your service handles 10,000+ requests per second and serializes large payloads, the JSON parsing overhead becomes measurable. This is where gRPC enters the picture.
gRPC — High-Performance RPC
gRPC is Google's open-source RPC framework built on HTTP/2 and Protocol Buffers. It's the go-to choice when you need low latency, high throughput, and strong typing across microservices communication boundaries.
Why gRPC Outperforms REST
| Factor | REST (JSON/HTTP1.1) | gRPC (Protobuf/HTTP2) |
|---|---|---|
| Serialization | Text-based JSON parsing | Binary Protocol Buffers |
| Multiplexing | One request per connection | Multiple requests over one connection |
| Streaming | Not supported (without SSE/WebSockets) | Bidirectional streaming built-in |
| Code Generation | Manual or third-party | First-class protoc generation |
| Typical latency | 5–20ms overhead | 1–3ms overhead |
Defining a Service with Protocol Buffers
syntax = "proto3";
package orders.v1;
service OrderService {
rpc GetOrder(GetOrderRequest) returns (Order);
rpc CreateOrder(CreateOrderRequest) returns (Order);
rpc StreamOrders(StreamOrdersRequest) returns (stream Order);
}
message Order {
string id = 1;
string customer_id = 2;
repeated string items = 3;
double total = 4;
int64 created_at = 5;
}
message GetOrderRequest {
string id = 1;
}
message CreateOrderRequest {
string customer_id = 1;
repeated string items = 2;
double total = 3;
}
message StreamOrdersRequest {
string customer_id = 1;
}
gRPC Server in Go
package main
import (
"context"
"log"
"net"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "genproto/orders/v1"
)
type orderServer struct {
pb.UnimplementedOrderServiceServer
orders map[string]*pb.Order
}
func (s *orderServer) GetOrder(ctx context.Context, req *pb.GetOrderRequest) (*pb.Order, error) {
order, ok := s.orders[req.Id]
if !ok {
return nil, status.Errorf(codes.NotFound, "order %s not found", req.Id)
}
return order, nil
}
func (s *orderServer) CreateOrder(ctx context.Context, req *pb.CreateOrderRequest) (*pb.Order, error) {
order := &pb.Order{
Id: generateUUID(),
CustomerId: req.CustomerId,
Items: req.Items,
Total: req.Total,
CreatedAt: time.Now().Unix(),
}
s.orders[order.Id] = order
return order, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
s := grpc.NewServer()
pb.RegisterOrderServiceServer(s, &orderServer{orders: make(map[string]*pb.Order)})
log.Println("gRPC server listening on :50051")
s.Serve(lis)
}
gRPC Client Streaming in TypeScript
import { OrderServiceClient } from './generated/orders/v1/OrderServiceClientPb';
import { StreamOrdersRequest, Order } from './generated/orders/v1/Order_pb';
const client = new OrderServiceClient('http://order-service:50051');
function streamCustomerOrders(customerId: string) {
const request = new StreamOrdersRequest();
request.setCustomerId(customerId);
const stream = client.streamOrders(request, {});
stream.on('data', (order: Order) => {
console.log(`Order received: ${order.getId()} — $${order.getTotal()}`);
});
stream.on('error', (err: Error) => {
console.error('Stream error:', err);
});
stream.on('end', () => {
console.log('Stream complete');
});
}
When to Choose gRPC
- Internal service-to-service calls with strict latency budgets (< 10ms)
- Polyglot environments where code generation across languages saves time
- Scenarios requiring streaming (real-time feeds, log pipelines, telemetry)
- High-throughput data pipelines where serialization cost is a bottleneck
Message Queues (RabbitMQ & Kafka)
Message queues are the backbone of asynchronous microservices communication. They decouple producers from consumers, buffer traffic spikes, and enable reliable delivery guarantees.
RabbitMQ: Flexible Routing for Complex Workflows
RabbitMQ implements AMQP and excels at complex routing topologies — topic exchanges, fan-out, header-based routing, and dead-letter queues.
┌──────────┐ ┌──────────────┐
│ Producer │──►[Exchange]──►[Queue]──►│ Consumer A │
│ Service │ (topic) (orders) └──────────────┘
└──────────┘ │
├──►[Queue]──►┌──────────────┐
│ (audit) │ Consumer B │
└─────────────┴──────────────┘
Python Example: RabbitMQ Producer and Consumer
# Producer — Order Service
import pika
import json
connection = pika.BlockingConnection(
pika.ConnectionParameters('rabbitmq', 5672)
)
channel = connection.channel()
# Declare a topic exchange
channel.exchange_declare(exchange='orders', exchange_type='topic')
def publish_order_created(order: dict):
routing_key = f"order.created.{order['customer_id']}"
channel.basic_publish(
exchange='orders',
routing_key=routing_key,
body=json.dumps(order),
properties=pika.BasicProperties(
delivery_mode=2, # Persistent
content_type='application/json',
message_id=order['id'],
)
)
publish_order_created({"id": "1234", "customer_id": "cust-5678", "total": 99.99})
connection.close()
# Consumer — Shipping Service
import pika
import json
connection = pika.BlockingConnection(
pika.ConnectionParameters('rabbitmq', 5672)
)
channel = connection.channel()
channel.exchange_declare(exchange='orders', exchange_type='topic')
channel.queue_declare(queue='shipping', durable=True)
channel.queue_bind(exchange='orders', queue='shipping', routing_key='order.created.*')
def callback(ch, method, properties, body):
order = json.loads(body)
print(f"Processing shipment for order {order['id']}")
# Business logic here...
ch.basic_ack(delivery_tag=method.delivery_tag)
channel.basic_consume(queue='shipping', on_message_callback=callback)
print("Shipping service waiting for orders...")
channel.start_consuming()
Apache Kafka: High-Throughput Event Streaming
Kafka is built for durability, replay, and massive throughput. Instead of queues, Kafka uses append-only logs partitioned across brokers. Consumers track their own position (offset), enabling multiple consumers to read independently.
┌────────────┐ ┌─────────────────────────────────┐ ┌──────────────┐
│ Producer │───►│ Kafka Cluster │───►│ Consumer │
│ (Orders) │ │ ┌─────────────────────────┐ │ │ Group A │
└────────────┘ │ │ Topic: order-events │ │ │ (Search) │
│ │ Partition 0: [msg msg] │ │ └──────────────┘
│ │ Partition 1: [msg msg] │ │
│ │ Partition 2: [msg msg] │ │ ┌──────────────┐
│ └─────────────────────────┘ │───►│ Consumer │
│ Retained for 7 days │ │ Group B │
└─────────────────────────────────┘ │ (Analytics) │
└──────────────┘
Go Example: Kafka Producer with confluent-kafka-go
package main
import (
"encoding/json"
"log"
"github.com/confluentinc/confluent-kafka-go/kafka"
)
type OrderEvent struct {
OrderID string `json:"order_id"`
CustomerID string `json:"customer_id"`
EventType string `json:"event_type"`
Total float64 `json:"total"`
}
func main() {
producer, err := kafka.NewProducer(&kafka.ConfigMap{
"bootstrap.servers": "kafka:9092",
})
if err != nil {
log.Fatalf("Failed to create producer: %s", err)
}
defer producer.Close()
event := OrderEvent{
OrderID: "order-1234",
CustomerID: "cust-5678",
EventType: "ORDER_CREATED",
Total: 149.99,
}
value, _ := json.Marshal(event)
err = producer.Produce(&kafka.Message{
TopicPartition: kafka.TopicPartition{
Topic: &[]string{"order-events"}[0],
Partition: kafka.PartitionAny,
},
Value: value,
Key: []byte(event.OrderID),
}, nil)
if err != nil {
log.Printf("Failed to produce message: %s", err)
}
producer.Flush(15 * 1000) // 15 second timeout
log.Println("Event published to Kafka")
}
RabbitMQ vs Kafka: Quick Selection Guide
| Requirement | Choose | |---|---| | Complex routing (fanout, topic matching) | RabbitMQ | | Message replay / event sourcing | Kafka | | > 100k messages/sec sustained | Kafka | | Small messages, simple queues | RabbitMQ | | Long-term event retention | Kafka | | Protocol flexibility (STOMP, MQTT) | RabbitMQ | | Consumer groups with parallel processing | Kafka | | Dead-letter queue handling | RabbitMQ |
Event-Driven Architecture
Event-driven architecture (EDA) represents the highest level of decoupling in microservices communication. Services don't call each other — they emit events and react to events. This pattern is the backbone of modern reactive systems and pairs naturally with Kafka, AWS EventBridge, or NATS.
Core Concept: Event Notifications vs Event-Carried State Transfer
┌──────────────────┐ ┌──────────────────────┐
│ Order Service │ │ Inventory Service │
│ │ Event │ │
│ "OrderCreated" ├────────►│ Reduce stock │
│ │ │ │
└──────────────────┘ └──────────────────────┘
│ │
│ Event │ Event
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ Notification │ │ Analytics Service │
│ Service │ │ Update dashboard │
└──────────────────┘ └──────────────────────┘
Event Notification — Just says "something happened" with minimal data. Consumers must query back for details.
Event-Carried State Transfer — Contains the full state change. Consumers are fully self-sufficient — no callback needed.
Building an Event-Driven System (TypeScript)
// events/order-events.ts
export interface OrderCreatedEvent {
type: 'OrderCreated';
data: {
orderId: string;
customerId: string;
items: Array<{ sku: string; quantity: number; price: number }>;
total: number;
createdAt: string;
};
}
export interface OrderCancelledEvent {
type: 'OrderCancelled';
data: {
orderId: string;
reason: string;
refundedAt: string;
};
}
export type OrderEvent = OrderCreatedEvent | OrderCancelledEvent;
// EventBus — lightweight in-process event bus
// (use Kafka/NATS in production for cross-service)
type EventHandler<T> = (event: T) => Promise<void>;
class EventBus {
private handlers = new Map<string, EventHandler<any>[]>();
subscribe<T>(eventType: string, handler: EventHandler<T>) {
const existing = this.handlers.get(eventType) || [];
this.handlers.set(eventType, [...existing, handler]);
}
async publish<T>(event: { type: string; data: T }) {
const handlers = this.handlers.get(event.type) || [];
await Promise.all(handlers.map(h => h(event.data)));
}
}
export const eventBus = new EventBus();
// services/order-service.ts
import { eventBus, OrderCreatedEvent } from '../events/order-events';
async function createOrder(orderData: any) {
// 1. Persist the order
const order = await saveOrder(orderData);
// 2. Emit the event — fire and forget
const event: OrderCreatedEvent = {
type: 'OrderCreated',
data: {
orderId: order.id,
customerId: order.customerId,
items: order.items,
total: order.total,
createdAt: order.createdAt,
},
};
await eventBus.publish(event);
return order;
}
// services/inventory-service.ts
import { eventBus } from '../events/order-events';
// React to OrderCreated events independently
eventBus.subscribe('OrderCreated', async (data: any) => {
for (const item of data.items) {
await reduceStock(item.sku, item.quantity);
console.log(`Stock reduced for SKU ${item.sku}`);
}
});
Benefits of Event-Driven Architecture
- Extreme decoupling — The Order Service doesn't know Inventory exists
- Easy to add new consumers — Just subscribe to existing events; zero producer changes
- Natural audit trail — The event log becomes your system's source of truth
- Resilience — Consumers can process events even after extended downtime
Challenges to Watch For
- Eventual consistency — UI must handle "pending" states gracefully
- Event schema evolution — Use schema registries (Confluent, Protobuf) to manage change
- Debugging difficulty — Distributed tracing (Jaeger, OpenTelemetry) is non-optional
- Duplicate event handling — Design consumers to be idempotent
Service Mesh (Istio & Linkerd)
A service mesh provides a dedicated infrastructure layer for service-to-service communication — handling load balancing, encryption, observability, and reliability without touching application code.
What a Service Mesh Solves
Without a service mesh, every microservice must implement its own:
- mTLS for encryption
- Retries and circuit breaking
- Distributed tracing headers
- Metrics and logging
- Load balancing strategies
With a service mesh, a sidecar proxy (Envoy for Istio, Linkerd2-proxy for Linkerd) intercepts all traffic and handles these cross-cutting concerns transparently.
┌─────────────────────────────────────────────────┐
│ Pod / Container │
│ │
│ ┌───────────┐ ┌──────────────────┐ │
│ │ Service A │◄──────►│ Sidecar Proxy │ │
│ │ (App) │ loop │ (Envoy/Linkerd) │ │
│ └───────────┘ back └────────┬─────────┘ │
│ │ │
└──────────────────────────────────┼───────────────┘
│
mTLS + Retry + Trace
│
┌──────────────────────────────────┼───────────────┐
│ Pod / Container│ │
│ ▼ │
│ ┌───────────┐ ┌──────────────────┐ │
│ │ Sidecar │◄──────►│ Service B │ │
│ │ Proxy │ loop │ (App) │ │
│ └───────────┘ back └──────────────────┘ │
│ │
└─────────────────────────────────────────────────┘
Istio Traffic Management Example
Istio uses Custom Resource Definitions (CRDs) to configure routing, retries, and timeouts. Here's a VirtualService that defines retry policy and circuit breaking:
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
name: order-service
namespace: production
spec:
hosts:
- order-service
http:
- match:
- uri:
prefix: "/orders"
route:
- destination:
host: order-service
subset: v2
weight: 90
- destination:
host: order-service
subset: v1
weight: 10
retries:
attempts: 3
perTryTimeout: 2s
retryOn: "5xx,reset,connect-failure,refused-stream"
timeout: 10s
---
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
name: order-service
namespace: production
spec:
host: order-service
trafficPolicy:
connectionPool:
tcp:
maxConnections: 100
http:
http2MaxRequests: 1000
maxRequestsPerConnection: 10
outlierDetection:
consecutive5xxErrors: 5
interval: 30s
baseEjectionTime: 30s
maxEjectionPercent: 50
subsets:
- name: v1
labels:
version: "1"
- name: v2
labels:
version: "2"
Linkerd: The Lightweight Alternative
Linkerd offers the same core features — mTLS, retries, golden-signal metrics — with a dramatically simpler control plane and lower resource overhead. The configuration model uses annotations instead of CRDs for many features:
apiVersion: v1
kind: Namespace
metadata:
name: production
annotations:
linkerd.io/inject: enabled
---
# Pods in this namespace automatically get sidecar-injected.
# Configure retry via Service-level annotation:
apiVersion: v1
kind: Service
metadata:
name: order-service
annotations:
config.linkerd.io/retry-attempts: "3"
config.linkerd.io/timeout: "10s"
spec:
selector:
app: order-service
ports:
- port: 80
targetPort: 8080
Istio vs Linkerd: Choosing a Service Mesh
| Factor | Istio | Linkerd | |---|---|---| | Feature richness | Extensive (traffic policies, WASM plugins) | Core features only | | Resource overhead | High (Envoy sidecars, large control plane) | Low (Rust sidecars, minimal control plane) | | Learning curve | Steep | Gentle | | Observability | Deep, integrates with Kiali | Built-in dashboards, simpler | | Production maturity | Battle-tested at scale (Google, IBM) | Production-ready, lighter deployments | | Best for | Large orgs needing fine-grained control | Teams wanting mTLS + metrics without complexity |
API Gateway Pattern
The API Gateway is the single entry point for all external clients. It handles request routing, authentication, rate limiting, and response aggregation — keeping individual microservices focused on business logic.
┌─────────────────────┐
Mobile App ────►│ │──► Auth Service
│ API Gateway │
Web Client ────►│ │──► Order Service
│ (Kong / AWS API │
Partner API ───►│ Gateway / Traefik)│──► Product Service
│ │
└─────────┬───────────┘──► Payment Service
│
┌─────────▼───────────┐
│ Rate Limiter │
│ Auth Validator │
│ Request Router │
│ Response Aggregator│
└─────────────────────┘
What an API Gateway Does
- Authentication & Authorization — Validates JWT/API keys before forwarding
- Rate Limiting — Protects backend services from abuse
- Request Routing — Maps external URLs to internal services
- Response Aggregation — Combines responses from multiple services into one payload
- Protocol Translation — Converts external REST to internal gRPC
- Caching — Short-circuits repeated read requests
Example: Kong API Gateway Configuration ( declarative YAML )
_format_version: "3.0"
services:
- name: order-service
url: http://order-service.production:8080
routes:
- name: orders-route
paths:
- /api/orders
methods:
- GET
- POST
- PUT
strip_path: false
plugins:
- name: jwt-auth
- name: rate-limiting
config:
minute: 100
policy: redis
redis_host: redis.production
- name: correlation
config:
header_name: X-Request-ID
- name: product-service
url: http://product-service.production:8080
routes:
- name: products-route
paths:
- /api/products
strip_path: false
plugins:
- name: key-auth
- name: rate-limiting
config:
minute: 1000
consumers:
- username: mobile-app
jwt_secrets:
- secret: "your-256-bit-secret"
algorithm: HS256
When You Need an API Gateway (and When You Don't)
Use an API Gateway when:
- You have multiple client types (mobile, web, third-party)
- Different services need different auth policies
- You need response aggregation to reduce client-side round trips
- Rate limiting and quotas are required
Skip it when:
- You have a single client and a handful of services
- Your architecture is purely internal (use service mesh instead)
- The gateway becomes a bottleneck for your team's deployment velocity
Saga Pattern for Distributed Transactions
In a monolith, a database transaction guarantees ACID properties. In microservices, a single business operation might span multiple services, each with its own database. Two-Phase Commit (2PC) across microservices is a non-starter — it's slow, fragile, and creates tight coupling.
The Saga pattern solves this by breaking a distributed transaction into a sequence of local transactions, each with a compensating action for rollback.
Orchestrated Saga vs Choreographed Saga
Choreography — Each service emits events that trigger the next step. No central coordinator.
Order Service ──► "OrderCreated" ──► Payment Service
│
"PaymentProcessed"
│
▼
Inventory Service
│
"StockReserved"
│
▼
Shipping Service
Orchestration — A central orchestrator commands each service and handles compensation.
┌──────────────────┐
│ Saga │
│ Orchestrator │
└────┬──────┬──────┘
│ │
create │ │ charge
┌────▼──┐ ┌─▼────────┐
│ Order │ │ Payment │
│ Svc │ │ Service │
└───────┘ └──────────┘
│ │
reserve │ │ schedule
┌────▼──┐ ┌─▼────────┐
│Stock │ │ Shipping │
│ Svc │ │ Service │
└───────┘ └──────────┘
Orchestrated Saga Implementation (Python)
from enum import Enum
from dataclasses import dataclass
from typing import Optional
import asyncio
class SagaState(Enum):
PENDING = "pending"
ORDER_CREATED = "order_created"
PAYMENT_PROCESSED = "payment_processed"
STOCK_RESERVED = "stock_reserved"
COMPLETED = "completed"
COMPENSATING = "compensating"
FAILED = "failed"
@dataclass
class SagaContext:
order_id: str
customer_id: str
amount: float
items: list
state: SagaState = SagaState.PENDING
payment_id: Optional[str] = None
reservation_id: Optional[str] = None
class OrderSagaOrchestrator:
def __init__(self, order_svc, payment_svc, inventory_svc, shipping_svc):
self.order_svc = order_svc
self.payment_svc = payment_svc
self.inventory_svc = inventory_svc
self.shipping_svc = shipping_svc
async def execute(self, ctx: SagaContext) -> bool:
"""Execute the saga forward; compensate on any failure."""
try:
# Step 1: Create order
ctx.order_id = await self.order_svc.create(ctx.customer_id, ctx.items)
ctx.state = SagaState.ORDER_CREATED
# Step 2: Process payment
ctx.payment_id = await self.payment_svc.charge(
ctx.customer_id, ctx.amount
)
ctx.state = SagaState.PAYMENT_PROCESSED
# Step 3: Reserve inventory
ctx.reservation_id = await self.inventory_svc.reserve(ctx.items)
ctx.state = SagaState.STOCK_RESERVED
# Step 4: Schedule shipping
await self.shipping_svc.schedule(ctx.order_id)
ctx.state = SagaState.COMPLETED
return True
except Exception as e:
print(f"Saga failed at state {ctx.state}: {e}")
await self.compensate(ctx)
return False
async def compensate(self, ctx: SagaContext) -> None:
"""Roll back completed steps in reverse order."""
ctx.state = SagaState.COMPENSATING
if ctx.state.value in ["stock_reserved", "completed"]:
try:
await self.inventory_svc.release(ctx.reservation_id)
print(f"Released stock reservation {ctx.reservation_id}")
except Exception as e:
print(f"Compensation failed (inventory): {e}")
if ctx.payment_id:
try:
await self.payment_svc.refund(ctx.payment_id)
print(f"Refunded payment {ctx.payment_id}")
except Exception as e:
print(f"Compensation failed (payment): {e}")
if ctx.order_id:
try:
await self.order_svc.cancel(ctx.order_id)
print(f"Cancelled order {ctx.order_id}")
except Exception as e:
print(f"Compensation failed (order): {e}")
ctx.state = SagaState.FAILED
Saga Design Principles
- Idempotency — Every step must be safely retryable. Network hiccups will cause duplicate calls.
- Commutativity — Design compensations so order doesn't matter when possible.
- Observability — Log every saga step. Use distributed tracing to visualize the full flow.
- Human intervention fallback — Some compensations can't be automated (e.g., shipped physical goods). Have a manual reconciliation process.
Circuit Breaker Pattern
When a downstream service is degraded, continuing to send requests makes things worse. The Circuit Breaker pattern prevents cascading failures by failing fast when a service is unhealthy.
Three States of a Circuit Breaker
┌─────────────────────┐
│ CLOSED │ ← Normal operation, requests flow through
│ (all requests pass) │
└─────────┬───────────┘
│ failure rate > threshold (e.g., 50%)
▼
┌─────────────────────┐
│ OPEN │ ← Requests fail immediately (fallback)
│ (requests blocked) │
└─────────┬───────────┘
│ after cooldown period (e.g., 30s)
▼
┌─────────────────────┐
│ HALF-OPEN │ ← Limited requests allowed as probe
│ (test if recovered) │
└─────────┬───────────┘
│ │
success failure
│ │
▼ └──► back to OPEN
back to CLOSED
Circuit Breaker Implementation in TypeScript
type CircuitState = 'CLOSED' | 'OPEN' | 'HALF_OPEN';
interface CircuitBreakerOptions {
failureThreshold: number; // e.g., 5 failures
successThreshold: number; // e.g., 3 successes to close
timeout: number; // cooldown ms, e.g., 30000
resetTimeout: number; // time before half-open, e.g., 30000
monitoringPeriod: number; // evaluation window ms, e.g., 60000
}
class CircuitBreaker<T> {
private state: CircuitState = 'CLOSED';
private failureCount = 0;
private successCount = 0;
private lastFailureTime: number | null = null;
private readonly options: CircuitBreakerOptions;
constructor(
private fn: (...args: any[]) => Promise<T>,
options: Partial<CircuitBreakerOptions> = {}
) {
this.options = {
failureThreshold: 5,
successThreshold: 3,
timeout: 30000,
resetTimeout: 30000,
monitoringPeriod: 60000,
...options,
};
}
async call(...args: any[]): Promise<T> {
if (this.state === 'OPEN') {
if (this.shouldAttemptReset()) {
this.state = 'HALF_OPEN';
console.log('Circuit breaker: OPEN → HALF_OPEN');
} else {
throw new Error('Circuit breaker is OPEN — failing fast');
}
}
try {
const result = await this.fn(...args);
this.onSuccess();
return result;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') {
this.successCount++;
if (this.successCount >= this.options.successThreshold) {
this.state = 'CLOSED';
this.successCount = 0;
console.log('Circuit breaker: HALF_OPEN → CLOSED');
}
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.state === 'HALF_OPEN') {
this.state = 'OPEN';
this.successCount = 0;
console.log('Circuit breaker: HALF_OPEN → OPEN');
return;
}
if (this.failureCount >= this.options.failureThreshold) {
this.state = 'OPEN';
console.log(`Circuit breaker: CLOSED → OPEN (${this.failureCount} failures)`);
}
}
private shouldAttemptReset(): boolean {
if (this.lastFailureTime === null) return true;
return Date.now() - this.lastFailureTime >= this.options.resetTimeout;
}
getState(): CircuitState {
return this.state;
}
}
Using the Circuit Breaker
// Wrap any service call
const breaker = new CircuitBreaker(
async (orderId: string) => {
const response = await fetch(`http://payment-service:8080/charge/${orderId}`);
if (!response.ok) throw new Error(`Payment failed: ${response.status}`);
return response.json();
},
{ failureThreshold: 5, resetTimeout: 30000 }
);
try {
const result = await breaker.call('order-1234');
console.log('Payment processed:', result);
} catch (error) {
// Fallback logic: use cached data, queue for later, etc.
console.error('Payment call failed, using fallback:', error.message);
}
Comparison Matrix & Decision Framework
Choosing the right microservices communication pattern depends on your latency requirements, consistency needs, team structure, and operational maturity. Here's a consolidated comparison to guide your decisions.
Pattern Comparison Matrix
| Pattern | Communication Type | Coupling | Latency | Complexity | Best For | |---|---|---|---|---|---| | REST/HTTP | Synchronous | Medium | Medium (5–20ms) | Low | Public APIs, CRUD operations, simple integrations | | gRPC | Synchronous | Medium | Low (1–3ms) | Medium | Internal service-to-service, high-throughput pipelines | | RabbitMQ | Asynchronous | Low | Low | Medium | Complex routing, task queues, work distribution | | Kafka | Asynchronous | Very Low | Low | High | Event streaming, log aggregation, event sourcing | | Event-Driven (EDA) | Asynchronous | Very Low | Eventual | High | Reactive systems, CQRS, maximal decoupling | | Service Mesh | Infrastructure | — (transparent) | Minimal | High (Istio) / Med (Linkerd) | Cross-cutting concerns: mTLS, tracing, retries | | API Gateway | Synchronous (facade) | Medium | Low | Medium | Client-facing entry point, auth, rate limiting | | Saga | Hybrid | Low | Long-running | High | Distributed transactions across services | | Circuit Breaker | Resilience pattern | — | — | Low | Preventing cascading failures |
Decision Framework: Which Pattern Should I Use?
For a simple CRUD microservice being called by a frontend: → REST via API Gateway
For a high-throughput internal call with strict latency budgets: → gRPC with Protocol Buffers
For fire-and-forget background processing: → RabbitMQ with durable queues
For an audit-grade event log that multiple services consume: → Kafka with event-carried state transfer
For a transaction spanning 3+ services (order + payment + shipping): → Orchestrated Saga with Circuit Breaker protection
For mTLS, distributed tracing, and zero-code reliability: → Service Mesh (Linkerd for simplicity, Istio for advanced needs)
For client-facing systems with auth, rate limiting, and routing: → API Gateway (Kong, Traefik, or cloud-native equivalent)
The Golden Rule
No single pattern is correct for every interaction. Mature microservices architectures combine multiple patterns: gRPC for high-performance internal calls, REST for public-facing APIs, event-driven Kafka for asynchronous workflows, service mesh for reliability, and circuit breakers for resilience. The art is in choosing the right tool for each job without over-engineering.
Conclusion
Microservices communication patterns form the nervous system of your distributed architecture. Getting them right means services that are fast, resilient, and independently evolvable. Getting them wrong means cascading failures, tight coupling, and operational pain.
Start simple: Begin with REST for most communication, add gRPC where latency matters, introduce message queues for asynchronous workloads, and layer in a service mesh when you need transparent reliability. Adopt the Saga pattern when transactions span services, and always protect critical calls with circuit breakers.
The most important decision isn't which pattern to use — it's being intentional about the trade-offs. Every choice in distributed systems involves giving something up. Understanding those trade-offs is what separates senior distributed-systems engineers from the rest.
Stay pragmatic, measure everything, and let your architecture evolve with your actual needs rather than chasing every trend.
Last updated: August 2026. This guide reflects production-tested patterns from teams operating microservices at scale in 2026.