Building Microservices with Spring Boot

Inter-Service Communication with WebClient

18 min Lesson 3 of 12

Inter-Service Communication with WebClient

Once you have two microservices running, they inevitably need to talk to each other over HTTP. Spring Boot 3 ships with two first-party HTTP clients: the older blocking RestTemplate (now in maintenance mode) and the modern, non-blocking WebClient from Spring WebFlux. Even if your service is built on the traditional servlet stack rather than a reactive one, WebClient is the recommended choice — it can be used in a synchronous blocking style while still being far more configurable and testable than RestTemplate.

Why WebClient Instead of RestTemplate?

RestTemplate was introduced in Spring 3.0 and is synchronous by nature: each call blocks the calling thread until the remote service responds. The Spring team declared it in maintenance mode as of Spring 5.0. WebClient, by contrast, was built from scratch for a non-blocking world but also supports blocking calls, making it a drop-in replacement with a better feature set:

  • Streaming support — can receive text/event-stream or large payloads without buffering the whole body.
  • Rich filter pipeline — interceptors (called ExchangeFilterFunction) let you attach logging, auth headers, retries, and circuit breakers in one place.
  • Reactive or blocking — call .block() when you need a synchronous result; leave it reactive when you want non-blocking I/O.
  • Built-in codec support — automatic JSON serialization and deserialization with Jackson; no extra boilerplate.
Servlet stack vs. reactive stack: You do not need spring-boot-starter-webflux to use WebClient. Add spring-boot-starter-web (servlet) plus spring-webflux as a direct dependency and you get WebClient without switching your whole application to reactive programming.

Adding the Dependency

If your service already uses spring-boot-starter-webflux, WebClient is already on the classpath. If you are on the servlet stack, add only the reactive web module:

<!-- pom.xml — add alongside spring-boot-starter-web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency>

Creating a WebClient Bean

Always create WebClient instances through a WebClient.Builder bean that Spring Boot auto-configures. This builder carries any globally registered ExchangeFilterFunctions — including those added by Spring Cloud for distributed tracing — so never instantiate WebClient.create() manually in production code.

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.reactive.function.client.WebClient; @Configuration public class WebClientConfig { @Bean public WebClient inventoryClient(WebClient.Builder builder) { return builder .baseUrl("http://inventory-service") // logical service name (resolved by discovery) .defaultHeader("Accept", "application/json") .build(); } }

Declare one bean per downstream service. Using a dedicated, named bean rather than a shared one keeps base URLs, default headers, and filters scoped to the correct service.

Making a GET Request

The following shows how the order-service calls the inventory-service to check stock for a given product ID.

import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClientResponseException; @Service public class InventoryClientService { private final WebClient inventoryClient; public InventoryClientService(WebClient inventoryClient) { this.inventoryClient = inventoryClient; } public StockResponse checkStock(String productId) { return inventoryClient .get() .uri("/api/inventory/{id}", productId) .retrieve() .bodyToMono(StockResponse.class) .block(); // synchronous call } }

Breaking down the fluent chain:

  1. .get() — starts a GET request spec.
  2. .uri(...) — appends the path; URI template variables are expanded safely (no string concatenation, no injection risk).
  3. .retrieve() — triggers the request and gives access to the response body. Automatically throws WebClientResponseException for 4xx/5xx status codes.
  4. .bodyToMono(StockResponse.class) — deserializes the JSON body into your DTO using Jackson.
  5. .block() — blocks the calling thread until the response arrives. Acceptable in a servlet-stack service; avoid in a reactive service.

Making a POST Request with a Body

public OrderConfirmation placeOrder(OrderRequest request) { return inventoryClient .post() .uri("/api/orders") .contentType(MediaType.APPLICATION_JSON) .bodyValue(request) // serialized to JSON automatically .retrieve() .bodyToMono(OrderConfirmation.class) .block(); }
Use .bodyValue() for a single object and .body(BodyInserters.fromValue(...)) when you need more control (e.g., multipart forms). Both serialize with the same Jackson ObjectMapper configured globally in your application.

Handling HTTP Errors Explicitly

By default .retrieve() maps 4xx responses to WebClientResponseException.BadRequest and 5xx to WebClientResponseException.InternalServerError. You can intercept specific status codes and translate them into domain exceptions:

public StockResponse checkStock(String productId) { return inventoryClient .get() .uri("/api/inventory/{id}", productId) .retrieve() .onStatus(HttpStatusCode::is4xxClientError, response -> response.bodyToMono(String.class) .map(body -> new ProductNotFoundException("Product not found: " + productId))) .onStatus(HttpStatusCode::is5xxServerError, response -> Mono.error(new InventoryServiceException("Inventory service is unavailable"))) .bodyToMono(StockResponse.class) .block(); }

Adding Authentication Headers

In a real microservices deployment, service-to-service calls often carry a JWT or an internal API key. Apply it via an ExchangeFilterFunction so every request from this client automatically includes it — no per-call boilerplate:

import org.springframework.web.reactive.function.client.ExchangeFilterFunction; import reactor.core.publisher.Mono; // In WebClientConfig: @Bean public WebClient inventoryClient(WebClient.Builder builder) { return builder .baseUrl("http://inventory-service") .filter(serviceTokenFilter()) .build(); } private ExchangeFilterFunction serviceTokenFilter() { return ExchangeFilterFunction.ofRequestProcessor(request -> Mono.just(ClientRequest.from(request) .header("X-Service-Token", System.getenv("INTERNAL_SERVICE_TOKEN")) .build()) ); }
Never log full Authorization or X-Service-Token header values. Request-logging filters are extremely useful for debugging, but you must redact sensitive headers before writing them to any log sink. Tokens that appear in logs can be scraped by anyone with log access — a common source of credential leakage in microservices environments.

Timeouts — An Essential Safety Net

Without timeouts, a slow downstream service will hold your thread (or subscription) indefinitely, eventually exhausting connection pools and cascading into a system-wide outage. Always set both a connection timeout and a read timeout:

import io.netty.channel.ChannelOption; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import reactor.netty.http.client.HttpClient; import java.time.Duration; @Bean public WebClient inventoryClient(WebClient.Builder builder) { HttpClient httpClient = HttpClient.create() .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 2_000) // 2 s to establish TCP .responseTimeout(Duration.ofSeconds(5)); // 5 s to receive full response return builder .baseUrl("http://inventory-service") .clientConnector(new ReactorClientHttpConnector(httpClient)) .build(); }

A common production rule of thumb: set the connection timeout short (1–3 s) and the response timeout no more than the SLA you want to expose to your own callers. If the inventory service promises 3-second p99 latency, your timeout should be 4–5 seconds to give it a margin but still fail fast.

Summary

WebClient is the standard tool for HTTP-based inter-service communication in Spring Boot 3. Obtain it via the auto-configured WebClient.Builder, scope one bean per downstream service, use URI templates to avoid injection risks, translate HTTP error codes into domain exceptions with .onStatus(), attach auth tokens through ExchangeFilterFunction, and always configure explicit connection and response timeouts. In the next lesson you will see how OpenFeign lets you express the same calls as a declarative interface with even less boilerplate.