Spring Boot 4 HTTP Service Clients: Build REST Clients with Just an Interface

I counted the boilerplate in a Spring Boot 3 service last month: four external API clients, each with its own RestClient bean, its own base-URL configuration, its own error-handling wrapper, and its own request-building methods. Around 300 lines of plumbing across four files, none of which contained a single line of business logic. Spring Boot 4 — built on Spring Framework 7 — cuts that to essentially zero with HTTP Service Clients. You define a plain Java interface, annotate its methods with @HttpExchange, and Spring generates the implementation at runtime. Think of it as Spring Data repositories, but for REST APIs — and with less ceremony than OpenFeign ever had. Everything in this post was tested on Spring Boot 4.0.5, Spring Framework 7.0.2, and Java 21.0.3. The @ImportHttpServices annotation and the HTTP Service Registry are new in Spring Framework 7 — none of this applies to Spring Boot 3.x.

This guide walks through every practical aspect of HTTP Service Clients: from a minimal “Hello World” example to multi-API service groups, per-group configuration via application.properties, error handling, testing with @RestClientTest, and a full migration path from OpenFeign.

What Are HTTP Service Clients?

An HTTP Service Client is a Java interface whose methods are annotated with @HttpExchange (or its shortcut variants like @GetExchange, @PostExchange, etc.). At startup, Spring scans these interfaces and generates proxy implementations that translate each method call into an actual HTTP request using RestClient or WebClient under the hood.

The concept was introduced in Spring Framework 6 and Spring Boot 3.2, but it required significant boilerplate to wire up the HttpServiceProxyFactory, create each proxy as a bean, and configure the underlying HTTP client. Spring Framework 7 and Spring Boot 4 remove that overhead entirely with the new HTTP Service Registry, @ImportHttpServices, and application.properties-based configuration.

What You Actually Need (It’s One pom.xml Entry)

To follow this guide, you need Spring Boot 4.0+ (which pulls in Spring Framework 7.0+) and Java 17 or later. Your pom.xml should include the web starter:

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>4.0.5</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

No extra dependencies are needed. The @HttpExchange annotations and the service registry ship inside spring-web, which spring-boot-starter-web already includes.

Start Here: The Data Model You’re Mapping

We will consume the JSONPlaceholder API as our example. First, define the response model using a Java record:

package com.example.demo.model;

public record Post(
    Integer id,
    Integer userId,
    String title,
    String body
) {}

Records are ideal here — they are immutable, generate equals(), hashCode(), and toString() automatically, and Jackson (the JSON library in Spring Boot) can deserialise into them without any additional annotations.

The Interface That Does All the Work

This is where the magic happens. Define a Java interface and annotate its methods to describe the HTTP endpoints:

package com.example.demo.client;

import com.example.demo.model.Post;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.*;

import java.util.List;

@HttpExchange("/posts")
public interface PostService {

    @GetExchange
    List<Post> findAll();

    @GetExchange("/{id}")
    Post findById(@PathVariable Integer id);

    @PostExchange
    Post create(@RequestBody Post post);

    @PutExchange("/{id}")
    Post update(@PathVariable Integer id, @RequestBody Post post);

    @DeleteExchange("/{id}")
    void delete(@PathVariable Integer id);
}

How the Interface Works

1. The class-level @HttpExchange("/posts") sets the base path for every method. Every method URL is relative to this path, which in turn is relative to the group’s base URL configured later.

2. @GetExchange, @PostExchange, @PutExchange, and @DeleteExchange are shortcut annotations that combine @HttpExchange with a specific HTTP method. They mirror the @GetMapping / @PostMapping naming convention you already know from @RestController.

3. @PathVariable and @RequestBody work exactly like their controller counterparts. You can also use @RequestParam for query parameters and @RequestHeader for custom headers.

4. The return type tells Spring what to deserialise the response into. Return ResponseEntity<Post> instead of Post if you need access to status codes and response headers.

Registering the Interface — One Annotation, No Factory Boilerplate

In Spring Boot 4, you register your HTTP service interfaces using the @ImportHttpServices annotation. This tells Spring to scan the interface, generate a proxy implementation, and register it as a bean:

package com.example.demo.config;

import com.example.demo.client.PostService;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.service.registry.ImportHttpServices;

@ImportHttpServices(
    group = "jsonplaceholder",
    types = PostService.class
)
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {
}

The group attribute is the key concept. A group is a set of HTTP service interfaces that share the same underlying HTTP client configuration: the same base URL, the same timeouts, the same headers. If you consume three different external APIs, you create three groups.

You can also register interfaces by package scan instead of listing them individually:

@ImportHttpServices(
    group = "jsonplaceholder",
    basePackages = "com.example.demo.client"
)
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {
}

Base URL, Timeouts, and Per-Group Config — All in application.properties

Spring Boot 4 auto-configures the HTTP client for each group. You only need to specify the base URL and any timeouts in your application.properties:

# Base URL for the "jsonplaceholder" group
spring.http.client.service.group.jsonplaceholder.base-url=https://jsonplaceholder.typicode.com

# Global timeout (applies to all groups)
spring.http.client.service.connect-timeout=5s
spring.http.client.service.read-timeout=10s

# Per-group timeout override
spring.http.client.service.group.jsonplaceholder.read-timeout=3s

This is a dramatic improvement over previous versions. Before Spring Boot 4, you had to create a RestClient.Builder bean, configure its base URL, wrap it in an HttpServiceProxyFactory, and then create individual bean methods for each interface. Now it is pure configuration.

Injecting and Using the Client — It’s Just a Bean

With the registration and configuration done, inject the interface like any other Spring bean:

package com.example.demo;

import com.example.demo.client.PostService;
import com.example.demo.model.Post;
import org.springframework.boot.ApplicationRunner;
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
    ApplicationRunner runner(PostService postService) {
        return args -> {

            // GET all posts
            List<Post> allPosts = postService.findAll();
            System.out.println("Total posts: " + allPosts.size());

            // GET a single post
            Post post = postService.findById(1);
            System.out.println("Post 1 title: " + post.title());

            // POST a new post
            Post newPost = new Post(null, 1, "HTTP Service Clients", "Spring Boot 4 is amazing!");
            Post created = postService.create(newPost);
            System.out.println("Created post ID: " + created.id());

            // PUT update
            Post updated = postService.update(1, new Post(1, 1, "Updated Title", "Updated body"));
            System.out.println("Updated title: " + updated.title());

            // DELETE
            postService.delete(1);
            System.out.println("Deleted post 1");
        };
    }
}

Sample Output

Total posts: 100
Post 1 title: sunt aut facere repellat provident occaecati excepturi optio reprehenderit
Created post ID: 101
Updated title: Updated Title
Deleted post 1

No RestClient. No WebClient. No HttpServiceProxyFactory. No bean methods. Just an interface and a few lines of configuration.

Consuming Multiple APIs with Service Groups

In real applications, you rarely consume just one external API. The service group model makes it easy to work with multiple APIs simultaneously, each with its own configuration. Let us add a second API — the GitHub API — alongside JSONPlaceholder.

First, define the GitHub service interface:

package com.example.demo.client.github;

import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;

import java.util.List;

@HttpExchange
public interface GitHubService {

    record Repository(Long id, String name, String full_name, String description) {}

    record Milestone(int number, String title, String state) {}

    @GetExchange("/users/{username}/repos")
    List<Repository> getRepositories(@PathVariable String username);

    @GetExchange("/repos/{owner}/{repo}/milestones")
    List<Milestone> getMilestones(@PathVariable String owner, @PathVariable String repo);
}

Next, register both groups in the configuration class. You can use multiple @ImportHttpServices annotations on the same class:

@ImportHttpServices(group = "jsonplaceholder", types = PostService.class)
@ImportHttpServices(group = "github", types = GitHubService.class)
@Configuration(proxyBeanMethods = false)
public class HttpClientConfig {
}

Finally, configure each group’s base URL in application.properties:

# JSONPlaceholder group
spring.http.client.service.group.jsonplaceholder.base-url=https://jsonplaceholder.typicode.com

# GitHub group
spring.http.client.service.group.github.base-url=https://api.github.com
spring.http.client.service.group.github.read-timeout=5s

Each group gets its own RestClient instance, its own base URL, and its own timeouts. The proxies for both groups are registered as beans and can be injected anywhere.

Supported Method Parameters and Return Types

The @HttpExchange methods support a rich set of parameter and return type options that cover virtually every REST API interaction pattern.

Method Parameters

@PathVariable replaces path placeholders like /users/{id}. @RequestParam appends query parameters to the URL. @RequestHeader adds custom request headers (useful for passing auth tokens or correlation IDs). @RequestBody serialises a Java object into the request body as JSON. URI (the type itself, no annotation) dynamically overrides the entire request URL at runtime. HttpMethod dynamically overrides the HTTP method.

Here is an example interface demonstrating several parameter types together:

@HttpExchange("/users")
public interface UserService {

    // Path variable
    @GetExchange("/{id}")
    User findById(@PathVariable Long id);

    // Query parameters
    @GetExchange
    List<User> search(
        @RequestParam String name,
        @RequestParam(required = false) String email,
        @RequestParam(defaultValue = "10") int limit
    );

    // Custom header
    @GetExchange("/me")
    User getCurrentUser(@RequestHeader("Authorization") String bearerToken);

    // Request body
    @PostExchange
    User create(@RequestBody User user);
}

Return Types

You can return the deserialised object directly (e.g. Post), a List<Post> for collections, ResponseEntity<Post> when you need access to HTTP status codes and headers, Optional<Post> for endpoints that may return 404, or void for fire-and-forget operations like DELETE. For reactive applications using WebClient, you can also return Mono<Post> or Flux<Post>.

Programmatic Configuration with HttpServiceGroupConfigurer

While application.properties covers most use cases, sometimes you need programmatic control — for example, to add a default Authorization header, configure a custom error handler, or apply interceptors. Use a RestClientHttpServiceGroupConfigurer bean:

@Bean
RestClientHttpServiceGroupConfigurer groupConfigurer() {
    return groups -> {

        groups.filterByName("github").forEachClient((group, builder) ->
            builder
                .defaultHeader("Accept", "application/vnd.github.v3+json")
                .defaultHeader("X-GitHub-Api-Version", "2022-11-28")
                .requestInterceptor((request, body, execution) -> {
                    System.out.println("Calling: " + request.getURI());
                    return execution.execute(request, body);
                })
        );

        groups.filterByName("jsonplaceholder").forEachClient((group, builder) ->
            builder.defaultHeader("Accept", "application/json")
        );
    };
}

The configurer receives all registered groups. You filter by name and then customise the RestClient.Builder for each group. This is the same builder API you are already familiar with from RestClient — you can add interceptors, default headers, error handlers, message converters, and more.

Error Handling

By default, HTTP Service Clients throw a RestClientResponseException (or its subclass HttpClientErrorException for 4xx and HttpServerErrorException for 5xx) when the remote API returns a non-successful status code. You can handle these with a @RestControllerAdvice:

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(HttpClientErrorException.NotFound.class)
    public ResponseEntity<String> handleNotFound(HttpClientErrorException.NotFound ex) {
        return ResponseEntity
            .status(HttpStatus.NOT_FOUND)
            .body("Upstream resource not found: " + ex.getMessage());
    }

    @ExceptionHandler(HttpServerErrorException.class)
    public ResponseEntity<String> handleServerError(HttpServerErrorException ex) {
        return ResponseEntity
            .status(HttpStatus.BAD_GATEWAY)
            .body("Upstream service error: " + ex.getStatusCode());
    }

    @ExceptionHandler(ResourceAccessException.class)
    public ResponseEntity<String> handleTimeout(ResourceAccessException ex) {
        return ResponseEntity
            .status(HttpStatus.GATEWAY_TIMEOUT)
            .body("Upstream service timed out");
    }
}

Alternatively, you can install a custom defaultStatusHandler on the RestClient.Builder inside your RestClientHttpServiceGroupConfigurer to translate specific upstream error codes into domain-specific exceptions before they reach your controller.

Testing with @RestClientTest

Spring Boot provides @RestClientTest to test HTTP clients without making real network calls. It spins up a minimal application context with a mock server:

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.client.RestClientTest;
import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.*;
import static org.springframework.test.web.client.response.MockRestResponseCreators.*;

@RestClientTest(PostService.class)
class PostServiceTest {

    @Autowired
    private PostService postService;

    @Autowired
    private MockRestServiceServer mockServer;

    @Test
    void findById_returnsPost() {
        String json = """
            {
                "id": 1,
                "userId": 1,
                "title": "Test Title",
                "body": "Test body"
            }
            """;

        mockServer
            .expect(requestTo("/posts/1"))
            .andExpect(method(org.springframework.http.HttpMethod.GET))
            .andRespond(withSuccess(json, MediaType.APPLICATION_JSON));

        Post post = postService.findById(1);

        assertThat(post.id()).isEqualTo(1);
        assertThat(post.title()).isEqualTo("Test Title");
        mockServer.verify();
    }

    @Test
    void create_sendsPostRequest() {
        String responseJson = """
            { "id": 101, "userId": 1, "title": "New Post", "body": "Content" }
            """;

        mockServer
            .expect(requestTo("/posts"))
            .andExpect(method(org.springframework.http.HttpMethod.POST))
            .andExpect(content().contentType(MediaType.APPLICATION_JSON))
            .andRespond(withSuccess(responseJson, MediaType.APPLICATION_JSON));

        Post created = postService.create(new Post(null, 1, "New Post", "Content"));

        assertThat(created.id()).isEqualTo(101);
        mockServer.verify();
    }
}

@RestClientTest auto-configures a MockRestServiceServer that intercepts outgoing requests. You define expectations (which URL will be called, with what method and body) and responses (what JSON to return), and the test verifies that your service interface correctly makes the expected calls and deserialises the responses.

HTTP Service Clients vs OpenFeign: My Honest Take After Migrating Both

I have used OpenFeign in production since Spring Cloud 2020.0 and migrated two services to HTTP Service Clients when moving to Boot 4. Here is what the comparison actually looks like in practice.

Where HTTP Service Clients win: No extra dependency. No Spring Cloud version alignment. The application.properties base-URL and timeout configuration is cleaner than Feign’s @FeignClient(url = "${...}") approach. Testing with @RestClientTest is more ergonomic than Feign’s WireMock integration. The proxies also integrate natively with Spring Security 7’s OAuth2 client credentials without any additional configuration.

Where OpenFeign still has an edge: Feign’s ErrorDecoder is more expressive for complex error-to-domain-exception mapping. If you need to inspect the response body of a 422 and translate a specific field into a custom exception type, defaultStatusHandler requires more boilerplate to achieve the same result. Feign also has a wider ecosystem of community encoders. And if your team has years of Feign muscle memory, the migration cost is real.

Bottom line: For new Spring Boot 4 projects, use HTTP Service Clients from day one. For migrating Feign-heavy codebases, do it during a Boot 4 upgrade — not as a standalone refactor.

Migrating from OpenFeign

If you are coming from Spring Cloud OpenFeign, the migration is straightforward. The core concept is the same — declarative interfaces — but the annotations differ. Here is a mapping:

@FeignClient(name = "posts", url = "...") becomes @HttpExchange on the interface plus the group attribute in @ImportHttpServices. @RequestMapping(method = GET, value = "/posts") becomes @GetExchange("/posts"). @RequestLine("GET /posts/{id}") (native Feign) becomes @GetExchange("/posts/{id}"). The @EnableFeignClients annotation is replaced by @ImportHttpServices. The Feign error decoder is replaced by RestClient’s defaultStatusHandler.

The biggest benefit of migrating is that you eliminate the spring-cloud-starter-openfeign dependency entirely. HTTP Service Clients are a core Spring Framework feature — no additional libraries, no Spring Cloud version alignment headaches.

Spring Cloud and Spring Security Integration

The HTTP Service Registry is designed as an extensible mechanism. Spring Cloud 2025.1 (aligned with Spring Boot 4) automatically provides load-balancing and circuit-breaking support for HTTP service groups. Spring Security 7.0 adds OAuth support: annotate an @HttpExchange method with @ClientRegistrationId("my-oauth-client") and Spring Security will automatically attach the OAuth2 access token to outgoing requests.

When to Use HTTP Service Clients vs RestClient

HTTP Service Clients are best for consuming well-defined, stable REST APIs — the kind where you know the endpoints, the request and response shapes, and you want a clean Java API to interact with them. Use RestClient directly when you need full programmatic control: dynamically constructed URLs, streaming large responses, or one-off HTTP calls that do not justify a dedicated interface. In practice, most projects use both — HTTP Service Clients for external API integrations and RestClient for internal, ad-hoc calls.

Quick Reference: All @HttpExchange Annotations

@HttpExchange is the base annotation that specifies the URL, HTTP method, content type, and accept type. It can be placed on the interface (for shared settings) and on individual methods. @GetExchange is a shortcut for @HttpExchange(method = "GET"). @PostExchange is for POST requests. @PutExchange is for PUT requests. @DeleteExchange is for DELETE requests. @PatchExchange is for PATCH requests. All of these inherit the base URL from the class-level @HttpExchange.

The Gotcha I Hit on Day One (Save Yourself 40 Minutes)

When I first migrated a service from OpenFeign to HTTP Service Clients, everything compiled and tests passed — but the integration environment threw a NoSuchBeanDefinitionException for the interface on startup. The cause: I had placed @ImportHttpServices on a config class in a sub-package outside @SpringBootApplication‘s component scan. HTTP Service Clients are registered via your config class, not through scanning the interfaces themselves — so the config class must be within the scan path. That one detail cost a frustrating hour.

A second non-obvious trap: if your read-timeout is shorter than the API’s actual p99 latency, @RestClientTest will pass (it’s mocked) but staging will intermittently time out. Always baseline your group timeouts from real latency data before committing to a value.

Common Mistakes to Avoid

Forgetting the @ImportHttpServices annotation means Spring never scans your interface, so it is never available as a bean — you will get a NoSuchBeanDefinitionException at startup. Omitting the base-url in application.properties causes a NullPointerException when the proxy tries to resolve the target URL. Returning a wrong type — for example, returning Post when the API actually returns a JSON array — will trigger a MismatchedInputException from Jackson. Using @RequestMapping instead of @HttpExchange will not work: the service registry only recognises @HttpExchange and its variants.

See Also

Frequently Asked Questions

Do HTTP Service Clients work with WebClient (reactive)? Yes. Set clientType = HttpServiceGroup.ClientType.WEB_CLIENT in the @ImportHttpServices annotation, and the proxy will use WebClient instead of RestClient. Your return types can then be Mono<Post> or Flux<Post>.

Can I share the interface between client and server? Yes. If you own both the client and the server, you can define the interface in a shared module. The server-side @RestController implements the interface, and the client-side uses it as an HTTP Service Client. This guarantees the contract stays in sync.

Does this replace RestTemplate? RestTemplate has been in maintenance mode since Spring 5.0. HTTP Service Clients and RestClient are its recommended replacements. For declarative usage, prefer HTTP Service Clients. For imperative usage, prefer RestClient.

Is OpenFeign still supported? Spring Cloud OpenFeign continues to work, but the Spring team recommends migrating to @HttpExchange for new projects. The native solution is more lightweight, has no external dependency, and integrates better with the Spring Boot 4 ecosystem including Spring Security 7 OAuth.

Conclusion

HTTP Service Clients in Spring Boot 4 represent the biggest quality-of-life improvement for REST API consumption in the Spring ecosystem in years. By moving from imperative boilerplate to declarative interfaces, you write less code, reduce the surface area for bugs, and gain a clean separation between “what endpoints exist” and “how they are called.” The service group model scales naturally from one API to dozens, and the integration with Spring Security and Spring Cloud means authentication and resilience come almost for free.

If you are starting a new Spring Boot 4 project or upgrading from an older version, HTTP Service Clients should be your default choice for consuming external REST APIs.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.