RestClient
Call other HTTP APIs with RestClient, the synchronous fluent client that Spring Boot configures for you through an injected RestClient.Builder.
RestClient rest = builder.baseUrl("https://api.example.com").build();
T value = rest.get().uri("/things/{id}", id).retrieve().body(T.class);
ResponseEntity<T> entity = rest.post().uri("/things").body(request).retrieve().toEntity(T.class);Most back ends call something else: a payment provider, a catalogue service, a geocoder. RestClient (in org.springframework.web.client) is Spring's synchronous HTTP client with a fluent API. It blocks the calling thread until the response arrives, which is exactly what you want in a Spring MVC application, and it pairs well with virtual threads.
In Spring Boot 4 the client support lives in its own starter, spring-boot-starter-restclient. With it on the classpath Boot registers a RestClient.Builder bean that already has the JSON message converters, the HTTP library Boot detected, timeouts from spring.http.clients.*, observation for metrics and tracing (when Actuator is present), and every RestClientCustomizer bean applied.
The shape of a call
Product product = rest.get()
.uri("/products/{sku}", sku)
.retrieve()
.body(Product.class);Pick the method (get(), post(), put(), patch(), delete()), set the URI with template variables, add headers or a body, then retrieve() and say what you want back. Template variables are encoded for you, so never build the URL by concatenating user input.
body(Type.class)converts the JSON body. It returnsnullwhen the response has no body;requiredBody(Type.class)fails instead, which is usually what you mean.body(new ParameterizedTypeReference<List<Product>>() {})keeps generic types such as lists.toEntity(Type.class)gives you aResponseEntitywith status and headers as well, for example to read aLocationheader after aPOST.toBodilessEntity()is for calls where only the status matters.
By default any 4xx or 5xx response becomes an exception. The lesson on remote errors covers how to translate those.
Build once, reuse everywhere
A built RestClient is immutable and thread-safe. Build it in the constructor of the class that owns the integration and keep it in a field. The builder bean, on the other hand, is prototype-scoped: each injection point gets a fresh copy, so setting a base URL in one client never leaks into another.
The common mistakes are calling RestClient.create() or RestClient.builder() in production code, which skips everything Boot configured (no timeouts, no tracing), and scattering raw HTTP calls through services. Keep each remote API behind one small class with methods named after what the API does; the rest of the code then depends on catalog.product(sku), not on URLs and status codes.
RestTemplate still exists and still works, but new code should use RestClient. They can share the same underlying request factory and message converters.
Example
import java.math.BigDecimal;
import java.util.List;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
record Product(String sku, String name, BigDecimal price) {
}
public class CatalogClient {
private final RestClient rest;
CatalogClient(RestClient.Builder builder, ("${app.catalog.base-url}") String baseUrl) {
// The injected builder already carries Boot's converters, timeouts and observation.
this.rest = builder
.baseUrl(baseUrl)
.defaultHeader("Accept", MediaType.APPLICATION_JSON_VALUE)
.build();
}
public Product product(String sku) {
return rest.get()
.uri("/products/{sku}", sku) // sku is URL-encoded for you
.retrieve()
.requiredBody(Product.class); // fails rather than returning null
}
public List<Product> search(String term, int limit) {
return rest.get()
.uri(uri -> uri.path("/products")
.queryParam("q", term)
.queryParam("limit", limit)
.build())
.retrieve()
.body(new ParameterizedTypeReference<List<Product>>() {
});
}
}
When to use it
- Fetch product details from an internal catalogue service while building an order, with one CatalogClient class that owns the URLs.
- Create a shipment at a courier's API and store the Location header it returns as the tracking reference.
- Add a request id header to every outbound call through a single RestClientCustomizer bean so logs across services line up.
More examples
POST a body and read the Location header
<code>toEntity</code> is the tool when the status or headers carry information, as with <code>201 Created</code> and its <code>Location</code> header. <code>toBodilessEntity()</code> still runs the default status handling, so a failed delete throws instead of passing silently. Passing a record to <code>body(...)</code> lets the configured Jackson converter write the JSON.
import java.net.URI;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
record NewShipment(String orderId, String postcode, int parcels) {
}
record ShipmentCreated(String id, String status) {
}
@Component
public class ShippingClient {
private final RestClient rest;
ShippingClient(RestClient.Builder builder, @Value("${app.shipping.base-url}") String baseUrl) {
this.rest = builder.baseUrl(baseUrl).build();
}
public URI book(NewShipment shipment) {
ResponseEntity<ShipmentCreated> response = rest.post()
.uri("/shipments")
.contentType(MediaType.APPLICATION_JSON)
.body(shipment) // serialised to JSON by Jackson
.retrieve()
.toEntity(ShipmentCreated.class);
// 201 Created: the new resource's address is in the header, not the body.
return response.getHeaders().getLocation();
}
public void cancel(String shipmentId) {
rest.delete()
.uri("/shipments/{id}", shipmentId)
.retrieve()
.toBodilessEntity(); // only the status matters
}
}
One customiser for every client
Boot applies every <code>RestClientCustomizer</code> bean to the <code>RestClient.Builder</code> it hands out, so this interceptor reaches every client in the application without each one opting in. Use it for cross-cutting concerns such as a request id or a user agent. Credentials for one particular API belong on that API's client, not here, or you will send one provider's token to another.
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.boot.restclient.RestClientCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class OutboundHttpConfig {
@Bean
RestClientCustomizer requestIdPropagation() {
return builder -> builder.requestInterceptor((request, body, execution) -> {
String requestId = MDC.get("requestId");
request.getHeaders().set("X-Request-Id",
requestId != null ? requestId : UUID.randomUUID().toString());
return execution.execute(request, body);
});
}
}
Testing a client with MockRestServiceServer
<code>MockRestServiceServer.bindTo(builder)</code> swaps the builder's request factory for one that answers from your expectations, so no socket is opened. Bind before the client calls <code>build()</code>, otherwise the client keeps the real factory. <code>verify()</code> fails the test if an expected request never happened. Spring Boot also has a <code>spring-boot-starter-restclient-test</code> starter for sliced client tests.
import static org.assertj.core.api.Assertions.assertThat;
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.withSuccess;
import org.junit.jupiter.api.Test;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
record Product(String sku, String name) {
}
class CatalogClient {
private final RestClient rest;
CatalogClient(RestClient.Builder builder) {
this.rest = builder.baseUrl("https://catalog.test").build();
}
Product product(String sku) {
return rest.get().uri("/products/{sku}", sku).retrieve().requiredBody(Product.class);
}
}
class CatalogClientTest {
@Test
void readsOneProduct() {
RestClient.Builder builder = RestClient.builder();
MockRestServiceServer server = MockRestServiceServer.bindTo(builder).build();
CatalogClient client = new CatalogClient(builder); // build the client after binding
server.expect(requestTo("https://catalog.test/products/AB-1"))
.andExpect(method(HttpMethod.GET))
.andRespond(withSuccess("{\"sku\": \"AB-1\", \"name\": \"Kettle\"}",
MediaType.APPLICATION_JSON));
assertThat(client.product("AB-1").name()).isEqualTo("Kettle");
server.verify();
}
}
Discussion