Building Robust API Clients with RestClient in Spring Boot 3.X

In this post, we’ll explore how to use the RestClient class in Spring Boot 3 to perform GET, POST, PUT, and DELETE operations against REST APIs.

· Prerequisites
· Overview
∘ Why RestClient Was Introduced in Spring Boot 3
∘ Choosing the Right REST Client in Spring Boot 3
· Real-World Example: CRUD on Products using FakeStoreAPI in Spring Boot 3
∘ Configure the RestClient Bean
∘ Product DTO (model)
∘ Using the RestClient: Service Layer – ProductService
∘ Controller
∘ Error handling patterns
∘ Test API Calls
· Conclusion
· References


Prerequisites

This is the list of all the prerequisites:

  • Spring Boot 3
  • Maven 3.6.3 or later
  • Java 21
  • IntelliJ IDEA, Visual Studio Code, or another IDE
  • Postman / insomnia or any other API testing tool.

Overview

In the world of modern web applications, communicating with external services through APIs is a common practice. Whether you’re fetching data from an external server or sending data to another microservice, having a reliable and efficient way to handle these API calls is essential. Spring Framework 6 and Spring Boot 3.2 introduced RestClient, a synchronous HTTP client that offers a modern, fluent API. It provides an abstraction over HTTP libraries, allowing for convenient conversion from a Java object to an HTTP request and the creation of objects from an HTTP response.

Why RestClient Was Introduced in Spring Boot 3

Spring Boot 3.2 includes support for the new RestClient interface, which has been introduced in Spring Framework 6.1. This interface provides a functional style blocking HTTP API with a similar design to WebClientRestClient fixes that by offering a modern, fluent API that’s easier to read and maintain. It is the successor to RestTemplate for synchronous calls. It keeps things simple for everyday API interactions without forcing you into the complexity of reactive programming like WebClient.

Choosing the Right REST Client in Spring Boot 3

The Spring Framework provides the following choices for making calls to REST endpoints:

  • RestClient – synchronous client with a fluent API.
  • WebClient – non-blocking, reactive client with fluent API.
  • RestTemplate – synchronous client with template method API. (Still works, but deprecated.)
  • HTTP Interface — annotated interface with generated, dynamic proxy implementation.

Real-World Example: CRUD on Products using FakeStoreAPI in Spring Boot 3

We’ll use the FakeStoreAPI’s /products endpoints for GET, POST, PUT, and DELETE operations, as documented by FakeStoreAPI.

Let’s set up a simple project and make our first API call with RestClient.

RestClient is already included in the Spring Boot Web starter, so no additional dependency is required.

Configure the RestClient Bean

You can create a RestClient using one of the static create methods. You can also use RestClient::builder to get a builder with further options, such as specifying the HTTP client to use, setting a default URL, path variables, and headers, or registering interceptors and initializers.

We create a FakeStoreClientConfig class.

@Configuration
public class FakeStoreClientConfig {

@Bean
public RestClient fakeStoreClient(RestClient.Builder builder) {
return builder
.baseUrl("https://fakestoreapi.com")
.build();
}
}

Product DTO (model)

Define a DTO (or record) matching the structure from FakeStoreAPI’s product JSON:

public record Product(
Long id,
String title,
String description,
Double price,
String category,
String image
) {}

Using the RestClient: Service Layer – ProductService

When making an HTTP request with the RestClient, the first thing to specify is which HTTP method to use. This can be done with method(HttpMethod) or with the convenience methods get()head()post(), and so on.

@Service
public class ProductService {
private final Logger log = LoggerFactory.getLogger(ProductService.class);
private final RestClient restClient;

public ProductService(RestClient restClient) {
this.restClient = restClient;
}

// methods below...
}

At runtime, when Spring creates the ProductService bean, it looks into the ApplicationContext for a RestClient bean (from your @Configuration class), and injects it automatically when creating the service.

Then we need to create methods to interact with our ProductService

  1. getAllProducts()
    public List<Product> getAllProducts() {
return restClient.get()
.uri("/products")
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
}

This method call GET /products to the FakeStore API and returns a List<Product>. It used ParameterizedTypeReference to deserialize JSON array -> List<Product>

If you prefer, you can receive an array and convert:

Product[] arr = restClient.get().uri("/products").retrieve().body(Product[].class);
return Arrays.asList(arr);

2. getProductById(Long id)

public Product getProductById(Long id) {
return restClient.get()
.uri("/products/{id}", id)
.retrieve()
.body(Product.class);
}

It calls GET /products/{id} and maps the response JSON to Product. When the remote API returns a 404, a RestClientResponseException (or its subclass) is triggered. It’s common practice to map this to a domain-specific exception, such as ProductNotFoundException, allowing controllers to return a 404 status to clients.

public Product getProductByIdOrThrow(Long id) {
try {
return getProductById(id);
} catch (RestClientResponseException ex) {
if (ex.getStatusCode() == HttpStatus.NOT_FOUND) {
throw new ProductNotFoundException(id, ex);
}
throw new ExternalApiException("Failed to fetch product " + id, ex);
}
}

3. createProduct(Product product)

public Product createProduct(Product product) {
return restClient.post()
.uri("/products")
.body(product)
.retrieve()
.body(Product.class);
}

RestClient is used to perform a POST /products request that contains JSON in the request body, which again is converted using Jackson. Many mock APIs (including FakeStore) return the created resource (often with an id) — hence this method returns the result Product returned by the API. The RestClient/Spring will usually set Content-Type: application/json automatically when serializing an object. If you must manually set headers, prefer configuring a request interceptor on the RestClient bean.

4. updateProduct(Long id, Product product)

public Product updateProduct(Long id, Product product) {
return restClient.put()
.uri("/products/{id}", id)
.body(product)
.retrieve()
.body(Product.class);
}

It sendsPUT /products/{id} with the updated product in the body and returns the updated product from the remote API. If the product doesn’t exist, the API may return 404 — convert to ProductNotFoundException if that fits your domain.

5. deleteProduct(Long id)

    public void deleteProduct(Long id) {
restClient.delete()
.uri(PRODUCTS_ID, id)
.retrieve()
// some APIs return deleted object or status; FakeStoreAPI returns data of deleted item
.body(Void.class);
}

Some endpoints (FakeStore included) return the deleted object — use .body(Product.class) to capture it.

Here is the complete ProductService class


@Service
public class ProductService {

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


private static final String PRODUCTS_ID = "/products/{id}";
private static final String PRODUCTS = "/products";
private final RestClient restClient;

public ProductService(RestClient restClient) {
this.restClient = restClient;
}

/** GET all products */
public List<Product> getAllProducts() {
return restClient.get()
.uri(PRODUCTS)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
}

/** GET single product by id */
public Product getProductById(Long id) {
return restClient.get()
.uri(PRODUCTS_ID, id)
.retrieve()
.body(Product.class);
}

/** CREATE a new product */
public Product createProduct(Product newProduct) {
return restClient.post()
.uri(PRODUCTS)
.body(newProduct)
.retrieve()
.body(Product.class);
}

/** UPDATE (PUT) an existing product */
public Product updateProduct(Long id, Product updatedProduct) {
return restClient.put()
.uri(PRODUCTS_ID, id)
.body(updatedProduct)
.retrieve()
.body(Product.class);
}

/** DELETE a product */
public void deleteProduct(Long id) {
restClient.delete()
.uri(PRODUCTS_ID, id)
.retrieve()
// some APIs return deleted object or status; FakeStoreAPI returns data of deleted item
.body(Void.class);
}

/** Example safe wrapper with error handling */
public Product safeGetProduct(Long id) {
try {
return getProductById(id);
} catch (RestClientResponseException ex) {
log.error("Failed to fetch product: status = {} , body = {}" ,ex.getStatusCode(), ex.getResponseBodyAsString());
return null;
}
}
}

Controller

controller/ProductController.java

@RestController
@RequestMapping("/api/products")
public class ProductController {

private final ProductService productService;

public ProductController(ProductService productService) {
this.productService = productService;
}

@GetMapping
public List<Product> listAll() {
return productService.getAllProducts();
}

@GetMapping("/{id}")
public Product getOne(@PathVariable Long id) {
return productService.getProductById(id);
}

@PostMapping
public Product create(@RequestBody Product product) {
return productService.createProduct(product);
}

@PutMapping("/{id}")
public Product update(@PathVariable Long id, @RequestBody Product product) {
return productService.updateProduct(id, product);
}

@DeleteMapping("/{id}")
public void delete(@PathVariable Long id) {
productService.deleteProduct(id);
}
}

Error handling patterns

By default, when you use Spring’s RestClient to call an external API, it will throw an exception if the server responds with an error code (4xx or 5xx).

For example, consider this simple call:

Product product = restClient.get()
.uri("/products/{id}", id)
.retrieve()
.body(Product.class);
  • If the response is 2xx (success), the response body is deserialized into a Product object.
  • If the response is 4xx or 5xx: RestClient throws a runtime exception, such as:
  • HttpClientErrorException (for 4xx client errors, e.g., 404 Not Found)
  • HttpServerErrorException (for 5xx server errors, e.g., 500 Internal Server Error)
  • Both extend from the common RestClientResponseException.

If you don’t handle these exceptions, they will bubble up through your service layer, and your controller may end up returning a generic 500 Internal Server Error to your clients, which is usually not very helpful.

The good news is that RestClient lets you intercept HTTP status codes before they’re turned into exceptions. This is done with the .onStatus(...) method:

public Product getProductById(Long id) {
return restClient.get()
.uri("/products/{id}", id)
.retrieve()
.onStatus(status -> status.value() == 404, (req, res) -> {
throw new ProductNotFoundException(id);
})
.onStatus(HttpStatusCode::is5xxServerError, (req, res) -> {
throw new ExternalApiException("FakeStore API unavailable");
})
.body(Product.class);
}

In this example:

  • If the API returns 404 Not Found, we throw a custom ProductNotFoundException.
  • If the API returns a 5xx server error, we throw an ExternalApiException with a clearer message.

This approach ensures your application responds with meaningful errors, instead of leaking raw RestClientResponseExceptiondata to your consumers. It also makes your service layer easier to test and maintain.

Test API Calls

Now we are all done with our code. We can run our application and test it.

Conclusion

Well done !!. In this post, we explored how to use Spring Boot 3’s RestClient to build robust API clients. We started with the basics of making GET, POST, PUT, and DELETE requests, and then walked through a practical example using the FakeStoreAPI for CRUD operations on products.

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉 Link to Medium blog

Related Posts