Two layers, three options: what RestClient, WebClient, HTTP interfaces and OpenFeign actually are

Spring's HTTP client story has two layers. Most confusion comes from comparing items across those layers as if they were alternatives.

The transport layer

RestClient is a synchronous client with a fluent builder API. It keeps the blocking, thread-per-request model of RestTemplate but replaces its overloaded template methods with chained calls. A call blocks the calling thread until the response arrives.

WebClient is non-blocking. It returns Mono and Flux and lets a small number of event-loop threads serve many concurrent requests. You can call block() on it, but you still carry the Reactor dependency and its debugging model.

The declarative layer

HTTP interfaces are Java interfaces annotated with @HttpExchange, @GetExchange and similar annotations. HttpServiceProxyFactory generates a proxy for each interface and sends every call through an adapter: RestClientAdapter, WebClientAdapter or RestTemplateAdapter. The interface contains no transport code. The adapter decides whether calls block or return reactive types.

OpenFeign comes from Spring Cloud, not Spring Framework. It has its own annotation contract, which in Spring Cloud is mapped onto Spring MVC annotations. It also has its own client abstraction and its own configuration model.

 @HttpExchange interface              @FeignClient interface
          |                                    |
 HttpServiceProxyFactory              Feign builder / contract
          |                                    |
 RestClientAdapter | WebClientAdapter  Feign Client implementation
 | RestTemplateAdapter                         |
          |                            underlying HTTP library
 RestClient | WebClient | RestTemplate
          |
 JDK HttpClient, Apache HttpClient, Jetty, Reactor Netty

This structure gives the order of decisions for this article. Choose the transport first, based on your concurrency model. Then decide whether a declarative facade is worth adding on top of it.

 DECLARATIVE LAYER (Spring Framework)          (Spring Cloud OpenFeign)
 +------------------------------------------+   +-----------------------+
 |         @HttpExchange interface          |   | @FeignClient interface|
 +------------------------------------------+   +-----------------------+
                      |                                     |
                      v                                     v
 +------------------------------------------+   +-----------------------+
 |         HttpServiceProxyFactory          |   | Feign builder/contract|
 +------------------------------------------+   +-----------------------+
        |              |              |                     |
        v              v              v                     v
 +------------+ +------------+ +------------+   +-----------------------+
 | RestClient | | WebClient  | |RestTemplate|   |  Feign Client impl.   |
 |  Adapter   | |  Adapter   | |  Adapter   |   |  (own abstraction)    |
 +------------+ +------------+ +------------+   +-----------------------+
        |              |              |                     |
 - - - -|- - - - - - - | - - - - - - -|- - - - - - - - - - -|- - - - - -
 TRANSPORT LAYER       |              |                     |
        v              v              v                     |
 +------------+ +------------+ +------------+               |
 | RestClient | | WebClient  | |RestTemplate|               |
 |  blocking  | | Mono/Flux  | |  blocking  |               |
 +------------+ +------------+ +------------+               |
        |              |              |                     |
        v              v              v                     v
 +------------------------------------------+   +-----------------------+
 | JDK HttpClient, Apache HttpClient, others|   | underlying HTTP lib.  |
 +------------------------------------------+   +-----------------------+

Spring's HTTP interfaces route calls through HttpServiceProxyFactory and an adapter to a transport client (RestClient, WebClient or RestTemplate), while OpenFeign is a separate Spring Cloud stack with its own contract and client abstraction.

Decision one: blocking RestClient or reactive WebClient

RestClient is synchronous. The calling thread waits for the response, so each in-flight request holds one thread. That fits servlet-stack applications, where request handling is already thread-per-request and stack traces, ThreadLocal-based MDC and the security context work as expected. With virtual threads, a blocked call parks a cheap virtual thread instead of tying up a platform thread, so high outbound concurrency costs much less than it does on a fixed pool.

WebClient is non-blocking. It returns Mono or Flux and frees the thread while I/O is pending. Teams use it for streaming responses, backpressure, and fanning out to several services and combining the results with Mono.zip. On a WebFlux event loop, calling .block() is a defect: it stalls one of a small number of threads that every other request depends on.

Adding WebClient to a servlet app only for outbound calls brings in Reactor and its programming model for little gain. Reactive code has real costs. Stack traces show operator internals rather than your call path. MDC and security context do not follow thread hops unless you configure context propagation. The operator set takes time to learn.

The two GET calls look almost the same: restClient.get().uri("/orders/{id}", id).retrieve().body(Order.class) against webClient.get().uri("/orders/{id}", id).retrieve().bodyToMono(Order.class).

Concern RestClient WebClient
Execution Blocking Non-blocking
Best stack Servlet, virtual threads WebFlux
Streaming/backpressure No Yes
Debuggability Plain stack traces Fragmented traces
Context propagation ThreadLocal works Needs explicit setup
import org.springframework.web.client.RestClient;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;

record Order(String id, String customerId) {}
record Customer(String id, String name) {}
record OrderView(Order order, Customer customer) {}

class OrderClients {

    // Blocking: the calling thread (ideally a virtual thread) waits for the response.
    private final RestClient restClient =
            RestClient.builder().baseUrl("").build();

    Order fetchOrderBlocking(String id) {
        return restClient.get().uri("/orders/{id}", id).retrieve().body(Order.class);
    }

    // Non-blocking: returns a Mono; no thread is held while I/O is pending.
    private final WebClient webClient =
            WebClient.builder().baseUrl("").build();

    Mono<Order> fetchOrder(String id) {
        return webClient.get().uri("/orders/{id}", id).retrieve().bodyToMono(Order.class);
    }

    Mono<Customer> fetchCustomer(String id) {
        return webClient.get().uri("/customers/{id}", id).retrieve().bodyToMono(Customer.class);
    }

    // Fan-out: start both calls together and combine them when both complete.
    // Return the Mono to the framework; calling .block() here on an event loop
    // would stall a thread that other requests depend on.
    Mono<OrderView> fetchOrderView(String orderId, String customerId) {
        return Mono.zip(fetchOrder(orderId), fetchCustomer(customerId))
                .map(t -> new OrderView(t.getT1(), t.getT2()));
    }
}

The same GET request written with blocking RestClient and with non-blocking WebClient, plus a WebClient fan-out that uses Mono.zip to run two calls concurrently and returns the result as a Mono instead of calling .block().

Decision two: defining and wiring an HTTP interface

An HTTP interface is a plain Java interface. A type-level @HttpExchange sets the shared path prefix and default headers, and methods carry @GetExchange or @PostExchange with @PathVariable, @RequestParam, @RequestHeader and @RequestBody arguments. Spring generates the implementation at runtime. You give HttpServiceProxyFactory an adapter, such as RestClientAdapter.create(restClient), and call createClient. Build the RestClient from the injected auto-configured RestClient.Builder instead of RestClient.create(). The auto-configured builder already has Boot's message converters and observation instrumentation, and a static factory skips both. Expose the resulting proxy as a @Bean so callers depend only on the interface.

Return types follow the adapter

The interface shape does not change, but the return types must match the adapter:

  • Blocking adapter (RestClient): methods return a DTO, void, or ResponseEntity<T> when status and headers matter.
  • Reactive adapter (WebClientAdapter): methods return Mono<T>, Flux<T> or Mono<ResponseEntity<T>>.

The facade hides the transport

Callers see only a method signature, and that makes two mistakes easy. One team calls .block() on a reactive proxy inside a servlet request. That ties up a thread and gives up the benefit of WebClient. Another team assumes a DTO-returning method is non-blocking because the codebase is "reactive". Name the bean or package after the transport so the choice stays visible.

Limits of the interface

  • Dynamic hosts: these need a URI argument on each method.
  • Multipart: basic @RequestPart works, but streaming large files or per-part content types often do not fit the annotation model.
  • Per-call behavior: a method-specific timeout or retry has no annotation.

Expect a mix in practice. Most endpoints go through the interface, and a few edge cases call the underlying client directly.

// Imports omitted. Annotations: org.springframework.web.service.annotation.*
// Type-level prefix and default Accept header shared by every method.
@HttpExchange(url = "/orders", accept = "application/json")
public interface OrderClient {

    @GetExchange("/{id}")
    Order getOrder(@PathVariable String id);              // blocking: plain DTO

    @GetExchange
    List<Order> findByStatus(@RequestParam("status") String status);

    @PostExchange
    ResponseEntity<Order> create(@RequestBody NewOrder body,
                                 @RequestHeader("Idempotency-Key") String key);

    // With WebClientAdapter instead, the same shape would return
    // Mono<Order>, Flux<Order> or Mono<ResponseEntity<Order>>.
}

@Configuration
class OrderClientConfig {

    // Inject Boot's auto-configured builder so the client keeps Boot's
    // message converters and observation instrumentation.
    // RestClient.create() would skip both.
    @Bean
    OrderClient blockingOrderClient(RestClient.Builder builder) {
        RestClient restClient = builder
                .baseUrl("")
                .build();

        HttpServiceProxyFactory factory = HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build();

        // Callers depend only on the interface. The bean name records the transport.
        return factory.createClient(OrderClient.class);
    }
}

Declares an HTTP interface with @HttpExchange, @GetExchange and @PostExchange, then exposes a proxy as a bean using HttpServiceProxyFactory over a RestClientAdapter built from the auto-configured RestClient.Builder.

Where OpenFeign fits as the alternative

OpenFeign predates Spring's HTTP interfaces and solves the same problem: you declare a Java interface, and a generated proxy turns method calls into HTTP requests. In a Spring application you get it through Spring Cloud OpenFeign. Annotating a configuration class with @EnableFeignClients triggers classpath scanning, and each interface marked @FeignClient becomes a bean. The interface methods use Spring MVC annotations (@GetMapping, @PathVariable, @RequestParam, @RequestBody), so teams familiar with writing controllers can read the client contract without learning anything new.

Underneath, Feign has its own extension model. A Contract interprets the annotations. An Encoder and Decoder handle bodies, and Spring's integration backs them with the application's message converters. RequestInterceptor implementations add headers such as auth tokens, and an ErrorDecoder maps non-2xx responses to exceptions. You can override any of these per client by giving @FeignClient a configuration class or by setting per-client properties.

Its strongest argument is the Spring Cloud integration. When you give a client a logical service name instead of a URL, Feign resolves the name through service discovery and spreads calls across instances with Spring Cloud LoadBalancer.

The costs are real. Feign is a third-party dependency, and it brings a second configuration surface that sits alongside Spring's RestClient setup without sharing it. Its long-term role next to built-in HTTP interfaces is also uncertain. Check the current Spring Cloud OpenFeign documentation for its maintenance status before you commit new code to it. In the example below, the HTTP interface version differs from the Feign version mainly in its annotations and in how the client is wired.

// Feign: needs spring-cloud-starter-openfeign and @EnableFeignClients on a config class.
// "inventory-service" is a logical name resolved via discovery + Spring Cloud LoadBalancer.
@FeignClient(name = "inventory-service", configuration = InventoryFeignConfig.class)
interface InventoryFeignClient {
    @GetMapping("/items/{sku}")
    Item getItem(@PathVariable("sku") String sku);
}

// Per-client Feign extension point: add an auth header to every request.
class InventoryFeignConfig {
    @Bean
    RequestInterceptor authHeader() {
        return template -> template.header("Authorization", "Bearer placeholder-token");
    }
}

// Built-in HTTP interface: no extra dependency, backed by a RestClient you configure.
interface InventoryHttpClient {
    @GetExchange("/items/{sku}")
    Item getItem(@PathVariable String sku);
}

@Configuration
class InventoryHttpClientConfig {
    @Bean
    InventoryHttpClient inventoryHttpClient(RestClient.Builder builder) {
        RestClient restClient = builder
                .baseUrl("")
                .defaultHeader("Authorization", "Bearer placeholder-token")
                .build();
        // Wire the interface to the RestClient explicitly.
        return HttpServiceProxyFactory
                .builderFor(RestClientAdapter.create(restClient))
                .build()
                .createClient(InventoryHttpClient.class);
    }
}

The same inventory lookup declared as a Spring Cloud OpenFeign client with a per-client RequestInterceptor, and as a built-in HTTP interface backed by RestClient.

Cross-cutting concerns: timeouts, errors, auth, resilience and observability

Each concern has a different mechanism in each option. The config snippet that follows sets a connect and read timeout on the request factory and registers a status handler that maps 4xx and 5xx responses to domain exceptions.

Concern RestClient WebClient OpenFeign
Timeouts, pooling ClientHttpRequestFactory ClientHttpConnector Request.Options, underlying Client
Error mapping defaultStatusHandler, onStatus retrieve().onStatus ErrorDecoder
Auth, header propagation ClientHttpRequestInterceptor ExchangeFilterFunction RequestInterceptor

Timeouts and connection pooling belong to the HTTP library underneath the client, not to the client API. If you swap the JDK client for Apache HttpComponents or Reactor Netty, the defaults change with it. Set connect, read and pool limits explicitly so a dependency change cannot alter them.

Retries and circuit breaking belong in an external resilience library, where one policy covers every call. Retrying inside the client is risky in two ways. It can resend a non-idempotent POST that the server already processed. It also multiplies load during an outage.

Illustrative estimate (assumed numbers, not a benchmark): total attempts = a₁ × a₂ × … × aₙ, where aᵢ is the attempts allowed at each of n layers. Three services that each allow 3 attempts send 3 × 3 × 3 = 27 requests to a downstream service that is already failing.

Observability comes from the auto-configured RestClient.Builder or WebClient.Builder bean, which applies Micrometer instrumentation, message converters and customizers. RestClient.create() and WebClient.create() skip all three without any warning. Inject the builder bean instead.

Testing declarative and fluent clients

Each client type needs a different test seam, and the seam determines what the test actually verifies.

For RestClient, use MockRestServiceServer. Bind it to the RestClient.Builder before calling build(). It replaces the request factory, so assertions run against the real RestClient pipeline: URI building, headers, message converters and status handlers. A typical test sets an expectation on the request URI and method, responds with a JSON body or an error status, calls the client, then calls verify() to confirm every expected request was made. Inside @RestClientTest, the auto-configured builder and the mock server are already wired together. An HTTP interface backed by a RestClient adapter can be tested the same way, because the proxy delegates to that client.

MockRestServiceServer does not intercept WebClient, which uses a different connector abstraction. For WebClient, and for HTTP interfaces built on it, start a local mock HTTP server on a random port and point the client's base URL at it. You still exercise real sockets, codecs and timeouts.

In service-layer unit tests, stub the interface directly. Because it is a plain Java interface, a mock or a hand-written fake is enough to test business logic.

Stubbing tells you nothing about the wire, though. A stub cannot show that a field name mismatch deserializes to null, that a 404 becomes the exception type your code catches, or that an empty body fails decoding. Cover those cases with the mock-server tests above, or with consumer-driven contract tests against the provider.

Decision guide, migration path and impact on engineering cost

Once those tests exist, the choice of client becomes much easier to change later. Use the table below for the initial decision.

Context Choose Avoid it when
Servlet app, no streaming RestClient, plus an HTTP interface once you have more than a handful of endpoints You need backpressure or to compose many concurrent calls without tying up threads
WebFlux, streaming, or high-fanout async composition WebClient, plus an HTTP interface The team would call block() everywhere. That is reactive cost with blocking behaviour.
Spring Cloud estate with heavy Feign usage and service discovery Keep OpenFeign and migrate opportunistically You are starting fresh and don't need Feign's ecosystem, which adds a dependency line to keep upgraded

Where the time goes. HTTP interfaces save the most time. Each endpoint becomes one annotated method instead of hand-written request building and decoding, and reviewers only have to read signatures. Staying on core Spring dependencies removes a separate release train to track. The main cost is the reactive learning curve. Debugging operator chains, propagating context, and avoiding accidental blocking take real time to learn, so pay that cost only when the workload needs it.

Migrating from RestTemplate or Feign. Follow these steps:

  1. Translate the annotations. For example, @GetMapping on a Feign interface becomes @GetExchange.

  2. Move cross-cutting logic to its equivalent. Feign RequestInterceptors become ClientHttpRequestInterceptors or ExchangeFilterFunctions. ErrorDecoders become status handlers on the builder.

  3. Before switching traffic, run parity tests against the old client:

    • timeout behaviour
    • mapping from error status to exception
    • the exact exception types callers catch

    A mismatch in any of these changes retry and fallback behaviour without any warning.

  4. Move one client at a time behind a flag, so you can roll back by changing configuration rather than redeploying.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to spring boot restclient vs webclient, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.