Inter-Service Communication Patterns
Choosing how services talk: synchronous REST and gRPC versus asynchronous messaging, the coupling each creates, correlation propagation, and graceful degradation.
On this page
Every call between services is a coupling decision. Synchronous calls are simple and make your availability depend on someone else's; asynchronous ones are resilient and make your logic harder to follow. Choosing well per interaction matters more than choosing one style globally.
Key Takeaways
- Synchronous couples availability and latency; asynchronous couples schema only.
- A chain of synchronous calls multiplies failure probability — that is the real anti-pattern.
- Use queries synchronously, facts asynchronously.
- Always propagate correlation and trace context, whichever transport you use.
- Design degraded responses for anything not strictly required.
The cost of a synchronous chain
That arithmetic is why "just call the other service" stops working around the third hop. Each addition makes the whole path slower and less available, and no amount of retrying fixes a multiplicative problem.
Two mitigations. Flatten the chain — if the gateway needs data from four services, calling them in parallel gives you the maximum latency rather than the sum, and one failure need not fail the others. Cache or replicate the data that rarely changes, so a customer's tier is read locally rather than fetched.
Synchronous REST
@Component
public class CustomerClient {
private final RestClient client;
public CustomerClient(RestClient.Builder builder) {
this.client = builder
.baseUrl("http://customer-service")
.requestInterceptor((request, body, execution) -> {
// Propagate context on every call, or traces and log
// correlation stop at this boundary.
request.getHeaders().add("X-Correlation-Id",
Objects.toString(MDC.get("correlationId"), ""));
return execution.execute(request, body);
})
.build();
}
@CircuitBreaker(name = "customer", fallbackMethod = "fallback")
@Retry(name = "customer")
public CustomerView fetch(String id) {
return client.get().uri("/api/v1/customers/{id}", id)
.retrieve().body(CustomerView.class);
}
// Degraded, not failed. The order can still be placed without the tier.
private CustomerView fallback(String id, Throwable ex) {
log.warn("customer service unavailable for {} — using default tier", id);
return CustomerView.unknown(id);
}
}The fallback is where the design decision lives. Ask, for every synchronous call: if this dependency is down, must my request fail? If the answer is no — and it often is — the fallback should return something usable and mark it as degraded.
gRPC
For high-volume internal traffic, Protocol Buffers over HTTP/2 is meaningfully more efficient:
syntax = "proto3";
package acme.pricing.v1;
service PricingService {
rpc Quote(QuoteRequest) returns (QuoteResponse);
rpc StreamPrices(PriceSubscription) returns (stream PriceUpdate);
}
message QuoteRequest {
string sku = 1;
int32 quantity = 2;
string customer_tier = 3;
}
message QuoteResponse {
int64 unit_price_minor_units = 1;
string currency = 2;
int64 total_minor_units = 3;
}@Service
public class PricingClient {
@GrpcClient("pricing")
private PricingServiceGrpc.PricingServiceBlockingStub stub;
public Money quote(String sku, int quantity, Tier tier) {
var response = stub
.withDeadlineAfter(2, TimeUnit.SECONDS) // always set a deadline
.quote(QuoteRequest.newBuilder()
.setSku(sku).setQuantity(quantity)
.setCustomerTier(tier.name()).build());
return Money.of(response.getTotalMinorUnits(), response.getCurrency());
}
}The four call types — unary, server-streaming, client-streaming and bidirectional — cover cases REST handles awkwardly. Server streaming in particular is much cleaner than polling or SSE for internal feeds.
The costs are real: a build-time codegen step, binary payloads you cannot read with curl, weaker
browser support, and a schema that must be shared. It earns its place for internal hot paths, not as
a default.
Asynchronous messaging
@Service
public class OrderService {
@Transactional
public Order place(PlaceOrderCommand command) {
Order order = repository.save(command.toOrder());
// Same transaction as the state change. The order service does not
// know or care which services consume this.
outbox.publish(new OrderPlaced(
UUID.randomUUID().toString(), order.id(), order.customerId(),
order.totalMinorUnits(), order.currency(), Instant.now()));
return order;
}
}The coupling here is only to the schema. Inventory, analytics and notifications each consume independently; adding a fourth consumer requires no change to the order service, and a consumer being down delays processing rather than failing the order.
What you give up is immediacy and simplicity. The caller gets no result, errors surface somewhere else entirely, and reasoning about the end-to-end flow means reading several services. That is the trade — resilience for traceability.
Choosing per interaction
That top-left branch is the one teams skip. A customer's tier, a product's category, a currency rate — these change rarely and are read constantly. Replicating them locally via an event stream removes a synchronous dependency permanently, at the cost of eventual consistency measured in seconds.
Contract testing
Whatever the transport, the contract needs a test that fails when it breaks. Consumer-driven contract testing is the mechanism: the consumer declares what it expects, and the provider's build verifies it still delivers that.
Contract.make {
request {
method GET()
url '/api/v1/customers/cus_8Fj3kQ'
}
response {
status OK()
headers { contentType(applicationJson()) }
body([ id: 'cus_8Fj3kQ', tier: 'PREMIUM', country: 'DE' ])
}
}Spring Cloud Contract generates provider tests from this and publishes a stub the consumer tests against. Neither side needs the other running, and a breaking change fails the producer's build rather than the consumer's production.
What to take away
Ask whether a call is needed at all before choosing how to make it — replicated data beats both transports. Use synchronous calls for queries the caller must have, asynchronous events for facts others may care about, and keep synchronous chains short. Propagate correlation everywhere, design degraded responses, and put a contract test on every boundary.
Frequently Asked Questions
Is synchronous communication between microservices an anti-pattern?
When is gRPC worth the extra tooling?
How do I avoid a service being unavailable because a dependency is?
Related tutorials
- API Gateway with Spring Cloud GatewayBuilding an edge gateway: route predicates, the filter catalogue, custom global filters for auth and correlation, Redis rate limiting, and circuit breakers at the edge.
- Circuit Breakers & Resilience4jResilience4j in production: how the circuit breaker state machine works, tuning the sliding window, combining retry and bulkhead correctly, and the decorator order that matters.
- Service Discovery & RegistrationClient-side versus server-side discovery, running Eureka properly including self-preservation, Consul as an alternative, and why Kubernetes usually makes a separate registry unnecessary.
- Distributed Tracing & ObservabilityDistributed tracing that actually helps: spans and trace context, W3C propagation across HTTP and messaging, sampling strategies, and correlating traces with logs and metrics.