Mastering Spring Boot 4 Declarative HTTP Client: A Complete Practical Guide
This article walks through Spring Boot 4's built‑in HTTP Interface, showing how to replace RestTemplate/WebClient with a type‑safe, annotation‑driven RestClient, covering Maven setup, DTO and interface definitions, configuration, error handling, header injection, timeout control, reactive alternatives, design principles, and a side‑by‑side comparison with the legacy approach.
Declarative HTTP Client in Spring Boot 4
Define a Java interface with annotations; Spring generates the implementation at runtime.
Core annotations: @HttpExchange, @GetExchange, @PostExchange, @PutExchange, @DeleteExchange. Spring Boot 4 binds them to the synchronous RestClient, eliminating manual URL construction, header setting, JSON parsing, and repetitive error‑handling code.
Project Dependencies
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>Configuration Property
todo.api.base-url=https://jsonplaceholder.typicode.comStep 1 – DTO
package com.icoderoad.todo.model;
public record Todo(Long userId, Long id, String title, boolean completed) {}Step 2 – Declarative HTTP Interface
package com.icoderoad.todo.client;
import java.util.List;
import com.icoderoad.todo.model.Todo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.*;
@HttpExchange("/todos")
public interface TodoClient {
@GetExchange
List<Todo> findAll();
@GetExchange("/{id}")
Todo findById(@PathVariable Long id);
@PostExchange
Todo create(@RequestBody Todo newTodo);
@PutExchange("/{id}")
Todo update(@PathVariable Long id, @RequestBody Todo updatedTodo);
@DeleteExchange("/{id}")
void delete(@PathVariable Long id);
}Step 3 – RestClient Configuration
package com.icoderoad.todo.config;
import com.icoderoad.todo.client.TodoClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
@Configuration
public class TodoClientConfig {
@Bean
public RestClient todoRestClient(RestClient.Builder builder,
@Value("${todo.api.base-url}") String baseUrl) {
return builder.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build();
}
@Bean
public TodoClient todoClient(RestClient restClient) {
var adapter = RestClientAdapter.create(restClient);
var factory = HttpServiceProxyFactory.builder(adapter).build();
return factory.createClient(TodoClient.class);
}
}Flow:
RestClient → RestClientAdapter → HttpServiceProxyFactory → generated TodoClient proxy.
Step 4 – Service Layer
package com.icoderoad.todo.service;
import com.icoderoad.todo.client.TodoClient;
import com.icoderoad.todo.model.Todo;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TodoService {
private final TodoClient client;
public TodoService(TodoClient client) { this.client = client; }
public List<Todo> getAll() { return client.findAll(); }
public Todo getOne(Long id) { return client.findById(id); }
public Todo create(Todo todo) { return client.create(todo); }
public Todo update(Long id, Todo todo) { return client.update(id, todo); }
public void delete(Long id) { client.delete(id); }
}Step 5 – Controller
package com.icoderoad.todo.controller;
import com.icoderoad.todo.model.Todo;
import com.icoderoad.todo.service.TodoService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/todos")
public class TodoController {
private final TodoService service;
public TodoController(TodoService service) { this.service = service; }
@GetMapping
public List<Todo> list() { return service.getAll(); }
@GetMapping("/{id}")
public Todo detail(@PathVariable Long id) { return service.getOne(id); }
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Todo create(@RequestBody Todo todo) { return service.create(todo); }
@PutMapping("/{id}")
public Todo update(@PathVariable Long id, @RequestBody Todo todo) { return service.update(id, todo); }
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void remove(@PathVariable Long id) { service.delete(id); }
}Remote Error Handling
@Bean
public TodoClient todoClient(RestClient restClient) {
var adapter = RestClientAdapter.create(restClient);
var factory = HttpServiceProxyFactory.builder(adapter)
.statusHandler(status -> status.is4xxClientError(),
(request, response) -> {
throw new IllegalStateException("Remote API 4xx error: " + response.getStatusCode());
})
.build();
return factory.createClient(TodoClient.class);
}This converts 4xx responses into business exceptions and hides low‑level HTTP details.
Custom Header and Additional Configuration
@Bean
public RestClient todoRestClient(RestClient.Builder builder,
@Value("${todo.api.base-url}") String baseUrl) {
return builder.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.defaultHeader("X-App-Source", "icoderoad-service")
.build();
}Further customization can include connection pooling, timeout settings, or switching the underlying client (Apache HttpClient, Reactor Netty).
Reactive Variant
@HttpExchange("/todos")
public interface ReactiveTodoClient {
@GetExchange
Flux<Todo> findAll();
@GetExchange("/{id}")
Mono<Todo> findById(@PathVariable Long id);
}When using WebFlux, replace RestClientAdapter with WebClientAdapter; methods return Mono or Flux.
Design Guidelines
One interface per remote service.
DTOs expose only required fields.
Controllers never depend directly on the client; they call a Service layer.
Layering: Controller → Service → HTTP Interface → Remote API improves decoupling, testability, and replaceability.
Comparison with Legacy Approaches
Boilerplate code: many vs very few.
Readability: average vs very clear.
Type safety: average vs strong.
Third‑party dependency: OpenFeign vs none.
Official support: RestTemplate deprecated vs HTTP Interface officially recommended.
Conclusion
For projects migrating from RestTemplate or Feign, the Spring Boot 4 + Spring Framework 6 HTTP Interface provides type safety, minimal boilerplate, and deep integration with the Spring ecosystem.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
LuTiao Programming
LuTiao Programming is a friendly community offering free programming lessons. We inspire learners to explore new ideas and technologies and quickly acquire job-ready skills.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
