For years, calling remote REST APIs in Spring Boot meant one of two things: wrestling with the aging, blocking RestTemplate, or writing verbose, reactive boilerplate with WebClient.

While libraries like Spring Cloud Feign offered a cleaner, declarative approach, they required extra dependencies and configuration.

With the arrival of Spring Framework 6 and Spring Boot 3, declarative HTTP interfaces are now a native part of the core framework. It’s time to modernize how your microservices talk to each other.

The Goal

We are going to build a simple Spring Boot application that consumes a third-party API. We will use the classic JSONPlaceholder API to fetch “Todo” items.

The Remote Endpoint: https://jsonplaceholder.typicode.com/todos

Prerequisites

To follow along, you need:

  • Java 17 or higher.
  • Spring Boot 3.1 or higher (we will use 3.2 for the latest features).

Step 1: Project Setup and Dependencies

Create a new Spring Boot project. You only need one main dependency.

Even though the new interfaces look synchronous, they were originally powered by reactive infrastructure (WebClient) under the hood in Spring Boot 3.0/3.1.

Gradle (build.gradle.kts):

dependencies {
    implementation("org.springframework.boot:spring-boot-starter-webflux")
}

(Note: If you are using Spring Boot 3.2+, it is possible to use spring-boot-starter-web and the new RestClient backend to avoid reactive dependencies entirely, but webflux remains the most versatile option across Spring Boot 3.x).

Step 2: Define the Domain Model

We need a Java object that maps to the JSON response from the remote service. Java Records are perfect for this.

The JSON looks like this:

{
  "userId": 1,
  "id": 1,
  "title": "delectus aut autem",
  "completed": false
}

Todo.java:

package com.example.demo.model;

public record Todo(Integer id, Integer userId, String title, Boolean completed) {
}

Step 3: The Declarative Interface (The Magic)

This is where the new feature shines. Instead of writing a service class that injects WebClient and manually constructs a request, we just define an interface.

We use the new @HttpExchange annotations (part of org.springframework.web.service.annotation).

TodoClient.java:

package com.example.demo.client;

import com.example.demo.model.Todo;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;

import java.util.List;

// 1. Define base path for all methods in this interface
@HttpExchange("/todos")
public interface TodoClient {

    // 2. Maps to GET /todos
    @GetExchange
    List<Todo> getAllTodos();

    // 3. Maps to GET /todos/{id} using standard Spring @PathVariable
    @GetExchange("/{id}")
    Todo getTodoById(@PathVariable("id") Integer id);

    // 4. Example of a POST operation
    @PostExchange
    Todo createTodo(@RequestBody Todo todo);
}

Notice how clean this is. It looks exactly like a Spring MVC Controller definition, but it’s used for client-side calls.

Step 4: Configuration (Wiring it up)

This is the most crucial step. Spring Boot knows about the interface, but it doesn’t know where the actual server lies (the base URL: https://jsonplaceholder.typicode.com).

We need to create a configuration bean that tells Spring how to generate the concrete implementation of our TodoClient interface via the HttpServiceProxyFactory.

ClientConfig.java:

package com.example.demo.config;

import com.example.demo.client.TodoClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.support.WebClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;

@Configuration
public class ClientConfig {

    @Bean
    TodoClient todoClient(WebClient.Builder builder) {
        // 1. Create the underlying WebClient with the base URL
        WebClient webClient = builder
                .baseUrl("[https://jsonplaceholder.typicode.com](https://jsonplaceholder.typicode.com)")
                // You could add default headers here, e.g., for auth
                // .defaultHeader("Authorization", "Bearer my-token")
                .build();

        // 2. Create an adapter that bridges WebClient to the HTTP Service machinery
        WebClientAdapter adapter = WebClientAdapter.create(webClient);
        
        // 3. Create the factory
        HttpServiceProxyFactory factory = HttpServiceProxyFactory.builderFor(adapter).build();

        // 4. Ask the factory to create the actual implementation of your interface
        return factory.createClient(TodoClient.class);
    }
}

💡 Pro-Tip for Spring Boot 3.2+ Users

If you are on the absolute latest version (Spring Boot 3.2) and you are in a traditional Servlet application (not reactive), you can use the new RestClient instead of WebClient for the backend.

You would swap lines 2 and 3 above for this:

// Requires Spring Boot 3.2+ and spring-boot-starter-web
RestClient restClient = RestClient.builder().baseUrl("[https://jsonplaceholder.typicode.com](https://jsonplaceholder.typicode.com)").build();
RestClientAdapter adapter = RestClientAdapter.create(restClient);
// ... rest of factory setup is same

Step 5: Usage

Now that we have configured the @Bean, we can inject TodoClient anywhere in our application just like any other service.

DemoApplication.java:

package com.example.demo;

import com.example.demo.client.TodoClient;
import com.example.demo.model.Todo;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;

import java.util.List;

@SpringBootApplication
public class DemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(DemoApplication.class, args);
    }

    @Bean
    CommandLineRunner run(TodoClient todoClient) {
        return args -> {
            System.out.println("--- Starting HTTP Interface Demo ---");

            // 1. Fetch all
            System.out.println("Fetching all todos...");
            List<Todo> allTodos = todoClient.getAllTodos();
            System.out.println("Found " + allTodos.size() + " todos.");
            if (!allTodos.isEmpty()) {
                System.out.println("First todo title: " + allTodos.get(0).title());
            }

            // 2. Fetch one
            System.out.println("\nFetching Todo #5...");
            Todo todoFive = todoClient.getTodoById(5);
            System.out.println("Todo #5: " + todoFive);

            // 3. Create (Mock)
            System.out.println("\nCreating a new Todo...");
            Todo newTodo = new Todo(null, 101, "Learn Spring Boot 3 HTTP Interfaces", false);
            Todo createdTodo = todoClient.createTodo(newTodo);
            // Note: JSONPlaceholder will return HTTP 201 but always return id 201.
            System.out.println("Created Todo response: " + createdTodo);

            System.out.println("--- Demo Finished ---");
        };
    }
}

Why is this better?

  1. Readability: Your client code is now just an interface definition. It’s incredibly easy to read and understand what API calls your application is making.
  2. Maintainability: Changing an endpoint path or a parameter happens in one annotation, not buried deep in service logic strings.
  3. Standardization: It uses standard Spring MVC annotations (@PathVariable, @RequestBody, @RequestParam) that you already know from building server-side APIs.
  4. Testability: Because TodoClient is just an interface, mock testing your services that depend on this client becomes incredibly easy using Mockito.

Discover more from GhostProgrammer - Jeff Miller

Subscribe to get the latest posts sent to your email.

By Jeffery Miller

I am known for being able to quickly decipher difficult problems to assist development teams in producing a solution. I have been called upon to be the Team Lead for multiple large-scale projects. I have a keen interest in learning new technologies, always ready for a new challenge.