DEV Community

Cover image for Service-to-Service Communication in Microservices: What Every Developer Should Know
Guna SantoshDeep Srivastava
Guna SantoshDeep Srivastava

Posted on

Service-to-Service Communication in Microservices: What Every Developer Should Know

I still remember the first time a junior dev I was mentoring asked me, "why did the order service just... hang for 30 seconds and then crash the whole checkout flow?" The answer had nothing to do with their code being wrong. It had everything to do with how their service was talking to another service — and nobody had ever actually taught them that part.

That's the gap this post is trying to close. Not "what is a microservice" — you already know that. This is about the part that trips up almost everyone the first time they split a monolith into services: how do these things actually talk to each other, and what breaks when they do?

We'll go through this using Spring Boot, since that's what most Java shops are running, but the concepts carry over regardless of stack.

The thing that changes the moment you split a monolith

In a monolith, calling another module is just a method call. It's fast, it's reliable, and if something goes wrong, you get a stack trace pointing at the exact line.

The moment that "module" becomes a separate service running somewhere else, that method call becomes a network call. And a network call can fail in ways a method call never does: the other service could be slow, temporarily down, overloaded, or reachable but taking 45 seconds to respond because its database is having a bad day. Your code needs to handle all of that, and most tutorials skip straight past it to show you the happy path.

So let's not skip it.

Synchronous communication: when you need the answer right now

This is the "call and wait" pattern — Service A calls Service B and blocks until it gets a response. Good for things like "check if this user is allowed to do this," where you genuinely can't proceed without the answer.

RestTemplate — you'll see this in a lot of existing codebases. It's not formally deprecated, but the class Javadoc itself says it's in maintenance mode and won't get new features. Worth knowing how to read, not worth starting a new project with.

RestTemplate restTemplate = new RestTemplate();
UserDto user = restTemplate.getForObject(
    "http://user-service/users/{id}", UserDto.class, userId);
Enter fullscreen mode Exit fullscreen mode

If you specifically want a modern synchronous client without pulling in WebFlux, Spring Boot 3.2+ also ships RestClient — same fluent style as WebClient below, but blocking by default. Worth a look if WebClient feels like overkill for a non-reactive app.

WebClient — the modern reactive replacement, and it works fine even if you're not doing reactive programming elsewhere in your app. You can call .block() if you just want a plain synchronous result. Full options are in the Spring Framework WebClient reference, including the example code for things like timeouts and filters.

WebClient client = WebClient.create("http://user-service");

UserDto user = client.get()
    .uri("/users/{id}", userId)
    .retrieve()
    .bodyToMono(UserDto.class)
    .block();
Enter fullscreen mode Exit fullscreen mode

OpenFeign — if you're in a Spring Cloud microservices setup, this is probably what you actually want. You declare an interface, Feign generates the HTTP client for you, and it reads like a normal method call again. Full setup and config options are in the Spring Cloud OpenFeign reference docs.

@FeignClient(name = "user-service")
public interface UserClient {
    @GetMapping("/users/{id}")
    UserDto getUser(@PathVariable("id") Long id);
}
Enter fullscreen mode Exit fullscreen mode

Then you just inject UserClient and call userClient.getUser(id) like it's a local bean. This is usually the nicest developer experience of the three, which is part of why it's so widely used in Spring Cloud shops.

Worth knowing if you're starting something new: Spring Cloud OpenFeign itself now says it's feature-complete and points people toward Spring's own native declarative clients (@HttpExchange + @ImportHttpServices), introduced in Spring Framework 7. Same declarative-interface idea as Feign, just built into core Spring instead of a separate dependency. Spring's own writeup on it is here if you want the example code. Feign isn't going anywhere soon, but if you're picking a client for a brand-new project, it's worth a look before you default to Feign out of habit.

Asynchronous communication: when you don't need the answer right now

Not everything needs an immediate response. "Send a confirmation email after checkout" doesn't need to block the checkout request — it just needs to eventually happen. This is where message brokers come in: Service A publishes an event, Service B picks it up whenever it's ready, and the two services never have to be online at the same moment.

Kafka, with Spring Kafka (full reference and runnable examples here):

// Producer
@Autowired
private KafkaTemplate<String, OrderEvent> kafkaTemplate;

public void publishOrderCreated(OrderEvent event) {
    kafkaTemplate.send("order-events", event);
}

// Consumer, in a different service entirely
@KafkaListener(topics = "order-events", groupId = "email-service")
public void handleOrderCreated(OrderEvent event) {
    emailService.sendConfirmation(event.getOrderId());
}
Enter fullscreen mode Exit fullscreen mode

RabbitMQ, with Spring AMQP (reference docs and more listener examples here), if you want more routing flexibility than Kafka's topic model gives you:

@RabbitListener(queues = "order.created.queue")
public void handleOrderCreated(OrderEvent event) {
    emailService.sendConfirmation(event.getOrderId());
}
Enter fullscreen mode Exit fullscreen mode

The real decision isn't "Kafka vs RabbitMQ" — that's a second-order question. The first question is sync vs async, and that comes down to one thing: does the caller need the result before it can continue? If yes, sync. If no, you're just adding latency and coupling for no reason by making it synchronous.

The part beginners skip and seniors get burned by anyway

This is the section that actually matters more than picking a client library.

Timeouts. If you don't set one explicitly, you may be relying on a default that's way too generous — or in some client setups, no timeout at all. A slow downstream service without a timeout on the caller side doesn't just slow you down, it can exhaust your thread pool and take down services that have nothing to do with the original problem.

WebClient client = WebClient.builder()
    .baseUrl("http://user-service")
    .clientConnector(new ReactorClientHttpConnector(
        HttpClient.create().responseTimeout(Duration.ofSeconds(3))))
    .build();
Enter fullscreen mode Exit fullscreen mode

Retries. Sounds simple until you ask: is this call safe to retry? A GET usually is. A POST that charges a credit card is not — unless you've made it idempotent (idempotency keys are the usual fix). Blindly wrapping every call in a retry loop is how you get duplicate charges, not resilience.

Circuit breakers. When a downstream service is genuinely down, retrying just piles more load onto something that's already struggling — and it makes the caller's own response times terrible while it keeps trying. Resilience4j (official docs and full config reference here) handles this cleanly in Spring Boot:

@CircuitBreaker(name = "userService", fallbackMethod = "fallbackUser")
public UserDto getUser(Long id) {
    return userClient.getUser(id);
}

public UserDto fallbackUser(Long id, Throwable t) {
    return UserDto.guest();
}
Enter fullscreen mode Exit fullscreen mode

Once the failure rate crosses a threshold, the circuit "opens," calls fail fast without even hitting the network, and the fallback kicks in instead. That one annotation is doing a lot of work most teams don't add until after their first real outage.

Finding each other in the first place

One more piece worth knowing: in a real deployment, service instances scale up and down and their addresses change. Hardcoding http://user-service-host:8080 doesn't survive that. Spring Cloud's usual answer is a discovery service like Eureka, paired with Spring Cloud LoadBalancer — services register themselves on startup, and callers ask the registry "where's user-service right now?" instead of hardcoding an address. If you noticed http://user-service in the Feign and WebClient examples above with no port or IP — that's this in action, resolved by the load balancer at call time.

Quick decision guide

Situation Reach for
You need the result before you can continue Synchronous (WebClient or Feign)
The action can happen "eventually" Asynchronous (Kafka or RabbitMQ)
Multiple services need to react to the same event Asynchronous, pub/sub
Calling a flaky or slow external dependency Synchronous + circuit breaker, always
Instance addresses change as you scale Service discovery (Eureka), not hardcoded URLs

What I've actually seen go wrong in practice

The bug that gets people almost every time isn't a missing feature — it's a missing timeout. Someone calls a downstream service, doesn't set an explicit timeout, everything works fine in dev because the downstream service is fast and local, and then in production, under real load, one slow dependency quietly takes the whole request chain down with it. It's boring, it's not a "clever" bug, and it's also the single most common root cause I've run into in real incident reviews.

If you take away one thing from this post: don't add a client library and call it done. Set a timeout. Decide, explicitly, whether each call is safe to retry. Those two habits alone prevent most of the outages I've seen traced back to service-to-service calls.

What's the worst service-to-service incident you've personally debugged? I'm curious whether it was a timeout, a retry storm, or something weirder — genuinely feels like everyone in this field has one story.

Top comments (0)