RestTemplate vs WebClient vs RestClient
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
getForObjectandexchange. - WebClient (since Spring 5) is the reactive, non-blocking client from Spring WebFlux. It returns
MonoandFluxtypes 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.
WebClientis 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
RestClientscales 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:
- For
RestClientandRestTemplate, the order of preference is Apache HttpClient, Jetty HttpClient, Reactor Netty, the JDK'sjava.net.http.HttpClient, and finally the oldHttpURLConnection. - 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:
| Starter | Gives you |
|---|---|
spring-boot-starter-restclient | Auto-configured RestClient.Builder and RestTemplateBuilder, plus HTTP interface client support |
spring-boot-starter-webclient | Auto-configured WebClient.Builder with Reactor Netty |
spring-boot-starter-restclient-test | @RestClientTest and MockRestServiceServer |
The example uses Spring MVC and RestClient only:
<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>
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:
public record SupplierProduct(String sku, String name, BigDecimal price, int stock) {
}
5.1 RestTemplate
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
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
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:
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
| RestTemplate | WebClient | RestClient | |
|---|---|---|---|
| Introduced | Spring 3.0 | Spring 5.0 | Spring 6.1 |
| Programming model | Blocking | Non-blocking, reactive | Blocking |
| API style | Template methods | Fluent | Fluent |
| Return types | Objects, ResponseEntity | Mono, Flux | Objects, ResponseEntity |
| Streaming responses | Limited | Yes | Limited |
| Extra dependencies in an MVC app | None | WebFlux and Reactor | None |
| Backs HTTP interface clients | Possible (adapter) | Yes | Yes (default) |
| Status | Deprecation announced (formal in Spring 7.1, removal in 8.0) | Recommended for reactive code | Recommended 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:
| Situation | RestTemplate and RestClient | WebClient |
|---|---|---|
| 404 response | HttpClientErrorException.NotFound | WebClientResponseException.NotFound |
| Other 4xx | HttpClientErrorException | WebClientResponseException |
| 5xx | HttpServerErrorException | WebClientResponseException |
| Timeout or connection failure | ResourceAccessException | WebClientRequestException |
These are the messages from a 404 in this tutorial's tests:
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:
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);
}
package com.javabytechie.catalog.supplier;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.registry.ImportHttpServices;
@Configuration
@ImportHttpServices(group = "supplier", types = SupplierClient.class)
public class SupplierClientConfig {
}
package com.javabytechie.catalog.supplier;
import java.math.BigDecimal;
public record SupplierProduct(String sku, String name, BigDecimal price, int stock) {
}
package com.javabytechie.catalog.supplier;
public record ReorderRequest(String sku, int quantity) {
}
package com.javabytechie.catalog.supplier;
public record ReorderConfirmation(String reference, String sku, int quantity, String status) {
}
spring.application.name=catalog-service
# Every auto-configured HTTP client
spring.http.clients.connect-timeout=2s
spring.http.clients.read-timeout=3s
# The "supplier" group of HTTP interface clients
spring.http.serviceclient.supplier.base-url=http://localhost:8080/supplier-api
spring.http.serviceclient.supplier.read-timeout=2s
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);
}
}
}
package com.javabytechie.catalog.supplier;
import org.springframework.http.HttpStatus;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/catalog")
public class CatalogController {
private final SupplierService supplierService;
public CatalogController(SupplierService supplierService) {
this.supplierService = supplierService;
}
@GetMapping("/{sku}/availability")
public ProductAvailability availability(@PathVariable String sku) {
return supplierService.availability(sku);
}
@PostMapping("/{sku}/reorders")
@ResponseStatus(HttpStatus.CREATED)
public ReorderConfirmation reorder(@PathVariable String sku, @RequestParam int quantity) {
return supplierService.reorder(sku, quantity);
}
}
package com.javabytechie.catalog.supplier;
import java.math.BigDecimal;
public record ProductAvailability(String sku, String name, BigDecimal price, boolean inStock, int stock) {
}
package com.javabytechie.catalog.supplier;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ResponseStatus;
@ResponseStatus(HttpStatus.NOT_FOUND)
public class SupplierProductNotFoundException extends RuntimeException {
public SupplierProductNotFoundException(String sku) {
super("Supplier has no product " + sku);
}
}
package com.javabytechie.catalog;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class CatalogApplication {
public static void main(String[] args) {
SpringApplication.run(CatalogApplication.class, args);
}
}
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:
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:
| Property | Applies to | Example |
|---|---|---|
spring.http.clients.connect-timeout | All auto-configured clients | 2s |
spring.http.clients.read-timeout | All auto-configured clients | 3s |
spring.http.clients.redirects | All auto-configured clients | dont-follow |
spring.http.clients.imperative.factory | RestClient and RestTemplate | http-components, jetty, reactor, jdk, simple |
spring.http.clients.reactive.connector | WebClient | reactor, jetty, http-components, jdk |
spring.http.serviceclient.<group>.base-url | One HTTP service group | https://api.supplier.example |
spring.http.serviceclient.<group>.read-timeout | One HTTP service group | 2s |
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:
# 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:
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:
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
@RestClientTestslice doesn't include the HTTP service client auto-configuration. The two classes in@ImportAutoConfigurationadd it, soSupplierClientis created and bound to the mock server. Without them, the test fails withUnable 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?
| Situation | Good choice | Poor choice |
|---|---|---|
| New Spring MVC service calling REST APIs | HTTP interface client or RestClient | RestTemplate |
| A remote API with many endpoints | HTTP interface client | A hand-written client class per endpoint |
| Reactive WebFlux application | WebClient | RestClient or .block() calls |
| Streaming large or endless responses | WebClient | Reading everything into memory |
| Existing RestTemplate code | Gradual migration with RestClient.create(restTemplate) | A big-bang rewrite |
| High concurrency in a blocking app | RestClient on virtual threads | Switching the whole app to WebFlux |
13. Best Practices
- Use RestClient for blocking code, and keep
RestTemplateonly 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.
Frequently Asked Questions (FAQ)
Q1. Is RestTemplate deprecated in Spring Boot 4?
Not formally yet. Spring Framework 7.0, used by Spring Boot 4, announced the intent to deprecate RestTemplate, but the class is not annotated @Deprecated in 7.0. The Spring team plans to deprecate it in 7.1 and remove it in 8.0, so new code should use RestClient.
Q2. Should I use WebClient in a Spring MVC application?
Usually not. In a blocking Spring MVC app, WebClient adds WebFlux and Reactor dependencies and ends with .block() calls, while RestClient gives the same fluent API without them. Use WebClient when the application is reactive or needs streaming responses.
Q3. Is RestClient faster than WebClient?
Neither is faster in general, because both delegate to the same kinds of HTTP libraries. The difference is threading: WebClient doesn't hold a thread while waiting, which helps when an app makes very many slow calls at once. On Java 21 with virtual threads, RestClient handles high concurrency well too, so measure before choosing for performance.
Q4. What are HTTP interface clients in Spring?
They are Java interfaces whose methods are annotated with @GetExchange, @PostExchange and similar annotations. Spring generates the implementation at runtime and runs it on RestClient by default. In Spring Boot 4 you register them with @ImportHttpServices and configure each group with spring.http.serviceclient.<group>.* properties.
Q5. How do I set timeouts for RestClient in Spring Boot?
Set spring.http.clients.connect-timeout and spring.http.clients.read-timeout for all auto-configured clients, or spring.http.serviceclient.<group>.read-timeout for one group of HTTP interface clients. These apply to clients built from the auto-configured RestClient.Builder. In Spring Boot 3.4 and 3.5, the global properties were named spring.http.client.*.
Q6. How do I migrate from RestTemplate to RestClient?
Start with RestClient.create(restTemplate) or RestClient.builder(restTemplate), which reuse the existing template's request factory, interceptors and message converters. Then rewrite calls one at a time: getForObject becomes get().uri(...).retrieve().body(...), and exchange becomes retrieve().toEntity(...). Finally, consider replacing whole client classes with an HTTP interface.
Q7. Why does RestClient use Reactor Netty in my application?
Spring Boot picks the HTTP library from the classpath, and Reactor Netty ranks above the JDK client. Adding spring-boot-starter-webclient or WebFlux puts Netty on the classpath, so RestClient and RestTemplate switch to it. Set spring.http.clients.imperative.factory=jdk to choose the JDK client explicitly.
Q8. How do I test code that uses RestClient?
Use @RestClientTest with the auto-configured MockRestServiceServer, declare the expected request and a canned response, and assert on your service's result. For HTTP interface clients in Spring Boot 4.1, also import HttpServiceClientPropertiesAutoConfiguration and HttpServiceClientAutoConfiguration, because the test slice doesn't include them.