Skip to content
Chapter 02 · Microservices

RestClient and Its Fluent API ​

Chapter 1 ended with the limits of RestTemplate: dozens of overloaded methods, no room for new features, maintenance mode. This chapter makes the same order-service to product-service call with its replacement, RestClient.

Spring has two alternatives to RestTemplate:

  • WebClient: asynchronous, non-blocking; the client does not wait for the response. It belongs to reactive programming (Spring WebFlux).
  • RestClient: synchronous, blocking, like RestTemplate; the client waits for the response. Introduced in Spring Framework 6.1 / Spring Boot 3.2.

We stay with synchronous calls, so RestClient it is. It offers a modern, fluent API that is easier to read and maintain.

Step 1: the call ​

The setup is unchanged: order-service on port 8081, product-service on port 8082 with its GET /products/{id} endpoint.

Create a RestClient bean in order-service:

java
@Configuration
public class AppConfig {

    @Bean
    public RestClient restClient() {
        return RestClient.create();      // same as RestClient.builder().build()
    }
}

Use it in the controller:

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

    @Autowired
    RestClient restClient;

    @GetMapping("/{id}")
    public ResponseEntity<String> getOrder(@PathVariable String id) {

        String response = restClient
                .get()
                .uri("http://localhost:8082/products/" + id)
                .retrieve()
                .body(String.class);

        System.out.println("Response from Product API called from order service: " + response);
        return ResponseEntity.ok("order call successful");
    }
}

GET /orders/1 returns order call successful and prints Product fetched with id: 1.

Read the chain aloud: get, from this uri, retrieve the response, give me the body as a String. Every request, whatever its method, follows this one template. There are no overloads to remember.

Step 2: what "fluent API" means ​

A fluent API is built on method chaining: each call returns an object, and that object offers the next set of methods.

The methods in a chain can live in different classes. Each step decides which methods are available next, so order matters: once a step has returned an object that no longer offers an earlier method, you cannot go back. The chain only works in the order the types allow.

You do not have to memorize RestClient's order. It follows from a handful of interfaces.

Step 3: how RestClient's chain works ​

The types come in three groups:

  • UriSpec: methods that set the URI.
  • RequestHeadersSpec: methods that set headers and cookies, plus retrieve() and exchange().
  • RequestBodySpec extends RequestHeadersSpec: methods that set the body, content type and length (for POST and PUT).
  • RequestHeadersUriSpec extends both UriSpec and RequestHeadersSpec.
  • RequestBodyUriSpec extends RequestBodySpec and RequestHeadersUriSpec.
  • DefaultRequestBodyUriSpec implements all of them. Every request, whatever its method, is one object of this class; the interfaces only control which of its methods you can see at each step.
  • ResponseSpec: knows how to handle the response: convert the body (body, toEntity) and react to error statuses (onStatus).

The headers interface is declared with a self-referencing generic type:

java
interface RequestHeadersSpec<S extends RequestHeadersSpec<S>> {
    S accept(MediaType... acceptableMediaTypes);
    S header(String headerName, String... headerValues);
    ResponseSpec retrieve();
    ...
}

accept and header return S: "the spec you are already on". That is what keeps a chain on the same step after setting a header.

Following a GET ​

java
restClient.get()                               // 1
          .uri("http://localhost:8082/products/1")   // 2
          .accept(MediaType.APPLICATION_JSON)  // 3
          .header("X-Custom-Header", "xyz")    // 3
          .retrieve()                          // 4
          .body(String.class);                 // 5
  1. get() creates a DefaultRequestBodyUriSpec, sets its method to GET, and returns it typed as RequestHeadersUriSpec<?>. Both URI and header methods are now visible.
  2. uri(...) sets the URI and returns S, here a RequestHeadersSpec. From now on, only header methods are visible.
  3. accept and header set headers and return S again: still RequestHeadersSpec.
  4. retrieve() returns a ResponseSpec, a different object with response methods.
  5. body(String.class) converts the response body.

Breaking the chain ​

Swap steps 2 and 3:

java
restClient.get()
          .accept(MediaType.APPLICATION_JSON)
          .uri("http://localhost:8082/products/1");   // compile error: cannot find symbol

get() returns RequestHeadersUriSpec<?>. Its type argument is a wildcard, so the compiler only knows S is some RequestHeadersSpec. accept therefore returns a plain RequestHeadersSpec, which has no uri method. Nothing in the chain leads back to UriSpec, so the chain is broken. This is why the URI always comes right after the HTTP method.

Following a POST ​

java
ResponseEntity<Product> response = restClient
        .post()                                           // RequestBodyUriSpec
        .uri("http://localhost:8082/products/create")    // RequestBodySpec
        .accept(MediaType.APPLICATION_JSON)               // RequestBodySpec
        .header("Content-Type", "application/json")       // RequestBodySpec
        .body(new Product("Ice-cream"))                   // RequestBodySpec
        .retrieve()                                       // ResponseSpec
        .toEntity(Product.class);

Product created = response.getBody();

post() returns a RequestBodyUriSpec, which can reach every method. Its uri returns a RequestBodySpec: body methods plus, through its parent, header methods. And because RequestBodySpec passes itself as S, accept and header return RequestBodySpec too, so body(...) stays reachable after the headers.

toEntity returns the full ResponseEntity (status, headers, body); body returns only the body.

Following a DELETE ​

java
ResponseEntity<Void> response = restClient
        .delete()
        .uri("http://localhost:8082/products/" + id)
        .retrieve()
        .toBodilessEntity();

HttpStatusCode deletionStatus = response.getStatusCode();

A delete sends no body and expects none, so toBodilessEntity() returns just status and headers.

Step 4: handling errors ​

By default, a 4xx response makes body() throw HttpClientErrorException (for example HttpClientErrorException$BadRequest: 400 Bad Request), and a 5xx throws HttpServerErrorException.

To throw your own exceptions, register handlers with onStatus on the ResponseSpec. Each handler is a predicate on the status code plus an error handler:

java
String response = restClient
        .get()
        .uri("http://localhost:8082/products/" + id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (request, resp) -> {
            throw new MyCustomException("Invalid request passed");
        })
        .onStatus(HttpStatusCode::is5xxServerError, (request, resp) -> {
            throw new IllegalStateException("Something wrong at server");
        })
        .body(String.class);

A 400 from product-service now surfaces as MyCustomException: Invalid request passed. onStatus returns the same ResponseSpec, so you can chain several handlers before body.

onStatus also has an overload taking a ResponseErrorHandler. That interface has two abstract methods (hasError and handleError), so it is not a functional interface and cannot be written as a lambda: onStatus(response -> { ... return false; }) does not compile. Use the predicate form above.

Full control with exchange ​

retrieve() hands the response to a ResponseSpec that decides how to convert it. To do it yourself, call exchange instead of retrieve, right after the URI and headers, and pass a function that receives the raw request and response:

java
String response = restClient
        .get()
        .uri("http://localhost:8082/products/" + id)
        .exchange((request, resp) -> {
            if (resp.getStatusCode().is4xxClientError()) {
                throw new MyCustomException("Invalid request passed");
            } else if (resp.getStatusCode().is5xxServerError()) {
                throw new IllegalStateException("Something wrong at server");
            }
            return StreamUtils.copyToString(resp.getBody(), StandardCharsets.UTF_8);
        });

You check the status and map the body yourself, so ResponseSpec is not needed at all.

How a request is executed ​

Every path ends in the same internal exchange method. It does three things:

  1. Create the request through the request factory.
  2. Execute it: open (or reuse) the TCP connection, send the request, wait for the response.
  3. Run the exchange function on the response: your lambda from exchange(...), or, with retrieve(), the one ResponseSpec builds from your onStatus handlers and the target type.

RestClient.create() uses the JdkClientHttpRequestFactory, built on the JDK's java.net.http.HttpClient (the newer client added in Java 11, not the HttpURLConnection of chapter 1). That client manages connections itself and supports HTTP/1.1 and HTTP/2. It even sends requests asynchronously internally, but RestClient waits for the result, so the call stays blocking.

Two details worth knowing:

  • The factory depends on how the client is built. If Apache HttpClient or Jetty is on the classpath, RestClient.create() uses it instead. And the RestClient.Builder that Spring Boot 3.3 can inject for you picks its own factory, which falls back to HttpURLConnection. Product-service sees User-Agent: Java-http-client/... from the first and Java/... from the second.
  • HTTP/2 is a feature of the HTTP library, not of RestClient. RestTemplate can also use HTTP/2 if you give it a JdkClientHttpRequestFactory. Its default factory, SimpleClientHttpRequestFactory, is what limits it to HTTP/1.1.

When is the request sent? ​

In Spring Framework 6.1 (Spring Boot 3.3), retrieve() sends the request. body() or toEntity() then reads and converts the response, applying the onStatus handlers first. You can check it with a log line on each side:

java
RestClient.ResponseSpec spec = restClient.get().uri(url).retrieve();
System.out.println("after retrieve()");
String body = spec.body(String.class);
text
product hit id=lazy          <- product-service
after retrieve()             <- order-service

Step 5: interceptors ​

An interceptor runs around every request a client sends. A typical use is adding a header. Implement ClientHttpRequestInterceptor:

java
public class MyCustomRequestInterceptor implements ClientHttpRequestInterceptor {

    @Override
    public ClientHttpResponse intercept(HttpRequest request, byte[] body,
                                        ClientHttpRequestExecution execution) throws IOException {
        request.getHeaders().add("X-Custom-Header", "myvalue");
        return execution.execute(request, body);      // continue with the request
    }
}

Register it when building the client:

java
@Configuration
public class AppConfig {

    @Bean
    public RestClient restClient(ClientHttpRequestInterceptor myCustomInterceptor) {
        return RestClient.builder()
                .requestInterceptor(myCustomInterceptor)
                .build();
    }

    @Bean
    public ClientHttpRequestInterceptor myCustomInterceptor() {
        return new MyCustomRequestInterceptor();
    }
}

The controller does not change. Every call now carries X-Custom-Header: myvalue, which product-service can read. The interceptor runs in step 2 of the internal flow, just before the request is executed; calling execution.execute(...) lets it continue.

RestTemplate and RestClient side by side ​

RestTemplateRestClient
APImany overloaded methodsone fluent chain
Statusmaintenance modeactively developed
SinceSpring 3Spring 6.1 / Boot 3.2
Default HTTP libraryHttpURLConnectionJDK HttpClient (with create())
Errorsthrows HttpClientErrorException / HttpServerErrorExceptionsame by default, customizable with onStatus
InterceptorssetInterceptors(...)builder().requestInterceptor(...)

Next: Both clients need the full URL of product-service, port included, written into order-service. Chapter 3 introduces FeignClient, the Spring Cloud client, where you declare an interface and Spring writes the HTTP call for you.

Check yourself ​

  1. How do WebClient and RestClient differ?
  2. What is a fluent API?
  3. What does get() actually create, and what type does it return?
  4. Why does restClient.get().accept(...).uri(...) not compile?
  5. In a POST chain, why can you still call body(...) after header(...)?
  6. What is the difference between body(...), toEntity(...) and toBodilessEntity()?
  7. What does a 404 from the server do to retrieve().body(String.class) by default?
  8. Why does onStatus(response -> { ... return false; }) not compile, and what do you write instead?
  9. When would you call exchange instead of retrieve?
  10. Which HTTP library does RestClient.create() use by default, and what does it add?
  11. Where does a ClientHttpRequestInterceptor run, and what must it call?
Answers
  1. WebClient is asynchronous and non-blocking (reactive); RestClient is synchronous and blocking, waiting for the response.
  2. An API built on method chaining, where each call returns an object that offers the next set of methods.
  3. A DefaultRequestBodyUriSpec with its method set to GET, returned as RequestHeadersUriSpec<?>.
  4. accept returns the self type S, which through the wildcard is only known to be a RequestHeadersSpec, and that interface has no uri method. The chain cannot go back to UriSpec.
  5. After uri, the chain is on RequestBodySpec, which passes itself as S, so header returns RequestBodySpec again.
  6. body returns only the converted body; toEntity returns a ResponseEntity with status, headers and body; toBodilessEntity returns a ResponseEntity<Void> with status and headers only.
  7. It throws HttpClientErrorException (here HttpClientErrorException.NotFound).
  8. That overload takes a ResponseErrorHandler, which has two abstract methods and is not a functional interface. Write onStatus(HttpStatusCode::is4xxClientError, (request, response) -> { throw ...; }).
  9. When you want to check the status and convert the raw response yourself, without ResponseSpec.
  10. The JDK's java.net.http.HttpClient (through JdkClientHttpRequestFactory). It manages connections and supports HTTP/2 as well as HTTP/1.1.
  11. Just before the request is executed; it must call execution.execute(request, body) to continue.