00:00

RestTemplate vs WebClient vs RestClient

Last updated:

Almost every Spring Boot service calls other services over HTTP: a payment gateway, a supplier's stock API, or another microservice. Spring gives you several clients for this, and choosing between RestTemplate vs WebClient vs RestClient is one of the first decisions in a new project. The choice affects how your code reads, how it handles errors and load, and whether it keeps working after future Spring upgrades, because RestTemplate is on its way out.

This tutorial explains how the three clients work, shows the same call written with each one, and adds the newest option: HTTP interface clients declared with @HttpExchange. You will build a catalog service on Spring Boot 4.1 that calls a supplier API, configure base URLs and timeouts, handle errors consistently, avoid the common pitfalls and test the client with MockRestServiceServer.

1. What Are RestTemplate, WebClient and RestClient?

All three send HTTP requests from your application and convert JSON responses into Java objects. They differ in style and threading:

  • RestTemplate (since Spring 3) is the original blocking client. "Blocking" means the calling thread waits until the response arrives. Its API is a long list of methods such as getForObject and exchange.
  • WebClient (since Spring 5) is the reactive, non-blocking client from Spring WebFlux. It returns Mono and Flux types that deliver the result later, without holding a thread while waiting.
  • RestClient (since Spring 6.1) is the modern blocking client. It offers a fluent API in the style of WebClient, but runs on the calling thread like RestTemplate.

On top of these sits a fourth option: HTTP interface clients. You declare a Java interface with annotations such as @GetExchange, and Spring generates the implementation, running it on RestClient by default.

2. Why the Choice Matters Now

  • RestTemplate is being retired. With Spring Framework 7.0 (Spring Boot 4), the Spring team announced its intent to deprecate RestTemplate, with formal deprecation planned for 7.1 and removal in 8.0. New code should not depend on it.
  • Reactive isn't free. WebClient is excellent in reactive applications, but in a Spring MVC app it pulls in the WebFlux stack and Reactor types just to call .block() at the end.
  • Virtual threads changed the trade-off. On Java 21 with virtual threads enabled, blocking calls no longer tie up expensive platform threads, so a blocking client like RestClient scales well for typical services.
  • Less boilerplate. HTTP interface clients replace hand-written client classes with a few annotated methods.

3. How Spring's HTTP Clients Work

None of the three clients opens network connections itself. Each one delegates to an underlying HTTP library through an adapter. For RestTemplate and RestClient that adapter is a ClientHttpRequestFactory, and for WebClient it is a ClientHttpConnector. Spring Boot chooses the library automatically from what is on the classpath:

How Spring HTTP clients reach a remote APIAn HTTP interface with GetExchange methods runs on RestClient. RestClient and RestTemplate are blocking clients that use a ClientHttpRequestFactory adapter, and WebClient is a non-blocking client that uses a ClientHttpConnector. The adapter delegates to an HTTP library that Spring Boot chooses from the classpath, in this order for RestClient and RestTemplate: Apache HttpClient, Jetty HttpClient, Reactor Netty, the JDK HttpClient and HttpURLConnection. WebClient prefers Reactor Netty. The library sends the request to the remote API. Your codeSpring clientAdapterHTTP library (chosen by classpath) HTTP interface @GetExchange methods RestClient blocking, fluent RestTemplate blocking, deprecation announced WebClient non-blocking, Mono / Flux ClientHttpRequestFactory for RestClient and RestTemplate ClientHttpConnector for WebClient 1. Apache HttpClient 2. Jetty HttpClient 3. Reactor Netty 4. JDK HttpClient 5. HttpURLConnection order used for RestClient and RestTemplate; WebClient prefers Reactor Netty RemoteAPI
Figure 1: every Spring HTTP client delegates to an HTTP library through an adapter, and Spring Boot picks that library from the classpath.
  • For RestClient and RestTemplate, the order of preference is Apache HttpClient, Jetty HttpClient, Reactor Netty, the JDK's java.net.http.HttpClient, and finally the old HttpURLConnection.
  • For WebClient, Reactor Netty comes first.
  • Message converters, the same Jackson setup your controllers use, turn JSON into records and back.

Section 10.1 shows how this automatic choice can surprise you.

4. Setup

Spring Boot 4 splits HTTP client support into dedicated starters:

StarterGives you
spring-boot-starter-restclientAuto-configured RestClient.Builder and RestTemplateBuilder, plus HTTP interface client support
spring-boot-starter-webclientAuto-configured WebClient.Builder with Reactor Netty
spring-boot-starter-restclient-test@RestClientTest and MockRestServiceServer

The example uses Spring MVC and RestClient only:

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-webmvc</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-restclient</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-restclient-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
Version note: in Spring Boot 4, RestTemplateBuilder moved from org.springframework.boot.web.client to org.springframework.boot.restclient, and the HTTP client starters above are new. The timeout properties were renamed from spring.http.client.* (Spring Boot 3.4 and 3.5) to spring.http.clients.*. RestTemplate itself is not yet annotated @Deprecated in Spring Framework 7.0, but the deprecation has been announced.

5. The Same Call in Each Client

Each snippet loads one product from the supplier's API, GET /products/{sku}, into this record:

SupplierProduct.java
public record SupplierProduct(String sku, String name, BigDecimal price, int stock) {
}

5.1 RestTemplate

SupplierRestTemplateClient.java
import org.springframework.boot.restclient.RestTemplateBuilder;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

@Component
public class SupplierRestTemplateClient {

    private final RestTemplate restTemplate;

    public SupplierRestTemplateClient(RestTemplateBuilder builder) {
        this.restTemplate = builder.rootUri("http://localhost:8080/supplier-api").build();
    }

    public SupplierProduct getProduct(String sku) {
        return restTemplate.getForObject("/products/{sku}", SupplierProduct.class, sku);
    }
}

Short for simple calls, but every variation (headers, status handling, generic types) needs a different method, often exchange. Use it only in existing code.

5.2 RestClient

SupplierRestClient.java
import java.util.List;

import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
public class SupplierRestClient {

    private final RestClient restClient;

    public SupplierRestClient(RestClient.Builder builder) {
        this.restClient = builder.baseUrl("http://localhost:8080/supplier-api").build();
    }

    public SupplierProduct getProduct(String sku) {
        return restClient.get()
                .uri("/products/{sku}", sku)
                .retrieve()
                .body(SupplierProduct.class);
    }

    public ResponseEntity<SupplierProduct> getProductWithStatus(String sku) {
        return restClient.get()
                .uri("/products/{sku}", sku)
                .retrieve()
                .toEntity(SupplierProduct.class);
    }

    public List<SupplierProduct> findByCategory(String category) {
        return restClient.get()
                .uri(uri -> uri.path("/products").queryParam("category", category).build())
                .retrieve()
                .body(new ParameterizedTypeReference<List<SupplierProduct>>() {});
    }
}

Each call reads as a sentence: method, URI, retrieve(), then how to read the body. toEntity also gives you the status and headers, and ParameterizedTypeReference handles generic types such as lists.

5.3 WebClient

SupplierWebClient.java
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;

import reactor.core.publisher.Mono;

@Component
public class SupplierWebClient {

    private final WebClient webClient;

    public SupplierWebClient(WebClient.Builder builder) {
        this.webClient = builder.baseUrl("http://localhost:8080/supplier-api").build();
    }

    public Mono<SupplierProduct> getProduct(String sku) {
        return webClient.get()
                .uri("/products/{sku}", sku)
                .retrieve()
                .bodyToMono(SupplierProduct.class);
    }
}

The same fluent style, but the result is a Mono that completes later. In a reactive application you return the Mono from your controller. In a Spring MVC application you would have to call .block(), which defeats the purpose.

5.4 HTTP Interface Clients with @HttpExchange

Declare the API as an interface, and Spring builds a proxy (a generated implementation) that sends the requests:

SupplierClient.java and SupplierClientConfig.java
import java.util.List;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

// Spring generates the implementation; the base URL comes from configuration
@HttpExchange("/products")
public interface SupplierClient {

    @GetExchange("/{sku}")
    SupplierProduct getProduct(@PathVariable String sku);

    @GetExchange
    List<SupplierProduct> findByCategory(@RequestParam String category);

    @PostExchange("/reorders")
    ReorderConfirmation reorder(@RequestBody ReorderRequest request);
}

@Configuration
@ImportHttpServices(group = "supplier", types = SupplierClient.class)
public class SupplierClientConfig {
}

@ImportHttpServices registers the interface as a bean in a named group, here supplier. Spring Boot reads the group's base URL and timeouts from spring.http.serviceclient.supplier.*, as section 9 shows. You then inject SupplierClient like any other bean.

6. RestTemplate vs WebClient vs RestClient: Comparison

RestTemplateWebClientRestClient
IntroducedSpring 3.0Spring 5.0Spring 6.1
Programming modelBlockingNon-blocking, reactiveBlocking
API styleTemplate methodsFluentFluent
Return typesObjects, ResponseEntityMono, FluxObjects, ResponseEntity
Streaming responsesLimitedYesLimited
Extra dependencies in an MVC appNoneWebFlux and ReactorNone
Backs HTTP interface clientsPossible (adapter)YesYes (default)
StatusDeprecation announced (formal in Spring 7.1, removal in 8.0)Recommended for reactive codeRecommended for blocking code

7. Handling HTTP Errors

By default, every client throws an exception for 4xx and 5xx responses. Each status code has its own subclass, so you can catch exactly the case you expect:

SituationRestTemplate and RestClientWebClient
404 responseHttpClientErrorException.NotFoundWebClientResponseException.NotFound
Other 4xxHttpClientErrorExceptionWebClientResponseException
5xxHttpServerErrorExceptionWebClientResponseException
Timeout or connection failureResourceAccessExceptionWebClientRequestException

These are the messages from a 404 in this tutorial's tests:

Console (404 from the supplier, one call per client)
RestTemplate: org.springframework.web.client.HttpClientErrorException$NotFound: 404  on GET request for "http://localhost:8080/supplier-api/products/XX-1": [no body]
RestClient:   org.springframework.web.client.HttpClientErrorException$NotFound: 404 : [no body]
WebClient:    org.springframework.web.reactive.function.client.WebClientResponseException$NotFound: 404 Not Found from GET http://localhost:8080/supplier-api/products/XX-1

Don't let these HTTP-specific exceptions leak into your business code. Translate them into your own exception at the boundary, either with onStatus on RestClient:

SupplierRestClient.java
public SupplierProduct getProductOrThrow(String sku) {
    return restClient.get()
            .uri("/products/{sku}", sku)
            .retrieve()
            .onStatus(status -> status.value() == 404, (request, response) -> {
                throw new SupplierProductNotFoundException(sku);
            })
            .body(SupplierProduct.class);
}

or with a try/catch around an HTTP interface call, as the service in section 8 does. Either way, the rest of the application sees one exception, SupplierProductNotFoundException, which returns 404 from your own API. The exception handling tutorial covers mapping exceptions to responses.

8. A Complete Example: Catalog Service Calling a Supplier API

The catalog service exposes two REST endpoints: product availability, and placing a reorder with the supplier. It calls the supplier through an HTTP interface client backed by RestClient. So that the example runs on its own, a stub controller in the same application plays the supplier's API at /supplier-api. In a real system, only the base URL would change.

8.1 The Supplier Client

package com.javabytechie.catalog.supplier;

import java.util.List;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

// Spring generates the implementation; the base URL comes from configuration
@HttpExchange("/products")
public interface SupplierClient {

    @GetExchange("/{sku}")
    SupplierProduct getProduct(@PathVariable String sku);

    @GetExchange
    List<SupplierProduct> findByCategory(@RequestParam String category);

    @PostExchange("/reorders")
    ReorderConfirmation reorder(@RequestBody ReorderRequest request);
}

8.2 Service and Controller

package com.javabytechie.catalog.supplier;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;

@Service
public class SupplierService {

    private static final Logger log = LoggerFactory.getLogger(SupplierService.class);

    private final SupplierClient supplierClient;

    public SupplierService(SupplierClient supplierClient) {
        this.supplierClient = supplierClient;
    }

    public ProductAvailability availability(String sku) {
        try {
            SupplierProduct product = supplierClient.getProduct(sku);
            return new ProductAvailability(product.sku(), product.name(), product.price(),
                    product.stock() > 0, product.stock());
        } catch (HttpClientErrorException.NotFound ex) {
            throw new SupplierProductNotFoundException(sku);
        }
    }

    public ReorderConfirmation reorder(String sku, int quantity) {
        try {
            ReorderConfirmation confirmation = supplierClient.reorder(new ReorderRequest(sku, quantity));
            log.info("Reorder {} placed for {} x {}", confirmation.reference(), quantity, sku);
            return confirmation;
        } catch (HttpClientErrorException.NotFound ex) {
            throw new SupplierProductNotFoundException(sku);
        }
    }
}

8.3 The Supplier Stub

This controller stands in for the external supplier. It is not part of a real client application:

package com.javabytechie.catalog.stub;

import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;

import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import com.javabytechie.catalog.supplier.ReorderConfirmation;
import com.javabytechie.catalog.supplier.ReorderRequest;
import com.javabytechie.catalog.supplier.SupplierProduct;

// Stands in for the supplier's real API so the example runs on its own
@RestController
@RequestMapping("/supplier-api/products")
public class SupplierStubController {

    private static final Map<String, SupplierProduct> PRODUCTS = Map.of(
            "KB-100", new SupplierProduct("KB-100", "Mechanical Keyboard", new BigDecimal("89.00"), 42),
            "MS-100", new SupplierProduct("MS-100", "Wireless Mouse", new BigDecimal("25.50"), 0),
            "MN-200", new SupplierProduct("MN-200", "27-inch Monitor", new BigDecimal("219.00"), 7));

    private final AtomicInteger nextReference = new AtomicInteger(5001);

    @GetMapping("/{sku}")
    public ResponseEntity<SupplierProduct> getProduct(@PathVariable String sku) {
        SupplierProduct product = PRODUCTS.get(sku);
        return product == null ? ResponseEntity.notFound().build() : ResponseEntity.ok(product);
    }

    @GetMapping
    public List<SupplierProduct> findByCategory(@RequestParam String category) {
        String prefix = switch (category) {
            case "keyboards" -> "KB-";
            case "mice" -> "MS-";
            case "monitors" -> "MN-";
            default -> "?";
        };
        return PRODUCTS.values().stream().filter(p -> p.sku().startsWith(prefix)).toList();
    }

    @PostMapping("/reorders")
    public ResponseEntity<ReorderConfirmation> reorder(@RequestBody ReorderRequest request) {
        if (!PRODUCTS.containsKey(request.sku())) {
            return ResponseEntity.notFound().build();
        }
        ReorderConfirmation confirmation = new ReorderConfirmation(
                "RO-" + nextReference.getAndIncrement(), request.sku(), request.quantity(), "ACCEPTED");
        return ResponseEntity.status(HttpStatus.CREATED).body(confirmation);
    }
}

8.4 Run It

Start the app with ./mvnw spring-boot:run and call the catalog API:

Terminal
curl http://localhost:8080/api/catalog/KB-100/availability
{"sku":"KB-100","name":"Mechanical Keyboard","price":89.00,"inStock":true,"stock":42}

curl http://localhost:8080/api/catalog/MS-100/availability
{"sku":"MS-100","name":"Wireless Mouse","price":25.50,"inStock":false,"stock":0}

curl -i http://localhost:8080/api/catalog/XX-1/availability
HTTP/1.1 404
{"timestamp":"2026-10-24T19:00:51.954Z","status":404,"error":"Not Found","path":"/api/catalog/XX-1/availability"}

curl -i -X POST 'http://localhost:8080/api/catalog/MN-200/reorders?quantity=10'
HTTP/1.1 201
{"reference":"RO-5001","sku":"MN-200","quantity":10,"status":"ACCEPTED"}

# application log
INFO  c.j.catalog.supplier.SupplierService : Reorder RO-5001 placed for 10 x MN-200

Each catalog request made one HTTP call to the supplier through SupplierClient. The supplier's 404 for an unknown SKU became the catalog's own 404 through SupplierProductNotFoundException.

9. Configuring Base URLs, Timeouts and the HTTP Library

Spring Boot 4 configures all auto-configured clients from properties, globally or per HTTP service group:

PropertyApplies toExample
spring.http.clients.connect-timeoutAll auto-configured clients2s
spring.http.clients.read-timeoutAll auto-configured clients3s
spring.http.clients.redirectsAll auto-configured clientsdont-follow
spring.http.clients.imperative.factoryRestClient and RestTemplatehttp-components, jetty, reactor, jdk, simple
spring.http.clients.reactive.connectorWebClientreactor, jetty, http-components, jdk
spring.http.serviceclient.<group>.base-urlOne HTTP service grouphttps://api.supplier.example
spring.http.serviceclient.<group>.read-timeoutOne HTTP service group2s

Group settings override the global ones, so the supplier group above waits 2 seconds for a response while other clients wait 3. Keep the base URL in configuration rather than code, so each environment can point to its own supplier endpoint, for example through Spring profiles. For one-off clients, inject the auto-configured RestClient.Builder and set baseUrl(...) on it, as the snippet in section 5.2 does.

10. Common Pitfalls and How to Fix Them

10.1 An Unexpected HTTP Library

Spring Boot picks the HTTP library from the classpath. With only spring-boot-starter-restclient, RestClient uses the JDK client. Adding spring-boot-starter-webclient puts Reactor Netty on the classpath, and because Netty ranks above the JDK client, even RestClient and RestTemplate switch to it:

Console
# spring-boot-starter-restclient only
RestClient   request factory: org.springframework.http.client.JdkClientHttpRequestFactory

# spring-boot-starter-restclient + spring-boot-starter-webclient
RestClient   request factory: org.springframework.http.client.ReactorClientHttpRequestFactory
RestTemplate request factory: org.springframework.http.client.ReactorClientHttpRequestFactory

That is not wrong, but it changes timeouts, connection pooling and log output. How to fix it: add only the starters you use, or set spring.http.clients.imperative.factory explicitly.

10.2 No Timeouts

Without a read timeout, a slow remote service can hold your request threads indefinitely. With spring.http.clients.read-timeout=3s, a call to an endpoint that takes 5 seconds fails after 3 seconds instead:

Console
org.springframework.web.client.ResourceAccessException: I/O error on GET request for "http://localhost:8080/supplier-api/slow": Request cancelled
Caused by: java.net.http.HttpTimeoutException   (thrown after about 3 seconds)

How to fix it: always set connect and read timeouts, and catch ResourceAccessException to return a clear error or a fallback.

10.3 Building a New Client for Every Request

RestClient, RestTemplate and WebClient are thread-safe once built. Creating a new one per call throws away connection pooling and wastes time. How to fix it: build each client once, in a constructor or as a bean, and reuse it.

10.4 Changing a Shared Builder

The auto-configured RestClient.Builder is a prototype bean, so each injection point gets its own builder. Within one class, though, a builder is stateful: settings added for one client carry over to the next client built from it. How to fix it: call builder.clone() before configuring a second client from the same builder.

10.5 Calling block() in Reactive Code

.block() is acceptable at the edge of a blocking application, but inside a WebFlux handler it stalls one of the few event-loop threads and can freeze the whole server. How to fix it: in reactive code, return the Mono or Flux. In blocking code, use RestClient instead of WebClient plus block().

10.6 Staying on RestTemplate

Existing RestTemplate code still works on Spring Boot 4, but the class is planned to be formally deprecated in Spring Framework 7.1 and removed in 8.0. How to fix it: migrate gradually. RestClient.create(restTemplate) or RestClient.builder(restTemplate) builds a RestClient that reuses an existing RestTemplate's request factory, interceptors and converters, so you can switch one call at a time.

11. Testing HTTP Clients with MockRestServiceServer

@RestClientTest starts only the HTTP client infrastructure, and MockRestServiceServer intercepts the requests, so no real server is needed. Each test declares the expected request and a canned response, calls the service, and checks the result:

SupplierServiceTest.java
package com.javabytechie.catalog.supplier;

import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.content;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.method;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withResourceNotFound;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.ImportAutoConfiguration;
import org.springframework.boot.http.client.autoconfigure.service.HttpServiceClientPropertiesAutoConfiguration;
import org.springframework.boot.restclient.autoconfigure.service.HttpServiceClientAutoConfiguration;
import org.springframework.boot.restclient.test.autoconfigure.RestClientTest;
import org.springframework.context.annotation.Import;
import org.springframework.http.HttpMethod;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;

@RestClientTest(SupplierService.class)
@Import(SupplierClientConfig.class)
// HTTP service clients are not part of the @RestClientTest slice, so add them
@ImportAutoConfiguration({HttpServiceClientPropertiesAutoConfiguration.class, HttpServiceClientAutoConfiguration.class})
class SupplierServiceTest {

    @Autowired
    private SupplierService supplierService;

    @Autowired
    private MockRestServiceServer supplierServer;

    @Test
    void mapsSupplierProductToAvailability() {
        supplierServer.expect(requestTo("http://localhost:8080/supplier-api/products/KB-100"))
                .andRespond(withSuccess("""
                        {"sku":"KB-100","name":"Mechanical Keyboard","price":89.00,"stock":42}
                        """, MediaType.APPLICATION_JSON));

        ProductAvailability availability = supplierService.availability("KB-100");

        assertThat(availability.inStock()).isTrue();
        assertThat(availability.stock()).isEqualTo(42);
        supplierServer.verify();
    }

    @Test
    void translatesSupplier404IntoNotFoundException() {
        supplierServer.expect(requestTo("http://localhost:8080/supplier-api/products/XX-1"))
                .andRespond(withResourceNotFound());

        assertThatThrownBy(() -> supplierService.availability("XX-1"))
                .isInstanceOf(SupplierProductNotFoundException.class)
                .hasMessage("Supplier has no product XX-1");
    }

    @Test
    void sendsReorderAsJson() {
        supplierServer.expect(requestTo("http://localhost:8080/supplier-api/products/reorders"))
                .andExpect(method(HttpMethod.POST))
                .andExpect(content().json("""
                        {"sku":"MN-200","quantity":10}
                        """))
                .andRespond(withStatus(HttpStatus.CREATED)
                        .contentType(MediaType.APPLICATION_JSON)
                        .body("""
                                {"reference":"RO-5001","sku":"MN-200","quantity":10,"status":"ACCEPTED"}
                                """));

        ReorderConfirmation confirmation = supplierService.reorder("MN-200", 10);

        assertThat(confirmation.reference()).isEqualTo("RO-5001");
        supplierServer.verify();
    }
}
  • In Spring Boot 4.1, the @RestClientTest slice doesn't include the HTTP service client auto-configuration. The two classes in @ImportAutoConfiguration add it, so SupplierClient is created and bound to the mock server. Without them, the test fails with Unable to use auto-configured MockRestServiceServer since a mock server customizer has not been bound to a RestTemplate or RestClient.
  • The expected URLs use the base URL from application.properties, but no request leaves the JVM.
  • supplierServer.verify() fails the test if an expected request was never sent.

For WebClient, MockRestServiceServer doesn't apply. Use a mock HTTP server such as OkHttp's MockWebServer or WireMock instead. The Spring Boot testing tutorial covers test slices in general.

12. Which HTTP Client Should You Use?

SituationGood choicePoor choice
New Spring MVC service calling REST APIsHTTP interface client or RestClientRestTemplate
A remote API with many endpointsHTTP interface clientA hand-written client class per endpoint
Reactive WebFlux applicationWebClientRestClient or .block() calls
Streaming large or endless responsesWebClientReading everything into memory
Existing RestTemplate codeGradual migration with RestClient.create(restTemplate)A big-bang rewrite
High concurrency in a blocking appRestClient on virtual threadsSwitching the whole app to WebFlux

13. Best Practices

  • Use RestClient for blocking code, and keep RestTemplate only in code you are migrating.
  • Prefer HTTP interface clients for APIs with several endpoints, so the contract lives in one annotated interface.
  • Use WebClient only in reactive code, and never call .block() on an event-loop thread.
  • Inject the auto-configured builders instead of RestClient.create(), so Spring Boot's message converters, timeouts and observability apply.
  • Keep base URLs in configuration, per environment, with spring.http.serviceclient.<group>.base-url.
  • Always set connect and read timeouts, globally with spring.http.clients.* and per group where a remote API needs different limits.
  • Translate HTTP errors at the boundary into your own exceptions, so the rest of the code never sees HttpClientErrorException.
  • Build clients once and reuse them, and clone a builder before configuring a second client from it.
  • Choose the HTTP library on purpose, with the starters you add or spring.http.clients.imperative.factory.
  • Test clients with MockRestServiceServer, checking the request sent and the mapping of both success and error responses.

14. Summary

In the RestTemplate vs WebClient vs RestClient decision, Spring's direction is clear: RestClient is the blocking client for new code, WebClient is for reactive applications and streaming, and RestTemplate is headed for deprecation and removal. In Spring Boot 4, add spring-boot-starter-restclient, declare remote APIs as HTTP interface clients with @HttpExchange and @ImportHttpServices, configure base URLs and timeouts with spring.http.serviceclient.* and spring.http.clients.*, translate HTTP errors into your own exceptions, and test with @RestClientTest and MockRestServiceServer. The main recommendation: use HTTP interface clients on RestClient for Spring MVC services, and reach for WebClient only when the application is truly reactive.