HTTP Interface Clients
Declare a remote API as a Java interface with @HttpExchange and let Spring generate the client, grouped and configured with @ImportHttpServices.
@HttpExchange(url = "/base")
interface RemoteApi {
@GetExchange("/{id}") Dto get(@PathVariable String id);
}
@ImportHttpServices(group = "remote", types = RemoteApi.class)
spring.http.serviceclient.remote.base-url=https://remote.example.comA hand-written RestClient class repeats the same pattern for every endpoint: method, URI, retrieve, convert. An HTTP interface removes that boilerplate. You describe the remote API as a Java interface with annotations from org.springframework.web.service.annotation, and Spring creates a proxy that makes the calls through a RestClient.
@HttpExchange(url = "/products", accept = "application/json")
interface CatalogApi {
@GetExchange("/{sku}")
Product product(@PathVariable String sku);
@PostExchange
ResponseEntity<Void> create(@RequestBody NewProduct product);
}@GetExchange, @PostExchange, @PutExchange, @PatchExchange and @DeleteExchange mirror the server-side mapping annotations, and the parameters use the same @PathVariable, @RequestParam, @RequestHeader and @RequestBody you know from controllers. The return type decides what you get: a DTO for the body, ResponseEntity<T> for status and headers, void when you only care that it succeeded.
Registering clients
Spring Framework 7 added a registry for these proxies. Put @ImportHttpServices on a configuration class, name a group and list the interfaces. Each group gets its own RestClient, and Spring Boot binds spring.http.serviceclient.<group>.* properties to it: base-url, timeouts, default headers. The interfaces then become beans you inject like any other.
Group by remote service, not by interface. Three interfaces that all talk to the billing system belong in one billing group so they share one base URL, one set of timeouts and one authentication setup.
When to use which
Interfaces shine when the remote API has many endpoints with plain request and response shapes. A direct RestClient is easier when a call needs unusual handling, such as streaming a large body or reading an error response in a special way. You can mix them: the interface for the common calls, and a RestClientHttpServiceGroupConfigurer bean to add interceptors or status handlers to a group's client.
Error behaviour is the same as RestClient: a 4xx or 5xx response throws HttpClientErrorException or HttpServerErrorException. Do not let those escape from the interface into your controllers. Wrap the interface in a small service that translates failures into your own exceptions, or configure a status handler for the group.
A common mistake is forgetting the base URL for a group; calls then go to a relative URL and fail at runtime, not at startup. Keep the property next to the group name and cover each client with at least one test.
Example
import java.math.BigDecimal;
import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
import org.springframework.web.service.registry.ImportHttpServices;
record Product(String sku, String name, BigDecimal price, int stock) {
}
record StockAdjustment(String sku, int delta, String reason) {
}
(url = "/products", accept = "application/json")
interface CatalogApi {
("/{sku}")
Product product( String sku);
List<Product> search(("q") String term, ("limit") int limit);
("/stock-adjustments")
ResponseEntity<Void> adjustStock( StockAdjustment adjustment);
}
(proxyBeanMethods = false)
(group = "catalog", types = CatalogApi.class)
class HttpServicesConfig {
// base URL and timeouts come from spring.http.serviceclient.catalog.*
}
public class StockService {
private final CatalogApi catalog;
StockService(CatalogApi catalog) {
this.catalog = catalog; // a generated proxy backed by the group's RestClient
}
public boolean reserve(String sku, int quantity) {
Product product = catalog.product(sku);
if (product.stock() < quantity) {
return false;
}
catalog.adjustStock(new StockAdjustment(sku, -quantity, "order reservation"));
return true;
}
}
When to use it
- Wrap a partner's twenty-endpoint REST API as one annotated interface instead of twenty hand-written RestClient methods.
- Point the catalogue client at a local stub in development and at the internal service in production by changing only spring.http.serviceclient.catalog.base-url.
- Give the billing group a longer read timeout and its own API key without touching the other HTTP clients.
More examples
Configuring a group with properties
Each key under <code>spring.http.serviceclient</code> is a group name from <code>@ImportHttpServices</code>. Settings you leave out fall back to the global <code>spring.http.clients.*</code> values, so you only override what differs, such as the slower billing service here. Taking the URL from an environment variable keeps the same jar deployable to every environment.
spring:
http:
serviceclient:
catalog:
base-url: https://catalog.internal.example.com
connect-timeout: 1s
read-timeout: 3s
default-header:
X-Client-Name: orders-service
billing:
base-url: ${BILLING_URL}
read-timeout: 10s
Adding authentication to one group
A <code>RestClientHttpServiceGroupConfigurer</code> receives every group's <code>RestClient.Builder</code> before the proxies are created. <code>filterByName</code> limits the change to one group, so the billing key is never sent to the catalogue service. The key itself comes from configuration, which in production should be an environment variable or a secret store, never a literal.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.support.RestClientHttpServiceGroupConfigurer;
@Configuration(proxyBeanMethods = false)
public class BillingClientConfig {
@Bean
RestClientHttpServiceGroupConfigurer billingAuth(@Value("${app.billing.api-key}") String apiKey) {
return groups -> groups.filterByName("billing")
.forEachClient((group, builder) -> builder
.defaultHeader("Authorization", "Bearer " + apiKey));
}
}
Creating a proxy by hand
This is the lower-level route the registry automates. It is useful when one client needs a <code>RestClient</code> you build yourself, for example with its own request factory. Because the builder is injected, Boot's converters, timeouts and observation still apply. For ordinary cases prefer <code>@ImportHttpServices</code>, which needs no factory code at all.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.annotation.GetExchange;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
record Coordinates(double latitude, double longitude) {
}
interface GeocoderApi {
@GetExchange("/geocode")
Coordinates lookup(@RequestParam("postcode") String postcode);
}
@Configuration(proxyBeanMethods = false)
public class GeocoderConfig {
@Bean
GeocoderApi geocoderApi(RestClient.Builder builder, @Value("${app.geocoder.base-url}") String baseUrl) {
RestClient rest = builder.baseUrl(baseUrl).build();
HttpServiceProxyFactory factory = HttpServiceProxyFactory
.builderFor(RestClientAdapter.create(rest))
.build();
return factory.createClient(GeocoderApi.class);
}
}
Discussion