Inter-Service Communication
Once you split a system into services, the calls between them become your most important design decisions. The first choice is synchronous (request/response over HTTP) versus asynchronous (events over a broker). Each has sharp trade-offs around coupling, consistency, and failure. This page covers both styles in Spring Boot and the disciplines — timeouts, retries, idempotency — that keep them reliable.
Synchronous vs asynchronous
SYNCHRONOUS ASYNCHRONOUS
orders ──HTTP request──► inventory orders ──event──► [broker] ──► inventory
◄─────response────────┘ (fire-and-forget, decoupled)
| Aspect | Synchronous (HTTP) | Asynchronous (messaging) |
|---|---|---|
| Coupling | Temporal — callee must be up now | Decoupled — broker buffers |
| Latency | Immediate response | Eventual processing |
| Consistency | Easy to reason about | Eventual consistency |
| Failure handling | Caller waits / retries | Broker retries, dead-letter |
| Backpressure | Hard (callee can be overwhelmed) | Natural (queue absorbs load) |
| Best for | Reads, queries, request/reply | State changes, fan-out, workflows |
Tip: A useful default — use synchronous calls for queries (you need the answer now) and asynchronous events for commands that propagate state (other services react in their own time). This keeps write paths resilient.
Synchronous: OpenFeign
OpenFeign turns a Java interface into an HTTP client and integrates with discovery and load balancing automatically.
@FeignClient(name = "inventory-service") // resolved + load-balanced via discovery
public interface InventoryClient {
@GetMapping("/inventory/{sku}")
InventoryResponse getStock(@PathVariable String sku);
}
@Service
@RequiredArgsConstructor
public class OrderService {
private final InventoryClient inventory;
public void reserve(String sku) {
InventoryResponse stock = inventory.getStock(sku); // a plain method call
// ...
}
}
Enable it once on a config class:
@SpringBootApplication
@EnableFeignClients
public class OrdersApplication { }
Synchronous: RestClient / WebClient
For more control, call services directly with a @LoadBalanced RestClient (blocking) or WebClient (reactive), addressing them by logical name:
InventoryResponse stock = inventoryClient.get()
.uri("http://inventory-service/inventory/{sku}", sku)
.retrieve()
.body(InventoryResponse.class);
See Load Balancing for the @LoadBalanced setup.
Asynchronous: messaging
For state changes, publish an event and let interested services consume it on their own schedule. With RabbitMQ:
@Service
@RequiredArgsConstructor
public class OrderService {
private final RabbitTemplate rabbit;
@Transactional
public Order place(OrderRequest req) {
Order order = repository.save(Order.from(req));
rabbit.convertAndSend("orders.exchange", "order.placed",
new OrderPlaced(order.getId(), req.items()));
return order;
}
}
@Component
public class InventoryConsumer {
@RabbitListener(queues = "inventory.order-placed")
public void on(OrderPlaced event) {
// reserve stock; failure here doesn't break the order flow
}
}
This decouples orders from inventory’s availability and underpins the Saga Pattern. See Messaging with Spring for brokers and patterns.
Timeouts
A call without a timeout is a latent outage. Always bound both connect and read time.
# OpenFeign timeouts (per client or default)
spring:
cloud:
openfeign:
client:
config:
inventory-service:
connect-timeout: 1000
read-timeout: 3000
Warning: The default read timeout for many HTTP clients is infinite. One stuck dependency can exhaust the caller’s threads. Set explicit, aggressive timeouts and pair them with a circuit breaker.
Retries and idempotency
Networks fail transiently, so retries are essential — but only safe for idempotent operations. GET, PUT, and DELETE are naturally idempotent; POST usually is not.
For non-idempotent commands, make them idempotent with an idempotency key the receiver deduplicates on:
@PostMapping("/payments")
public PaymentResponse charge(@RequestHeader("Idempotency-Key") String key,
@RequestBody ChargeRequest req) {
return payments.findByKey(key) // already processed? return prior result
.orElseGet(() -> payments.charge(key, req));
}
| Operation | Idempotent? | Safe to retry blindly? |
|---|---|---|
GET /orders/42 | Yes | Yes |
PUT /orders/42 (full replace) | Yes | Yes |
DELETE /orders/42 | Yes | Yes |
POST /payments | No | Only with an idempotency key |
Combine retries, timeouts, and circuit breaking with Resilience4j — see Circuit Breaker (Resilience4j). And to debug a request as it crosses services, you need Distributed Tracing.