Microservices Communication Patterns

When you split a monolith into microservices, you trade intra-process method calls for network calls. That shift changes everything: you now deal with latency, partial failures, retries, service discovery, and distributed transactions. How services communicate is one of the most consequential architectural decisions you will make — and one of the least carefully considered.

This article maps out the communication patterns available, when to apply each, and how to avoid the traps that turn a microservices architecture into a distributed monolith with all the downsides of both worlds.

Synchronous vs. Asynchronous: The Core Trade-off

Every service-to-service interaction falls into one of two categories:

Synchronous (Request/Response)

  • Caller waits for the response
  • Strong consistency guarantees
  • Simple to reason about
  • Temporal coupling — caller fails if callee is down
  • Cascading failures under load
  • Tools: REST/HTTP, gRPC

Asynchronous (Event/Message)

  • Caller fires and forgets
  • Eventual consistency
  • Services are temporally decoupled
  • Higher operational complexity
  • Requires idempotent consumers
  • Tools: Kafka, RabbitMQ, SQS

Decision rule: Use synchronous when the caller needs the result to continue its work (e.g., fetching user data to build a response). Use asynchronous when the caller just needs to notify that something happened and can continue regardless of what other services do with that notification.

REST vs. gRPC

For synchronous communication, REST over HTTP/1.1 is the default — but it is not always the right choice. gRPC offers significant advantages in internal service-to-service communication.

REST: When and Why

REST remains the best choice for external-facing APIs and any scenario where human readability, broad client compatibility, or browser support matters. JSON is universally understood, tooling is mature, and debugging with curl or Postman is trivial.

gRPC: When and Why

gRPC uses Protocol Buffers (binary serialization) over HTTP/2 and generates strongly typed client/server stubs from a schema definition. The advantages for internal communication are measurable:

// order_service.proto — contract-first API definition syntax = "proto3"; package orders; service OrderService { rpc PlaceOrder (PlaceOrderRequest) returns (PlaceOrderResponse); rpc StreamOrderUpdates (OrderId) returns (stream OrderEvent); } message PlaceOrderRequest { string customer_id = 1; repeated OrderItem items = 2; string payment_method = 3; } message PlaceOrderResponse { string order_id = 1; string status = 2; int64 estimated_delivery_unix = 3; }

Pattern: Use REST for your public API Gateway layer. Use gRPC for internal service-to-service calls. The protocol boundary at the gateway gives you the best of both: broad client compatibility externally and performance + type safety internally.

API Gateway

Clients should not call individual microservices directly. An API Gateway is the single entry point for all external traffic. It handles cross-cutting concerns that would otherwise be duplicated across every service:

Mobile App
Web App
Third-party
↓
API Gateway
auth · rate-limit · routing
↓
Order Service
Catalog Service
User Service
Payment Service
All external traffic enters through the Gateway — downstream services only see authenticated, validated requests

Service Mesh

A Service Mesh (Istio, Linkerd) takes the cross-cutting concerns one step further — to the infrastructure layer. A sidecar proxy (Envoy) is injected alongside every service container and intercepts all inbound/outbound traffic transparently.

This gives you, without any application code changes:

Caveat: A service mesh adds significant operational complexity. It is justified when you have 10+ services with complex traffic management needs. For smaller systems, handle retries and timeouts in application code or at the gateway — do not over-engineer.

The Saga Pattern: Distributed Transactions

In a monolith, a database transaction guarantees ACID properties across multiple operations. In microservices, each service owns its data. There is no global transaction manager. When a business operation spans multiple services — place order, reserve inventory, charge payment, send confirmation — how do you ensure consistency?

The Saga pattern replaces a distributed transaction with a sequence of local transactions, each publishing an event or command that triggers the next step. If a step fails, compensating transactions roll back the preceding steps.

Choreography-based Saga

Each service listens for events and decides what to do next. There is no central coordinator.

# Choreography Saga — order placement flow # 1. Order Service OrderPlaced event → published to Kafka # 2. Inventory Service listens on OrderPlaced: if stock_available: reserve_items() publish InventoryReserved else: publish InventoryReservationFailed # 3. Payment Service listens on InventoryReserved: if charge_succeeds: publish PaymentCharged else: publish PaymentFailed ← triggers compensation # 4. Order Service listens on PaymentFailed: update_order_status(CANCELLED) ← compensating transaction publish OrderCancelled # 5. Inventory Service listens on OrderCancelled: release_reserved_items() ← compensating transaction

Orchestration-based Saga

A central Saga Orchestrator coordinates the flow by sending commands to each service and handling the responses. The business logic lives in one place, making it easier to understand and debug.

// Orchestrator (Java/Spring State Machine) @SagaOrchestrator public class PlaceOrderSaga { @StartSaga @SagaEventHandler(associationProperty = "orderId") public void on(OrderCreatedEvent event) { commandGateway.send(new ReserveInventoryCommand(event.getOrderId(), event.getItems())); } @SagaEventHandler(associationProperty = "orderId") public void on(InventoryReservedEvent event) { commandGateway.send(new ChargePaymentCommand(event.getOrderId(), event.getAmount())); } @SagaEventHandler(associationProperty = "orderId") public void on(PaymentFailedEvent event) { // Compensate: release inventory commandGateway.send(new ReleaseInventoryCommand(event.getOrderId())); commandGateway.send(new CancelOrderCommand(event.getOrderId())); } @EndSaga @SagaEventHandler(associationProperty = "orderId") public void on(PaymentChargedEvent event) { commandGateway.send(new ConfirmOrderCommand(event.getOrderId())); } }

Choreography

  • No single point of failure
  • Services are fully decoupled
  • Hard to track overall flow
  • Cyclic dependencies risk
  • Best for simple, short sagas

Orchestration

  • Business logic in one place
  • Easier to test and debug
  • Central point of failure
  • Orchestrator can become a god object
  • Best for complex, multi-step flows

Service Discovery

In a dynamic environment where service instances scale up and down, hardcoding IP addresses is not viable. Service discovery solves this in two ways:

Key Takeaways

Back to all articles