RestTemplate entered maintenance mode, and many people have since wondered how to write code for calling external APIs. WebClient, on the other hand, assumes Reactor, and calling block() every time just for synchronous processing feels somewhat unnatural.

That’s where RestClient, introduced in Spring Boot 3.2 (Spring Framework 6.1), comes in. It’s a modern HTTP client that is imperative and synchronous yet has a fluent API, making it the top choice for synchronous use cases. In this article, I’ll walk through everything from basic requests to error handling, timeouts, testing, and migrating from RestTemplate—enough to get you writing real code.

Note that the code in this article has been verified on Spring Boot 3.4 and later (Spring Framework 6.2). RestClient itself is available from 3.2, but the timeout configuration described later differs by version, so I’ll also include the 3.2-oriented way of writing it.

Where RestClient Fits In

Roughly speaking, here’s the situation:

  • RestTemplate is in maintenance mode, and no new features are being added
  • WebClient can be used synchronously, but the Reactor dependency and the awkwardness of block() remain
  • RestClient fills the gap between the two as an imperative, synchronous fluent API

What’s interesting is that RestClient internally reuses the same infrastructure as RestTemplate (ClientHttpRequestFactory, etc.). In other words, the execution model remains the same synchronous blocking one as RestTemplate—only the API has been modernized, which makes it easy to grasp. Since you can rewrite your code without bringing in a reactive stack, it fits existing synchronous applications extremely well.

Creating a RestClient

The simplest approach is RestClient.create(). When you want to add configuration, use builder(). In practice, since you’ll want to consolidate the base URL and common headers, I recommend defining it as a Bean and reusing it.

@Configuration
public class RestClientConfig {

    @Bean
    public RestClient userApiClient() {
        return RestClient.builder()
                .baseUrl("https://api.example.com")
                .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
                .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                .build();
    }
}

By managing baseUrl() centrally, each request only needs to specify the path. Common headers like authentication tokens should also be consolidated in defaultHeader().

Writing GET/POST/PUT/DELETE with the Fluent API

GET and Receiving the Response

The basic form is method().uri().retrieve().body(type). If you want to deserialize a GET response directly into an object, you can write it like this:

// GET: pass a URI variable and receive it as an object
User user = userApiClient.get()
        .uri("/users/{id}", 42)
        .retrieve()
        .body(User.class);

// Query parameters can be assembled with UriBuilder
List<User> users = userApiClient.get()
        .uri(uriBuilder -> uriBuilder.path("/users")
                .queryParam("role", "admin")
                .build())
        .retrieve()
        .body(new ParameterizedTypeReference<>() {});

The key point is to receive generic types like lists with ParameterizedTypeReference.

POST/PUT/DELETE and Sending a Body

For POST and PUT, you pass the request body to body().

// POST: send a request body and use toEntity() since we also want the status and headers
ResponseEntity<User> response = userApiClient.post()
        .uri("/users")
        .body(new CreateUserRequest("mizu", "admin"))
        .retrieve()
        .toEntity(User.class);

URI location = response.getHeaders().getLocation();

// PUT: update
User updated = userApiClient.put()
        .uri("/users/{id}", 42)
        .body(new UpdateUserRequest("newName"))
        .retrieve()
        .body(User.class);

// DELETE: use toBodilessEntity() if you don't need the response body
userApiClient.delete()
        .uri("/users/{id}", 42)
        .retrieve()
        .toBodilessEntity();

Choose between body(type) if you only want the body, toEntity(type) if you also want to see the status code and headers, and toBodilessEntity() if you don’t need the response body.

Converting Errors into Exceptions with onStatus

By default, retrieve() throws 4xx/5xx responses as RestClientResponseException. In practice, however, this is hard to handle on the business logic side as-is, so you’ll often want to convert them into your own exceptions. That’s where onStatus() comes in.

User user = userApiClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .onStatus(HttpStatusCode::is4xxClientError, (request, response) -> {
            if (response.getStatusCode().isSameCodeAs(HttpStatus.NOT_FOUND)) {
                throw new UserNotFoundException(id);
            }
            throw new ExternalApiException("Client error: " + response.getStatusCode());
        })
        .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
            throw new ExternalApiException("Server error: " + response.getStatusCode());
        })
        .body(User.class);

For status comparison, it’s safer to use isSameCodeAs() rather than reference comparison (==). Since the return type of getStatusCode() is HttpStatusCode, == will break down if custom codes get mixed in the future. You can also read the response body from the handler’s second argument, so you can parse the error JSON and include it in the message. When you want more low-level control, you also have the option of using exchange() instead of retrieve() and processing the entire response yourself.

Configuring connect/read Timeouts

In production operations, timeout configuration is essential. If you wait indefinitely when the target API doesn’t respond, threads get exhausted and the entire application gets dragged down. On Spring Boot 3.4 and later, you can write it like this using ClientHttpRequestFactorySettings and ClientHttpRequestFactoryBuilder:

@Bean
public RestClient userApiClient() {
    // org.springframework.boot.http.client package (3.4 and later)
    ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.defaults()
            .withConnectTimeout(Duration.ofSeconds(2))
            .withReadTimeout(Duration.ofSeconds(5));

    ClientHttpRequestFactory requestFactory =
            ClientHttpRequestFactoryBuilder.detect().build(settings);

    return RestClient.builder()
            .baseUrl("https://api.example.com")
            .requestFactory(requestFactory)
            .build();
}

Since this builder doesn’t yet exist in Spring Boot 3.2/3.3, use ClientHttpRequestFactories.get(ClientHttpRequestFactorySettings.DEFAULTS.withConnectTimeout(...).withReadTimeout(...)) instead (DEFAULTS is a constant, and the package is org.springframework.boot.web.client).

The factory is auto-detected from the classpath, so if you include a client like Apache HttpClient or Jetty, that one will be used. If you want fine-grained control over the connection pool, it’s a good idea to explicitly choose Apache HttpClient. When a timeout is exceeded, you’ll want to connect it to retries or a circuit breaker, but I’ll leave that to the article on implementing a circuit breaker with Resilience4j.

Steps for Migrating from RestTemplate

Existing RestTemplate code can be replaced with RestClient almost mechanically. Here’s how the commonly used methods map:

RestTemplateRestClient
getForObject(url, T.class)get().uri(url).retrieve().body(T.class)
getForEntity(url, T.class)get().uri(url).retrieve().toEntity(T.class)
postForObject(url, body, T.class)post().uri(url).body(body).retrieve().body(T.class)
exchange(...)method().uri().body().retrieve().toEntity()

The exchange(...) row is a generalized form, so for requests that don’t send a body, like GET or DELETE, you don’t include body(). It’s easier to picture when you compare them in actual code.

// Before: RestTemplate
ResponseEntity<User> res = restTemplate.exchange(
        "/users/{id}", HttpMethod.GET, null, User.class, id);
User user = res.getBody();

// After: RestClient (no body() since it's a GET)
User user = restClient.get()
        .uri("/users/{id}", id)
        .retrieve()
        .body(User.class);

You don’t have to do the migration all at once. You can have the existing RestTemplate Bean and the RestClient Bean coexist, replacing code in the order you touch it. Existing interceptor assets can be carried over with requestInterceptor().

One thing to watch out for is the difference in error handling behavior. With RestTemplate it depended on the DefaultResponseErrorHandler configuration, but RestClient turns 4xx/5xx into exceptions at the retrieve() point. So after migrating, explicitly write onStatus() and confirm that the behavior matches what it was before.

Testing with MockRestServiceServer

For unit tests of code that uses RestClient, you can use MockRestServiceServer—familiar from the RestTemplate era—as-is. The key is to make the RestClient passed to the service under test and the builder that binds the mock the same one. Here, we set the same baseUrl as in production and write the verifications with the same absolute URL.

RestClient.Builder builder = RestClient.builder()
        .baseUrl("https://api.example.com");
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();

// Pass the service a RestClient created from the builder that has baseUrl set
UserService service = new UserService(builder.build());

server.expect(requestTo("https://api.example.com/users/42"))
        .andExpect(method(HttpMethod.GET))
        .andRespond(withSuccess("{\"id\":42,\"name\":\"mizu\"}",
                MediaType.APPLICATION_JSON));

User user = service.findById(42);
assertThat(user.name()).isEqualTo("mizu");
server.verify();

If you align the baseUrl, then even when the service side calls with a relative path like /users/{id}, it will match the absolute URL requestTo("https://api.example.com/users/42"). By returning an error response like andRespond(withStatus(HttpStatus.NOT_FOUND)), you can also verify that the onStatus() branch throws an exception as intended. When you want to verify the entire integration with an external API, the approach in the article on testing external APIs with WireMock is a good fit.

So Which One Should You Choose?

Finally, let me summarize the guidelines for choosing:

  • If synchronous and imperative is fine, RestClient is the top choice
  • If you need asynchronous or streaming, use WebClient
  • If you want to create a client just by declaring an interface, use @HttpExchange
  • If you value Feign compatibility or its ecosystem, use OpenFeign

The comparison of RestTemplate and WebClient itself is covered in the article on choosing between RestTemplate and WebClient, and declarative clients are covered in the @HttpExchange article and the OpenFeign article, so if you want to dig deeper, please check those out as well.

Conclusion

RestClient is a straightforward successor that keeps RestTemplate’s synchronous execution model while modernizing only the API. It’s readable thanks to the fluent API, error conversion can be written cleanly with onStatus(), and for testing you can use the existing MockRestServiceServer as-is. If you’re looking for a migration target for an HTTP client in synchronous use cases, I recommend trying RestClient first.