Skip to content

RestClient in Spring Boot Microservices | Synchronous Communication using RestClient | Fluent API

The Assembly Line Analogy

Think of building a car on a factory floor. In the old approach, the supervisor had to memorise a separate checklist for every possible combination of model, color, and options — a massive binder full of overloaded procedures. The new approach uses a sequential assembly line: first the chassis, then the engine, then the paint, then the trim. The order is fixed and obvious; every worker knows exactly what comes next. You cannot apply paint before the engine is mounted — the line simply does not allow it.

RestClient is the assembly line. Each method you chain opens up the next set of operations. You can only move forward in the sequence; you can never jump to a stage that requires a previous stage to be complete first. This property — called a fluent API — eliminates the need to memorise dozens of overloaded method variants.


RestTemplate Limitations Recap

From the previous chapter, the major problems with RestTemplate were:

  • Dozens of overloaded methods (getForObject, getForEntity, postForObject, postForEntity, exchange × many overloads …). Hard to remember, hard to maintain.
  • Added before modern HTTP features — each new capability (retry, circuit breaker, interceptors) required even more overloads.
  • Not extensible cleanly.

Alternatives: WebClient vs. RestClient

WebClientRestClient
NatureAsynchronous, non blockingSynchronous, blocking
Programming modelReactive (Spring WebFlux)Imperative (Spring MVC)
IntroducedSpring 5Spring Framework 6 / Spring Boot 3
Use whenYou want reactive pipelinesYou want simple, readable HTTP calls

For microservice communication that follows a request-response pattern and does not require a reactive stack, RestClient is the preferred modern choice.


Creating a RestClient

As a Spring Bean

java
@Configuration
public class AppConfig {

    /**
     * RestClient.create() is shorthand for RestClient.builder().build().
     * A single shared instance is safe across threads.
     */
    @Bean
    public RestClient restClient() {
        return RestClient.create();
    }

    /**
     * RestClient with a preconfigured base URL.
     * Relative URIs in .uri() are resolved against this base.
     */
    @Bean
    public RestClient productRestClient() {
        return RestClient.builder()
            .baseUrl("http://localhost:8082")
            .build();
    }
}

Minimal GET Example

java
@RestController
@RequestMapping("/order")
public class OrderController {

    private final RestClient restClient;

    public OrderController(RestClient restClient) {
        this.restClient = restClient;
    }

    @GetMapping("/{id}")
    public String placeOrder(@PathVariable Long id) {
        String response = restClient
            .get()                                         // choose HTTP method
            .uri("http://localhost:8082/product/" + id)   // set the target URL
            .retrieve()                                    // indicate intent to read the response
            .body(String.class);                          // extract and deserialize body

        System.out.println("Response from product service: " + response);
        return "Order placed. " + response;
    }
}

Compare this with the RestTemplate equivalent that required knowing which of many overloads to use. Here the sequence .get() → .uri() → .retrieve() → .body() is always the same pattern regardless of whether you are doing a GET, POST, PUT, or DELETE.


What Is a Fluent API?

A fluent API is built from method chaining where each method returns an object that exposes only the operations that make sense at that stage. Consider:

restClient.get()       → returns RequestHeaderUriSpec
  .uri("…")           → returns RequestHeadersSpec
  .header("…","…")    → returns RequestHeadersSpec (same stage, keeps adding headers)
  .retrieve()          → returns ResponseSpec
  .body(String.class) → returns String (final result)

Each arrow represents a class boundary. The method .uri() is defined on RequestHeaderUriSpec, not on ResponseSpec. So you cannot call .uri() after .retrieve() — the chain would break because ResponseSpec simply does not have that method. This enforced sequencing is what makes the API self-documenting and prevents incorrect usage.

Class Diagram Summary

RestClient
  └─ get() / post() / put() / delete()
       └─ RequestBodyUriSpec  (implements RequestHeaderUriSpec + RequestBodySpec)
            ├─ .uri(String)       → RequestBodySpec / RequestHeadersSpec
            ├─ .header(k, v)      → same spec (chaining within stage)
            ├─ .accept(MediaType) → same spec
            ├─ .body(Object)      → RequestHeadersSpec  (POST/PUT only)
            └─ .retrieve()        → ResponseSpec
                  ├─ .body(Class)         → T
                  ├─ .toEntity(Class)     → ResponseEntity<T>
                  └─ .onStatus(…)         → ResponseSpec (for exception handling)

Why Sequence Matters

java
// WRONG — calling .accept() before .uri() blocks you from ever calling .uri() again
restClient.get()
    .accept(MediaType.APPLICATION_JSON)  // now stuck on RequestHeadersSpec
    .uri("http://…")                     // COMPILE ERROR — uri() not available here
    .retrieve()
    .body(String.class);

// CORRECT
restClient.get()
    .uri("http://…")
    .accept(MediaType.APPLICATION_JSON)
    .retrieve()
    .body(String.class);

POST, PUT, DELETE Examples

POST — Creating a Resource

java
Product newProduct = new Product("Laptop", 999.99);

ResponseEntity<Product> response = restClient
    .post()
    .uri("http://localhost:8082/product")
    .contentType(MediaType.APPLICATION_JSON)
    .body(newProduct)       // serialize Java object → JSON automatically
    .retrieve()
    .toEntity(Product.class);  // full ResponseEntity including status + headers

System.out.println("Created with status: " + response.getStatusCode());
System.out.println("Body: " + response.getBody());

PUT — Updating a Resource

java
Product updated = new Product("Gaming Laptop", 1299.99);

restClient
    .put()
    .uri("http://localhost:8082/product/{id}", productId)
    .contentType(MediaType.APPLICATION_JSON)
    .body(updated)
    .retrieve()
    .toBodilessEntity(); // PUT typically returns no body

DELETE

java
restClient
    .delete()
    .uri("http://localhost:8082/product/{id}", productId)
    .retrieve()
    .toBodilessEntity();

Exception Handling with onStatus

In RestTemplate, exception handling was awkward and inconsistent. RestClient provides a clean onStatus hook on the ResponseSpec:

java
@GetMapping("/{id}")
public Product getProduct(@PathVariable Long id) {
    return restClient
        .get()
        .uri("http://localhost:8082/product/" + id)
        .retrieve()
        .onStatus(status -> status.is4xxClientError(), (request, response) -> {
            throw new ProductNotFoundException(
                "Product not found. Status: " + response.getStatusCode()
            );
        })
        .onStatus(status -> status.is5xxServerError(), (request, response) -> {
            throw new ProductServiceException(
                "Product service error. Status: " + response.getStatusCode()
            );
        })
        .body(Product.class);
}

How it works internally: onStatus stores the predicate and handler pair in a list on the ResponseSpec. When body() is called, it internally invokes the exchange method. The exchange method evaluates each registered status handler before attempting body deserialization. If a handler matches, it throws the provided exception, short-circuiting the deserialization step.


Using exchange Directly for Full Control

If you want to bypass onStatus and body() and control everything yourself, call exchange() directly:

java
String result = restClient
    .get()
    .uri("http://localhost:8082/product/1")
    .exchange((request, response) -> {
        if (response.getStatusCode().is4xxClientError()) {
            throw new ProductNotFoundException("Not found");
        }
        if (response.getStatusCode().is5xxServerError()) {
            throw new ProductServiceException("Server error");
        }
        // Manual deserialization from response body stream
        return StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
    });

Use exchange when you need to inspect headers or status before deciding how to parse the body.


Adding Interceptors

Interceptors allow you to add cross cutting behavior (authentication headers, request logging, correlation IDs) at the RestClient level rather than at each call site.

java
@Component
public class AuthInterceptor implements ClientHttpRequestInterceptor {

    private final TokenProvider tokenProvider;

    public AuthInterceptor(TokenProvider tokenProvider) {
        this.tokenProvider = tokenProvider;
    }

    @Override
    public ClientHttpResponse intercept(
            HttpRequest request,
            byte[] body,
            ClientHttpRequestExecution execution) throws IOException {

        // Add auth header to every outgoing request
        request.getHeaders().set("Authorization", "Bearer " + tokenProvider.currentToken());
        // Log the request
        System.out.println("Outgoing request to: " + request.getURI());

        return execution.execute(request, body);
    }
}
java
@Bean
public RestClient restClientWithAuth(AuthInterceptor authInterceptor) {
    return RestClient.builder()
        .baseUrl("http://localhost:8082")
        .requestInterceptor(authInterceptor)
        .build();
}

Interceptors are applied before the call to execute() inside the framework, so they run after the request is fully constructed but before the TCP connection is opened.


RestClient Internal Flow

RestClient uses a different HTTP client underneath compared to RestTemplate:

  1. JdkClientHttpRequestFactory (default in Spring 6) creates an HttpClient from java.net.http — the modern Java HTTP client available since Java 11. This client supports HTTP/1.1 and HTTP/2 (concurrent streams over a single connection).

  2. execute() calls HttpClient.sendAsync() internally, but immediately blocks for the result (with CompletableFuture.join()). This is why RestClient is synchronous from the caller's perspective even though the underlying I/O uses async APIs.

  3. Response handling — once the response is available, the registered ResponseSpec handlers run. If no error handler matches, a registered HttpMessageConverter deserializes the response body to the target type.

  4. Connection managementHttpClient manages its own connection pool. Unlike RestTemplate's KeepAliveCache, the modern client handles HTTP/2 multiplexing transparently.


HTTP/2 Support: A Key Advantage Over RestTemplate

RestTemplate (via HttpUrlConnection) supports only HTTP 1.0 and HTTP 1.1. With HTTP 1.1, each TCP connection handles one request at a time in sequence.

RestClient (via java.net.http.HttpClient) supports HTTP/2, which allows multiple concurrent HTTP requests to be multiplexed over a single TCP connection. The server can also send responses out of order. This results in significantly lower latency when a service makes many parallel downstream calls.


Interview Questions & Pitfalls

Q1. What is the difference between RestClient and WebClient?

Both are modern alternatives to RestTemplate. RestClient is synchronous and blocking — the calling thread waits for the response before proceeding. WebClient is asynchronous and non blocking and belongs to the Spring WebFlux reactive stack. Use RestClient in a standard Spring MVC application; use WebClient when you are building a reactive application or need non blocking I/O.

Q2. What is a fluent API and how does RestClient implement it?

A fluent API uses method chaining where each method returns an object that exposes only the next logical set of operations. RestClient achieves this through a chain of interfaces: get() returns RequestHeaderUriSpec, .uri() returns RequestBodySpec or RequestHeadersSpec, .retrieve() returns ResponseSpec, and .body() returns the deserialized type. Because each method returns a different interface type, the compiler enforces the correct order of operations.

Q3. What does retrieve() actually do? Does it make the HTTP call?

No. retrieve() creates a ResponseSpec object and stores the request configuration built so far. The actual HTTP call (TCP connection, request transmission, response reading) happens when you call .body(), .toEntity(), or .toBodilessEntity() on the ResponseSpec.

Q4. How do you add a global authentication header to all requests made by a RestClient?

Implement ClientHttpRequestInterceptor, set the header in the intercept method, then register the interceptor on the RestClient.Builder via .requestInterceptor(myInterceptor). Every request made through that RestClient instance will include the header automatically.

Q5. What is a common mistake when using onStatus with RestClient?

A common pitfall is forgetting that onStatus is evaluated only when .body() or .toEntity() is called, not when .retrieve() is called. Another pitfall is registering a handler for 4xx errors but not for 5xx errors — the default behavior for unhandled error status codes is to throw a generic RestClientException, which may mask the real cause.

Q6. How is exception handling in RestClient better than in RestTemplate?

RestTemplate required wrapping calls in try-catch for HttpClientErrorException and HttpServerErrorException, and custom error handling required subclassing DefaultResponseErrorHandler. RestClient provides a clean onStatus method directly on the call chain, allowing you to register type safe, expressive handlers per status range without subclassing framework classes.

Q7. What HTTP versions does RestClient support compared to RestTemplate?

RestTemplate (using HttpUrlConnection) supports HTTP 1.0 and HTTP 1.1. RestClient (using java.net.http.HttpClient by default) supports HTTP 1.1 and HTTP/2, including the multiplexing feature of HTTP/2 where multiple requests share a single TCP connection concurrently.