Let's get started with a Microservice Architecture with Spring Cloud:
HTTP Interface in Spring
Last updated: January 29, 2026
1. Overview
The Spring Framework release 6, as well as Spring Boot version 3, enables us to define declarative HTTP services using Java interfaces. In particular, the approach is inspired by popular HTTP client libraries like Feign and is similar to how we define repositories in Spring Data.
In this tutorial, we’ll first look at how to define an HTTP interface. Then, we’ll check the available exchange method annotations, as well as the supported method parameters and return values. Next, we’ll see how to create an actual HTTP interface instance, a proxy client that performs the declared HTTP exchanges. Moreover, we’ll understand how to perform exception handling and testing of the declarative HTTP interface and its proxy client.
Finally, we examine HTTP service registries as a potential improvement to the usual process.
2. HTTP Interface
The declarative HTTP interface includes annotated methods for HTTP exchanges. We can simply express the remote API details using an annotated Java interface and let Spring generate a proxy that implements this interface and performs the exchanges. This helps reduce the boilerplate code.
2.1. Exchange Methods
@HttpExchange is the root annotation we can add to an HTTP interface and its exchange methods. In case we set it at the interface level, then it applies to all exchange methods. This can be useful for specifying attributes common to all interface methods, like content type or URL prefix.
Of course, additional annotations for all the HTTP methods are available:
- @GetExchange for HTTP GET requests
- @PostExchange for HTTP POST requests
- @PutExchange for HTTP PUT requests
- @PatchExchange for HTTP PATCH requests
- @DeleteExchange for HTTP DELETE requests
Let’s define a sample declarative HTTP interface using the method-specific annotations for a simple REST service:
interface BooksService {
@GetExchange("/books")
List<Book> getBooks();
@GetExchange("/books/{id}")
Book getBook(@PathVariable long id);
@PostExchange("/books")
Book saveBook(@RequestBody Book book);
@DeleteExchange("/books/{id}")
ResponseEntity<Void> deleteBook(@PathVariable long id);
}
Notably, all the HTTP method-specific annotations are meta-annotated with @HttpExchange. Therefore, @GetExchange(“/books”) is equivalent to @HttpExchange(url = “/books”, method = “GET”).
2.2. Method Parameters
In the example interface, we used @PathVariable and @RequestBody annotations for method parameters. In addition, we may use a set of method parameters for exchange methods:
- URI: dynamically sets the URL for the request, overriding the annotation attribute
- HttpMethod: dynamically sets the HTTP method for the request, overriding the annotation attribute
- @RequestHeader: adds the request header names and values, the argument may be a Map or MultiValueMap
- @PathVariable: replaces a value that has a placeholder in the request URL
- @RequestBody: provides the body of the request either as an object to be serialized, or a reactive streams publisher such as Mono or Flux
- @RequestParam: adds request parameter names and values, the argument may be a Map or MultiValueMap
- @CookieValue: adds cookie names and values, the argument may be a Map or MultiValueMap
Importantly, request parameters are encoded in the request body only for the content type application/x-www-form-urlencoded. In other cases, request parameters are added as URL query parameters.
2.3. Return Values
Lastly, the exchange methods in the example interface return blocking values. However, declarative HTTP interface exchange methods support both blocking and reactive return values.
In addition, we may choose to return only the specific response information, such as status codes or headers. As well as returning void in case we aren’t interested in the service response at all.
To summarize, HTTP interface exchange methods support a set of return values:
- void, Mono<Void>: performs the request and releases the response content
- HttpHeaders, Mono<HttpHeaders>: performs the request, releases the response content, and returns the response headers
- <T>, Mono<T>: performs the request and decodes the response content to the declared type
- <T>, Flux<T>: performs the request and decodes the response content to a stream of the declared type
- ResponseEntity<Void>, Mono<ResponseEntity<Void>>: performs the request, releases the response content, and returns a ResponseEntity containing status and headers
- ResponseEntity<T>, Mono<ResponseEntity<T>>: performs the request, releases the response content, and returns a ResponseEntity containing status, headers, and the decoded body
- Mono<ResponseEntity<Flux<T>>: performs the request, releases the response content, and returns a ResponseEntity containing status, headers, and the decoded response body stream
Of course, we can also use any other async or reactive types registered in the ReactiveAdapterRegistry.
3. Client Proxy
Now that we defined a sample HTTP service interface, let’s create a proxy that implements the interface and performs the exchanges.
3.1. Proxy Factory
Spring framework provides an HttpServiceProxyFactory that we can use to generate a client proxy for the HTTP interface:
HttpServiceProxyFactory httpServiceProxyFactory = HttpServiceProxyFactory
.builder(WebClientAdapter.forClient(webClient))
.build();
booksService = httpServiceProxyFactory.createClient(BooksService.class);
To create a proxy using the provided factory, besides the HTTP interface, we also require an instance of a reactive web client:
WebClient webClient = WebClient.builder()
.baseUrl(serviceUrl)
.build();
Now, we can register the client proxy instance as a Spring bean or component and use it to exchange data with the REST service.
3.2. Exception Handling
By default, WebClient throws WebClientResponseException for any client or server error HTTP status codes. In addition, we can customize exception handling by registering a default response status handler that applies to all responses performed through the client:
BooksClient booksClient = new BooksClient(WebClient.builder()
.defaultStatusHandler(HttpStatusCode::isError, resp ->
Mono.just(new MyServiceException("Custom exception")))
.baseUrl(serviceUrl)
.build());
As a result, in case we request a book that doesn’t exist, we receive a custom exception:
BooksService booksService = booksClient.getBooksService();
assertThrows(MyServiceException.class, () -> booksService.getBook(9));
In this case, we use MyServiceException for a custom implementation.
4. Testing
Let’s see how we can test the sample declarative HTTP interface and its client proxy that performs the exchanges.
4.1. Using Mockito
Since the aim is to test the client proxy created using a declarative HTTP interface, let’s mock the underlying WebClient fluent API using the Mockito deep stubbing feature:
@Mock(answer = Answers.RETURNS_DEEP_STUBS)
private WebClient webClient;
Now, let’s use the Mockito BDD methods to call the chained WebClient methods and provide a mocked response:
given(webClient.method(HttpMethod.GET)
.uri(anyString(), anyMap())
.retrieve()
.bodyToMono(new ParameterizedTypeReference<List<Book>>(){}))
.willReturn(Mono.just(List.of(
new Book(1,"Book_1", "Author_1", 1998),
new Book(2, "Book_2", "Author_2", 1999)
)));
Once we have the mocked response in place, we can call the service using the methods defined in the HTTP interface:
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
Thus, we verify the result is as expected.
4.2. Using MockServer
In case we want to avoid mocking the WebClient, we can use a library like MockServer to generate and return fixed HTTP responses:
new MockServerClient(SERVER_ADDRESS, serverPort)
.when(
request()
.withPath(PATH + "/1")
.withMethod(HttpMethod.GET.name()),
exactly(1)
)
.respond(
response()
.withStatusCode(HttpStatus.SC_OK)
.withContentType(MediaType.APPLICATION_JSON)
.withBody("{\"id\":1,\"title\":\"Book_1\",\"author\":\"Author_1\",\"year\":1998}")
);
Now that we have the mocked responses in place and a running mock server, let’s call the service:
BooksClient booksClient = new BooksClient(WebClient.builder()
.baseUrl(serviceUrl)
.build());
BooksService booksService = booksClient.getBooksService();
Book book = booksService.getBook(1);
assertEquals("Book_1", book.title());
In addition, we can verify that the code under test called the correct mocked service:
mockServer.verify(
HttpRequest.request()
.withMethod(HttpMethod.GET.name())
.withPath(PATH + "/1"),
VerificationTimes.exactly(1)
);
Again, the verification should pass.
5. HTTP Service Registry
As of Spring Framework 7.0, when working with multiple HTTP interfaces, we can use the HTTP Service Registry to reduce configuration overhead.
Let’s imagine we introduce two other HTTP interfaces:
- AuthorsService
- PaymentService
Specifically, we can implement them fairly simply:
interface AuthorsService {
@GetExchange("/authors")
List<Author> getAuthors();
@GetExchange("/authors/{id}")
Author getAuthor(@PathVariable("id") long id);
}
interface PaymentService {
// ...
}
Instead of manually creating a @Bean for each of the new service proxies, we can register HTTP services in groups and let Spring handle the proxy creation automatically.
To that end, let’s create a new configuration class and use the @ImportHttpServices annotation to reference the HTTP interfaces:
@Configuration
@ImportHttpServices(group = "books", types = { BooksService.class, AuthorsService.class })
@ImportHttpServices(group = "payments", types = PaymentService.class)
class HttpServicesConfig {
@Bean
WebClientHttpServiceGroupConfigurer groupConfigurer() {
// ...
}
}
We can now define the RestClientHttpServiceGroupConfigurer and WebClientHttpServiceGroupConfigurer beans to apply custom configuration across entire groups of HTTP interfaces.
For example, to add a default User-Agent header to all HTTP clients, we can configure it once and have it automatically applied to every client, regardless of name or group:
@Bean
WebClientHttpServiceGroupConfigurer groupConfigurer() {
return groups -> {
groups.forEachClient((client, builder) -> builder
.defaultHeader("User-Agent", "Baeldung-Client v1.0"));
};
}
On the other hand, if we need a specific configuration for certain groups, such as the “books” group containing BookService and AuthorService, we can filter by group name:
@Bean
WebClientHttpServiceGroupConfigurer groupConfigurer() {
return groups -> {
groups.forEachClient((group, builder) -> builder
.defaultHeader("User-Agent", "Baeldung-Client v1.0"));
groups.filterByName("books")
.forEachClient((group, builder) -> builder
.defaultUriVariables(Map.of("foo", "bar"))
.defaultApiVersion("v1"));
};
}
As we can see, the WebClientHttpServiceGroupConfigurer provides a fluent API for configuring HTTP clients at multiple levels: globally across all interfaces, for specific groups, or for individual HTTP interfaces.
6. Conclusion
In this article, we explored declarative HTTP service interfaces available in Spring release 6. We looked at how to define an HTTP interface using the available exchange method annotations, as well as the supported method parameters and return values.
Further, we explored how to create a proxy client that implements the HTTP interface and performs the exchanges. Also, we performed exception handling by defining a custom status handler. On top of that, we saw how to test the declarative interface and its client proxy using Mockito and MockServer. Finally, we leveraged an HTTP service registry and understood how it works.
The code backing this article is available on GitHub. Once you're logged in as a Baeldung Pro Member, start learning and coding on the project.
















