Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

Inter-Service Communication Patterns

Choosing how services talk: synchronous REST and gRPC versus asynchronous messaging, the coupling each creates, correlation propagation, and graceful degradation.

Intermediate5 min readUpdated
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

Synchronous dependencies multiply. Four services at three nines give you barely two and a half nines together.

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

CustomerClient.java
@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:

pricing.proto
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;
}
PricingGrpcClient.java
@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

AsyncPublication.java
@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

The first question is whether a call is needed at all. Replicating slow-changing data removes the dependency entirely.

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.

contracts/shouldReturnCustomer.groovy
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?
No, but a chain of them is. One synchronous hop to fetch data the caller genuinely needs to answer its request is fine. Five hops deep means your availability is the product of five services and your latency is their sum, which is a distributed monolith wearing microservice clothing.
When is gRPC worth the extra tooling?
For internal, high-volume, low-latency service-to-service calls where the schema is stable and both sides are yours. The binary encoding and HTTP/2 multiplexing are a real win at volume. For public APIs, browser clients or anything needing human-readable debugging, REST remains the better trade.
How do I avoid a service being unavailable because a dependency is?
Ask whether the call is genuinely required to answer. Often it is not — a recommendation panel, a loyalty balance, an enriched label. Those should degrade to a default when the dependency is down, behind a circuit breaker, rather than failing the whole response.

Related tutorials