Service Communication in Spring Boot: REST vs Messaging
by Eric Hanson, Backend Developer at Clean Systems Consulting
The Default Is Costing You
Most Spring Boot services default to REST for everything. A new integration shows up on the backlog and someone wires up a RestTemplate or a Feign client, the feature ships, and the coupling is permanent. Six months later, when the downstream service has a degraded weekend and takes your service down with it, you realize the choice wasn't neutral.
REST is synchronous. When service A calls service B over HTTP, A is blocked until B responds. If B is slow, A is slow. If B is down, A fails. The two services are coupled at runtime — not just at the API contract level but at the availability level. Your uptime is now bounded by the product of your dependencies' uptimes.
That's sometimes the right tradeoff. The mistake is making it without thinking.
When REST Is the Right Call
Synchronous HTTP is correct when the caller genuinely needs the response before it can proceed. This is a narrower category than it sounds.
Query operations — fetching a user profile, looking up product details, checking account balance — are the obvious case. The caller needs data. It can't do anything useful until it has it. REST is appropriate.
Operations where the result determines the next action — fraud checks before authorizing a payment, inventory validation before confirming an order — are also legitimate synchronous calls. If the answer changes what you do next, you need the answer now.
External-facing APIs are REST by default because HTTP is the universal client contract. Your mobile apps, your third-party integrations, your developer portal — all expect request/response semantics. Don't overthink this one.
In Spring Boot, a Feign client with explicit timeout configuration is the baseline for synchronous service-to-service calls:
@FeignClient(
name = "inventory-service",
configuration = InventoryClientConfig.class
)
public interface InventoryClient {
@GetMapping("/inventory/{sku}/availability")
AvailabilityResponse checkAvailability(
@PathVariable String sku,
@RequestParam int quantity
);
}
@Configuration
public class InventoryClientConfig {
@Bean
public Request.Options requestOptions() {
return new Request.Options(
500, TimeUnit.MILLISECONDS, // connect timeout
2000, TimeUnit.MILLISECONDS, // read timeout
true
);
}
@Bean
public Retryer retryer() {
// Retry once, 500ms interval, for transient failures only
return new Retryer.Default(500, 500, 2);
}
}
The timeouts are not optional. An unconfigured Feign client inherits the JVM's default socket timeout, which is effectively infinite. One slow downstream call without a timeout will exhaust your thread pool under load. Every synchronous client in production needs explicit connect and read timeouts, full stop.
Add Resilience4j circuit breakers for any synchronous call on a critical path:
@CircuitBreaker(name = "inventory", fallbackMethod = "assumeAvailable")
public AvailabilityResponse checkAvailability(String sku, int quantity) {
return inventoryClient.checkAvailability(sku, quantity);
}
private AvailabilityResponse assumeAvailable(String sku, int quantity, Exception ex) {
log.warn("Inventory circuit open for SKU {}, assuming available", sku);
return AvailabilityResponse.available(sku, quantity);
}
The fallback behavior depends on your domain — sometimes assuming available is safe, sometimes it isn't. The point is that you've made a deliberate choice about what happens when the downstream is unavailable, rather than letting a thread block until timeout and propagating the failure up the stack.
When Messaging Is the Right Call
Async messaging belongs anywhere the caller doesn't need an immediate response to proceed — and especially anywhere coupling the caller's availability to the callee's availability would be dangerous.
State change notifications — an order was placed, a payment completed, a user registered — are the canonical case. The service that owns the state change publishes an event. Downstream services that care about it consume it independently. Neither knows about the other. Neither's availability depends on the other's.
Work that takes longer than a request-response cycle — sending emails, generating reports, processing uploads, triggering batch jobs — should never happen inside a synchronous HTTP handler. Put it on a queue.
Fan-out — one event needs to trigger reactions in multiple services — is structurally better served by a topic than by the originating service knowing about and calling each consumer.
With Spring Boot and Kafka, publishing an event is straightforward:
@Service
public class OrderService {
private final OrderRepository repository;
private final KafkaTemplate<String, OrderPlacedEvent> kafkaTemplate;
@Transactional
public Order place(OrderRequest request) {
var order = repository.save(Order.from(request));
kafkaTemplate.send(
"orders.placed",
order.getId().toString(),
new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal())
);
return order;
}
}
@Service
public class NotificationConsumer {
@KafkaListener(topics = "orders.placed", groupId = "notification-service")
public void onOrderPlaced(OrderPlacedEvent event) {
notificationService.sendOrderConfirmation(event.getCustomerId(), event.getOrderId());
}
}
The notification service can be down for 30 minutes. Orders still get placed. When it recovers, it processes the backlog from where it left off. That resilience is structural — you didn't have to write retry logic in the order service.
The Problem Neither Solves Cleanly: Dual Write
The Kafka example above has a subtle bug that surfaces in production. The @Transactional block saves the order to the database and then publishes to Kafka. If the Kafka publish fails after the database commit, the order exists but the event was never sent. Downstream consumers never see it.
The reverse is also possible: if the application crashes between the database commit and the Kafka send, same result. You have committed state with no corresponding event.
This is the dual write problem. The database and the message broker are two separate systems with no shared transaction coordinator. You cannot atomically commit to both.
The clean solution is the transactional outbox pattern. Instead of publishing directly to Kafka, write the event to an outbox table in the same database transaction as the order:
@Transactional
public Order place(OrderRequest request) {
var order = repository.save(Order.from(request));
outboxRepository.save(new OutboxEvent(
"orders.placed",
order.getId().toString(),
serialize(new OrderPlacedEvent(order.getId(), order.getCustomerId(), order.getTotal()))
));
return order;
}
A separate relay process — Debezium doing CDC on the outbox table, or a scheduled poller — reads unpublished events and forwards them to Kafka, then marks them delivered. The database transaction guarantees that either both the order and the outbox entry are committed or neither is. The relay handles the Kafka publish separately, with retries.
Debezium with Postgres CDC is the production-grade implementation. For lower-volume systems, a scheduled Spring @Scheduled poller on the outbox table is simpler and sufficient.
Choosing in Practice
The decision tree is short. Ask two questions:
Does the caller need the result to continue? If yes, REST. If no, keep going.
Does the caller's availability need to be independent of the callee's? If yes, messaging. If the answer to both is no — the caller doesn't need the result and doesn't need independence — you may still want messaging for fan-out or for work that's slow.
Most non-trivial systems end up with both. REST for queries and validation gates, messaging for state propagation and async work. The architecture that uses only REST is fragile under dependency failures. The architecture that uses only messaging has no synchronous query path and makes simple reads unnecessarily complex.
The mistake to avoid in both directions: using REST because it's familiar, or using Kafka because it sounds sophisticated. The communication pattern is a consequence of the interaction model, not a technology preference.