π Inter-Service Communication Patterns
Splitting an app into services is the easy part. The moment two services need to cooperate, every in-process function call becomes a network call β with latency, failures, and timing to manage. This lesson gives you the toolbox: synchronous calls (REST, gRPC), asynchronous messaging and events, and the resilience patterns that keep it all reliable.
π― Learning Objectives
By the end of this lesson, you will be able to:
- Distinguish synchronous from asynchronous communication and choose the right one per interaction
- Compare REST, gRPC, and GraphQL for service-to-service calls
- Explain the risks of synchronous coupling β availability math, latency accumulation, resource exhaustion
- Apply resilience patterns: circuit breaker, timeout, retry-with-backoff, and bulkhead
- Design an event-driven flow and recognize when Event Sourcing / CQRS fit
Estimated Time: 40β50 minutes β’ Difficulty: Advanced
Hands-on: Design the communication flow and event schema for a food-delivery service.
In This Lesson
Two Ways to Talk
Every interaction between services is fundamentally one of two shapes. In synchronous communication, the caller sends a request and waits for the response before continuing. In asynchronous communication, the caller sends a message and moves on; the response (if any) arrives later.
π‘ Office analogy: Synchronous is a phone call β you wait on the line for an answer. Asynchronous is an email β you send it and get on with your day. An event is the company announcement board β you post that something happened, and whoever cares reacts, without you calling them one by one.
The rule of thumb: use synchronous when the caller genuinely needs the answer to continue (a user waiting on a page), and asynchronous for everything that can happen in the background. Overusing synchronous calls is the number-one cause of fragile microservices.
Synchronous: REST & gRPC
Request/Response over HTTP + REST
The most common style: one service calls another's HTTP endpoint and awaits JSON. Here a service assembles an order view by calling three others. Note the use of Promise.all to fire independent calls in parallel rather than one-after-another:
// order-details.js β aggregating data across services (Node.js, native fetch)
async function getOrderDetails(orderId) {
// 1. Fetch the order
const orderRes = await fetch(`http://order-service/orders/${orderId}`);
if (!orderRes.ok) throw new Error(`Order lookup failed: ${orderRes.status}`);
const order = await orderRes.json();
// 2. Fetch customer + all products in PARALLEL (they don't depend on each other)
const [customer, products] = await Promise.all([
fetch(`http://user-service/users/${order.customerId}`).then(r => r.json()),
Promise.all(
order.items.map(item =>
fetch(`http://product-service/products/${item.productId}`).then(r => r.json())
)
),
]);
return { order, customer, products };
}
β REST design tips for services
- Resource-oriented URLs (
/orders/123), not RPC-style verbs. - Standard status codes and a consistent error body.
- Versioning (URL or header) so you can evolve without breaking callers.
- Idempotent writes where possible, so a safe retry doesn't double-charge.
- Always set a timeout β never wait forever on another service.
gRPC β high-performance RPC
For internal, high-volume calls, gRPC is often a better fit than REST. It uses HTTP/2 and a compact binary format (Protocol Buffers), and generates strongly-typed client/server code from a single contract file:
// order.proto β the contract both sides generate code from
syntax = "proto3";
package ecommerce;
service OrderService {
rpc GetOrder(OrderRequest) returns (Order);
rpc CreateOrder(CreateOrderRequest) returns (Order);
}
message OrderRequest { string order_id = 1; }
message Order {
string order_id = 1;
string customer_id = 2;
string status = 3;
repeated OrderItem items = 4;
double total_amount = 5;
}
message OrderItem {
string product_id = 1;
int32 quantity = 2;
double price = 3;
}
message CreateOrderRequest {
string customer_id = 1;
repeated OrderItem items = 2;
}
| Feature | gRPC | REST |
|---|---|---|
| Transport | HTTP/2, binary (Protobuf) | HTTP/1.1, text (JSON) |
| Contract | Strong (.proto) | Loose (OpenAPI optional) |
| Performance | Higher (compact, multiplexed) | Lower (verbose, sequential) |
| Browser support | Needs a proxy (gRPC-Web) | Native |
| Streaming | Bidirectional streaming | Limited (SSE / WebSockets) |
| Best for | Internal service-to-service | Public / browser-facing APIs |
GraphQL for aggregation
At the edge, GraphQL lets a client fetch exactly the shape it needs in one round trip, with the gateway's resolvers fanning out to backend services. Netflix famously uses this in its API layer so a mobile app makes a single request instead of a dozen.
# One query, the gateway resolves each field from a different service
query GetOrderDetails($orderId: ID!) {
order(id: $orderId) {
id
status
totalAmount
customer { name email } # resolved via User service
items {
quantity
product { name price imageUrl } # resolved via Product service
}
}
}
The Cost of Synchronous
Synchronous calls are simple to reason about, which is exactly why they get overused. Three hidden costs bite at scale.
Temporal coupling & availability math
When A synchronously calls B, A is only as available as B. Chain enough dependencies and your uptime quietly erodes β because availabilities multiply:
β οΈ Each 99.9% dependency costs you
- 1 dependency: 99.9% β 99.8%
- 5 dependencies: 99.9%5 β 99.5%
- 10 dependencies: 99.9%10 β 99.0%
- 20 dependencies: 99.9%20 β 98.0% (β 7 days of downtime a year)
The more synchronous hops in a request path, the lower your effective availability.
Latency accumulation
Every hop adds its own latency, and in a synchronous chain they sum. A request that touches four services can easily spend more time waiting on the wire than doing work:
Resource exhaustion
While a service waits on a slow downstream call, it's holding a connection and a thread. Under load those pile up until the pool is empty and the service stops accepting any requests β a slow dependency turns into a full outage. Timeouts and bulkheads (below) contain this.
Resilience Patterns
If you must call synchronously, wrap those calls in patterns that assume failure.
Timeout β never wait forever
// Fail fast after 2s, then fall back to cached data
async function getProductDetails(productId) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2000);
try {
const res = await fetch(`http://product-service/products/${productId}`, {
signal: controller.signal,
});
return await res.json();
} catch (err) {
if (err.name === 'AbortError') {
console.warn('Product service timed out β serving cached copy');
return getCachedProductDetails(productId); // graceful degradation
}
throw err;
} finally {
clearTimeout(timer);
}
}
Retry with exponential backoff + jitter
Transient failures often clear on a second try β but naive retries can create a "thundering herd" that stampedes a recovering service. Back off exponentially and add random jitter to spread the load:
# Retry with exponential backoff and jitter (Python)
import time, random, requests
def get_with_retry(url, max_retries=3, base_delay=1.0):
for attempt in range(max_retries):
try:
resp = requests.get(url, timeout=2)
resp.raise_for_status()
return resp.json()
except requests.RequestException:
if attempt == max_retries - 1:
raise # give up after the last try
# 1s, 2s, 4s ... plus up to 0.5s of random jitter
delay = base_delay * (2 ** attempt) + random.uniform(0, 0.5)
print(f"Attempt {attempt + 1} failed; retrying in {delay:.2f}s")
time.sleep(delay)
β οΈ Only retry idempotent operations
Retrying a GET is safe. Blindly retrying "charge the card" can double-charge. Make writes idempotent (e.g. with an idempotency key) before you retry them.
Circuit breaker
Covered in the previous lesson: after repeated failures it "trips" open and fails fast, sparing both the caller and the struggling downstream service, then tests recovery in a half-open state. Libraries like Resilience4j (JVM), Polly (.NET), and opossum (Node.js) implement it for you.
Bulkhead
Named after a ship's watertight compartments: isolate resources per dependency (separate thread/connection pools) so one flooded compartment can't sink the ship. If the Payment client's pool is exhausted, calls to the Product service still get through.
Backend for Frontend (BFF)
Give each client type its own thin gateway that aggregates the calls it needs, so the client makes one request instead of many β cutting round trips and tailoring the payload:
Asynchronous Messaging
Asynchronous communication decouples services in time: the sender doesn't wait, and the receiver processes when it's ready. A message broker sits in the middle, holding messages durably so nothing is lost if a consumer is briefly down.
Here the Order service publishes an event to RabbitMQ; a consumer picks it up whenever it's ready, acknowledging on success and re-queuing on failure:
// publisher.js β announce that an order was created
const amqp = require('amqplib');
async function publishOrderCreated(order) {
const conn = await amqp.connect('amqp://localhost');
const channel = await conn.createChannel();
const exchange = 'order_events';
await channel.assertExchange(exchange, 'topic', { durable: true });
const message = Buffer.from(JSON.stringify({
eventType: 'OrderCreated',
timestamp: new Date().toISOString(),
data: order,
}));
// persistent: true β survives a broker restart
channel.publish(exchange, 'order.created', message, { persistent: true });
await channel.close();
await conn.close();
}
// consumer.js β the Payment service reacts to OrderCreated
const amqp = require('amqplib');
async function startPaymentConsumer() {
const conn = await amqp.connect('amqp://localhost');
const channel = await conn.createChannel();
const exchange = 'order_events';
const queue = 'payment_service_orders';
await channel.assertExchange(exchange, 'topic', { durable: true });
await channel.assertQueue(queue, { durable: true });
await channel.bindQueue(queue, exchange, 'order.created');
channel.consume(queue, async (msg) => {
if (!msg) return;
try {
const event = JSON.parse(msg.content.toString());
await processPayment(event.data);
channel.ack(msg); // success β remove from queue
} catch (err) {
console.error('Payment failed:', err);
channel.nack(msg, false, true); // failure β requeue for retry
}
});
}
| Broker | Strengths | Best for |
|---|---|---|
| RabbitMQ | Flexible routing, mature, many protocols | Complex routing, task queues |
| Apache Kafka | High throughput, durable log, replay | Event streaming, event sourcing, analytics |
| Amazon SQS / SNS | Fully managed, serverless-friendly | AWS workloads, simple queues |
| Google Pub/Sub | Managed, global, at-least-once | GCP workloads, global distribution |
| Azure Service Bus | Enterprise features, AMQP | Azure workloads, integration |
Event-Driven Architecture
Take messaging one step further: instead of commanding a specific service ("process this payment"), a service simply announces that something happened ("an order was created"). Any number of services subscribe and react β and you can add a new subscriber later without touching the publisher.
// Publish an event after persisting state β others decide what to do with it
async function createOrder(order) {
await orderRepository.save(order);
await eventBus.publish('order-events', {
eventType: 'OrderCreated',
orderId: order.id,
customerId: order.customerId,
items: order.items,
totalAmount: order.totalAmount,
occurredAt: new Date().toISOString(),
});
}
π Real-world: Uber
When a rider requests a ride, Uber publishes a RideRequested event. The matching service finds nearby drivers; on acceptance a RideAccepted event fans out so payment pre-authorizes the card, notifications alert the rider, mapping starts ETAs, and analytics records the match β all reacting independently. New features become new subscribers, not edits to existing services.
β οΈ Trade-offs of going event-driven
You gain loose coupling and easy extensibility, but flows become harder to trace end-to-end, and you must embrace eventual consistency β for a moment, different services hold different views of the world. Distributed tracing (trace IDs on every message) becomes essential.
Event Sourcing & CQRS
Two advanced patterns that build on event-driven thinking. Reach for them only when the domain genuinely needs them.
Event Sourcing
Instead of storing only the current state, store the full sequence of events that produced it. Current state is derived by replaying them. A bank account is the classic example β you keep every deposit and withdrawal, not just the balance:
// A bank account rebuilt from its event history
class BankAccount {
constructor(id) { this.id = id; this.balance = 0; this.pending = []; }
deposit(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
this.record({ type: 'FundsDeposited', amount, at: Date.now() });
}
withdraw(amount) {
if (amount <= 0) throw new Error('Amount must be positive');
if (amount > this.balance) throw new Error('Insufficient funds');
this.record({ type: 'FundsWithdrawn', amount, at: Date.now() });
}
// Apply an event to state, and queue it for persistence
record(event) { this.apply(event); this.pending.push(event); }
apply(event) {
if (event.type === 'FundsDeposited') this.balance += event.amount;
else if (event.type === 'FundsWithdrawn') this.balance -= event.amount;
}
// Reconstruct state by replaying stored events
static fromEvents(id, events) {
const acct = new BankAccount(id);
for (const e of events) acct.apply(e);
return acct;
}
}
β Why event sourcing
- Complete audit trail β every change is a recorded fact.
- Time travel β reconstruct state as of any past moment.
- Replay β rebuild read models or fix bugs by re-processing events.
CQRS β Command Query Responsibility Segregation
Split the model that writes data (commands) from the model(s) that read it (queries). Writes go through one path; reads are served from purpose-built, query-optimized projections kept up to date via events.
For a product catalog, the write side handles CreateProduct and ChangePrice, while separate read models power search results, the product detail page, and analytics reports β each shaped for its query.
Choosing a Pattern
Match the pattern to the interaction, not to fashion. Real systems mix all of them.
| Scenario | Recommended | Why |
|---|---|---|
| User waiting for an immediate answer | Synchronous | Needs instant feedback |
| Background / deferred work | Asynchronous | No one is waiting |
| Guaranteed delivery required | Async with durable queue | Broker persists the message |
| One action, many reactions | Event-driven | Loose coupling, extensible |
| High throughput / traffic spikes | Asynchronous | Absorbs bursts via the queue |
| Simple data lookup | Synchronous | Simplest for a direct read |
π‘ Hybrid in practice
A ride-share app uses synchronous REST/gRPC for the ride request and payment, async queues for driver-location updates and receipts, events for status changes and notifications, and event sourcing for the auditable ride history. One pattern rarely fits a whole system.
Hands-on Exercise
ποΈ Design a Food-Delivery Communication Plan
Objective: Choose the right communication pattern for each interaction and design a real event schema.
Services: Customer, Restaurant, Order, Payment, Delivery, Notification.
Tasks:
- For each interaction in "Place Order," decide synchronous or asynchronous and justify it.
- Write a JSON event schema for
OrderCreatedincluding an event type, version, id, timestamp, and a data payload. - Sketch the flow for placing an order, marking which calls block the customer and which happen in the background.
- Pick two failure scenarios (payment declined, no driver available) and describe your handling β retry, compensate, or notify.
π‘ Hint
The customer only needs to wait for "order accepted & payment authorized." Everything after that β notifying the restaurant, assigning a driver, sending SMS β can be event-driven. A declined payment should fail synchronously (the user is watching); a missing driver can retry/expand the search asynchronously.
β Example event schema
{
"eventType": "OrderCreated",
"version": "1.0",
"id": "ede39fde-1d32-4231-a1f5-1a63f7c3ef77",
"source": "order-service",
"timestamp": "2026-03-15T14:30:45.123Z",
"correlationId": "cust-order-5678",
"data": {
"orderId": "ORD-12345",
"customerId": "CUST-6789",
"restaurantId": "REST-1234",
"items": [
{ "itemId": "ITEM-001", "name": "Margherita Pizza", "quantity": 2, "price": 12.99 }
],
"totalAmount": 25.98,
"estimatedDeliveryTime": "2026-03-15T15:15:00.000Z"
}
}
Sync vs async: OrderβPayment is synchronous (user waiting); Restaurant notification, Delivery assignment, and Notification are all triggered by the OrderCreated event asynchronously. Payment declined: fail the request synchronously with a clear message. No driver: retry with an expanding radius, then notify the customer of a delay.
π― Quick Quiz
Question 1: If a request path synchronously depends on 10 services that each have 99.9% uptime, roughly what is the combined availability?
Question 2: Why add random jitter to exponential-backoff retries?
Question 3: A key advantage of event-driven communication over direct synchronous calls is thatβ¦
Summary & Quiz
π Key Takeaways
- Synchronous (REST, gRPC, GraphQL) is simple and immediate but creates temporal coupling β availability multiplies down and latency adds up.
- gRPC suits internal high-volume calls; REST/GraphQL suit browser- and client-facing APIs.
- Wrap synchronous calls in timeouts, backoff retries, circuit breakers, and bulkheads β and only retry idempotent operations.
- Asynchronous messaging and events decouple services in time, absorb spikes, and let you add reactors without touching publishers β at the cost of eventual consistency.
- Event Sourcing and CQRS are powerful for audit-heavy, read-heavy domains; use them deliberately, not by default.
π Further Reading
- Enterprise Integration Patterns
- microservices.io β Communication Styles
- gRPC β Introduction
- Martin Fowler β What do you mean by "Event-Driven"?
π What's Next?
You've seen how services talk. Next we build the front door that ties it together: an API Gateway that routes, aggregates, authenticates, rate-limits, and observes traffic on behalf of every client.
π Well done!
You can now choose and harden the communication style for any service interaction. On to the gateway.