Two layers: declarative contracts vs the clients that send requests

The four options sit at two different layers, and most confusion comes from comparing them as if they were peers.

Declarative contracts. Spring's HTTP interface clients and OpenFeign both start from an annotated Java interface. You declare methods, paths, headers and parameters, and the framework generates a proxy that turns each method call into an HTTP request. Neither one opens a socket. Each delegates the actual exchange to something underneath.

Clients that send requests. RestClient is Spring's fluent, blocking client. WebClient is its reactive, non-blocking counterpart. Each one builds the request, runs interceptors or filters, sends the request through an underlying HTTP library, and decodes the response.

The HTTP interface proxy is created by HttpServiceProxyFactory and backed by an adapter: RestClientAdapter, WebClientAdapter, or RestTemplateAdapter. That makes "HTTP interface vs WebClient" partly a false choice. An HTTP interface with a WebClientAdapter is a WebClient call, with a typed contract on top. Feign differs because it ships its own client abstraction, encoders, decoders and interceptors. With Feign, you configure HTTP behaviour in Feign's model rather than Spring's.

The request path looks like this:

  • HTTP interface: caller → interface proxy → adapter → RestClient or WebClient → HTTP library
  • OpenFeign: caller → Feign proxy → Feign client → HTTP library

Our position: use an HTTP interface backed by RestClient as the default for new services. Choose Feign only when you depend on a specific Feign feature. Use a WebClient transport only when the call path is reactive end to end.

                               +----------+
                               |  Caller  |
                               +----+-----+
               +--------------------+--------------------+
               |                                         |
===============|= LAYER 1: declarative contracts ========|====================
               v                                         v
 +----------------------------+            +----------------------------+
 | HTTP interface proxy       |            | OpenFeign proxy            |
 | (HttpServiceProxyFactory)  |            | (annotated interface)      |
 +-------------+--------------+            +-------------+--------------+
               v                                         |
 +----------------------------+                          |
 | Adapter:                   |                          |
 | RestClientAdapter (default)|                          |
 | WebClientAdapter           |                          |
 | RestTemplateAdapter        |                          |
 +------+-------------+-------+                          |
========|=============|= LAYER 2: sending clients =======|====================
        v             v                                  v
 +------------+ +------------+             +----------------------------+
 | RestClient | | WebClient  |             | Feign client               |
 | (blocking) | | (reactive) |             | encoders, decoders,        |
 +------+-----+ +-----+------+             | interceptors               |
        |             |                    +-------------+--------------+
        +------+------+                                  |
               v                                         v
 +----------------------------------------------------------------------+
 |                       Underlying HTTP library                        |
 +----------------------------------------------------------------------+

HTTP interface clients and OpenFeign are declarative contracts. An HTTP interface proxy hands each call to an adapter over RestClient or WebClient (or RestTemplate, omitted from the client layer here), while Feign sends requests through its own client, encoders, decoders and interceptors.

One endpoint, four implementations

Take a single endpoint: GET /orders/{id}?expand=lines, returning an Order.

HTTP interface. You declare a method annotated with @GetExchange("/orders/{id}") that takes @PathVariable and @RequestParam arguments. The interface holds the contract and nothing else. To create the proxy, you wrap a configured RestClient in RestClientAdapter, or a WebClient in WebClientAdapter. You pass that adapter to HttpServiceProxyFactory.builderFor(...), build the factory, and call createClient(OrderApi.class). This wiring is more explicit than Feign's @EnableFeignClients scanning. Newer Spring versions add their own registration support, so check what your version provides before you hand-write a bean for every client.

Feign. The method signature is almost the same, but it uses @GetMapping with @FeignClient(name = ..., url = ...) on the interface. Scanning creates the bean. Codecs and interceptors come from Feign configuration rather than from a client you built yourself.

RestClient. About four chained calls: get(), uri(template, id, expand), retrieve(), body(Order.class). This is more code at each call site. The benefit is that URI expansion, status handling and deserialization are all visible on the page.

WebClient. The chain has the same shape, but it ends in bodyToMono(Order.class). The reactive type then spreads to every caller.

The declarative options hide the mapping, while the hand-written ones show it. Exchange annotations are not server-side @RequestMapping, even though they look similar. Do not share one interface between a controller and its client. Doing so ties the two services' deployments and versions together, and it can expose server-only assumptions to consumers.

// Fragments, imports omitted. Base URL is a placeholder.

// 1. HTTP interface: the contract only, with no transport details.
interface OrderApi {
  @GetExchange("/orders/{id}")
  Order getOrder(@PathVariable("id") String id, @RequestParam("expand") String expand);
}

// Explicit wiring: client -> adapter -> proxy factory -> proxy.
@Bean
OrderApi orderApi(RestClient.Builder builder) {
  RestClient restClient = builder.baseUrl("").build();
  HttpServiceProxyFactory factory =
      HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient)).build();
  return factory.createClient(OrderApi.class);
}

// 2. Feign: Spring MVC annotations. @EnableFeignClients scanning creates the bean.
@FeignClient(name = "orders", url = "")
interface OrderFeignClient {
  @GetMapping("/orders/{id}")
  Order getOrder(@PathVariable("id") String id, @RequestParam("expand") String expand);
}

// 3. RestClient: URI expansion, status handling and decoding are visible at the call site.
Order order = restClient.get()
    .uri("/orders/{id}?expand={expand}", "42", "lines")
    .retrieve()            // default: 4xx/5xx raise exceptions
    .body(Order.class);

// 4. WebClient: same shape, but Mono<Order> spreads to every caller.
Mono<Order> orderMono = webClient.get()
    .uri("/orders/{id}?expand={expand}", "42", "lines")
    .retrieve()
    .bodyToMono(Order.class);

The same GET /orders/{id}?expand=lines call written as an @GetExchange interface wired through RestClientAdapter and HttpServiceProxyFactory, as a Feign client, and as hand-written RestClient and WebClient chains.

Blocking or reactive: return types, block() and virtual threads

The method signature tells you which execution model you have committed to. An interface backed by RestClient declares plain return types such as Order, List<Order>, void, or ResponseEntity<Order> when the caller needs status codes and headers. An interface backed by WebClient declares Mono<Order> or Flux<Order>, and nothing happens until something subscribes.

The block() trap

A common pattern is to adopt WebClient in a servlet application and call block() on every result. The request thread still waits for the response, so you gain no concurrency. You do take on Reactor and Reactor Netty as dependencies, a second thread pool, reactive stack traces that are harder to read, and context propagation for MDC and tracing that needs extra configuration. If every call ends in block(), RestClient is the better fit.

The reverse mistake is worse. In a WebFlux application, a small number of event-loop threads serve every connection. Blocking one of them stalls every request assigned to it. Reactor detects some of these cases and throws instead of blocking, but blocking JDBC calls or other blocking libraries can still stall the loop without any error.

Virtual threads

Reactive clients were attractive in I/O-bound servlet services mainly because blocking platform threads are expensive. Virtual threads make blocking cheap, so a RestClient call on a virtual thread can scale to high concurrency while the code stays sequential and easy to debug.

This article makes no throughput or latency claims. The transport, connection pool settings and thread model determine performance. The declarative layer does not.

Choose WebClient as the transport when the application is already reactive end to end, or when you need streaming (Flux over server-sent events) or fan-out composition that the rest of the pipeline consumes without blocking.

// Same API, two execution models. Order is a record defined elsewhere.

// Blocking: plain types, suited to servlet apps (incl. virtual threads)
interface OrderClient {
    @GetExchange("/orders/{id}")
    Order get(@PathVariable String id);              // caller waits, sequential code

    @GetExchange("/orders/{id}")
    ResponseEntity<Order> getWithMeta(@PathVariable String id); // status + headers
}

// Reactive: lazy publishers, nothing is sent until something subscribes
interface ReactiveOrderClient {
    @GetExchange("/orders/{id}")
    Mono<Order> get(@PathVariable String id);

    @GetExchange(url = "/orders/stream", accept = "text/event-stream")
    Flux<Order> stream();                            // streaming is a real reason to use WebClient
}

class Clients {
    OrderClient blocking() {
        RestClient rest = RestClient.create("");
        return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(rest))
                .build().createClient(OrderClient.class);
    }

    ReactiveOrderClient reactive() {
        WebClient web = WebClient.create("");
        return HttpServiceProxyFactory.builderFor(WebClientAdapter.create(web))
                .build().createClient(ReactiveOrderClient.class);
    }

    // Anti-pattern in a servlet app: reactive dependencies, no concurrency gain.
    // On a WebFlux event-loop thread this can stall every request on that loop.
    Order trap(ReactiveOrderClient c) {
        return c.get("42").block();  // prefer OrderClient.get("42") instead
    }
}

The same declarative interface backed by RestClient returns plain types or ResponseEntity, and backed by WebClient returns Mono or Flux. Calling block() on the reactive version throws away the reason to use it.

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

Every option needs a base URL, timeouts, auth headers and error mapping. They differ in where you configure them. An HTTP interface has no settings of its own. Everything lives on the RestClient or WebClient builder that you pass to the proxy factory. Feign keeps configuration per client, through a configuration class or properties keyed by the client name.

Concern HTTP interface + RestClient HTTP interface + WebClient OpenFeign
Base URL, timeouts Builder + request factory Builder + connector Per-client config
Auth headers ClientHttpRequestInterceptor ExchangeFilterFunction RequestInterceptor
Error mapping defaultStatusHandler onStatus, defaultStatusHandler ErrorDecoder
Default exception RestClientResponseException subtypes WebClientResponseException FeignException
Retries External library Reactor retryWhen or external Retryer

The exception types matter more than they first appear to. A retry predicate or circuit-breaker rule written against FeignException will not match anything once you migrate the client. Your 5xx calls will then fail without being retried. Before switching clients, audit every place that catches or classifies exceptions, and map errors to your own domain exceptions where you can.

HTTP interfaces have no built-in retries, circuit breakers or client-side load balancing. You add them through builder configuration, such as a load-balanced builder, or through a separate resilience library.

Clients built from Spring's auto-configured builders take part in the observation setup without extra work, so they produce metrics and trace propagation. A builder you create with a bare static factory skips that setup.

Proxies hide how each request is built. Encoding mistakes, missing headers and serialization errors don't show up in your code. Add a logging interceptor or filter, or set Feign's logger level, before you need it, and redact credentials in its output.

@Configuration
class OrderClientConfig {

    @Bean
    OrderClient orderClient(RestClient.Builder builder, TokenProvider tokens) {
        // Timeouts live on the request factory, not on the HTTP interface.
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(2));
        factory.setReadTimeout(Duration.ofSeconds(5));

        RestClient restClient = builder // injected builder keeps observation/metrics setup
            .baseUrl("")
            .requestFactory(factory)
            // Auth header added by an interceptor, the counterpart of Feign's RequestInterceptor.
            .requestInterceptor((request, body, execution) -> {
                request.getHeaders().setBearerAuth(tokens.currentToken());
                return execution.execute(request, body);
            })
            // Map HTTP errors to a domain exception so retry and fallback rules
            // do not depend on RestClientResponseException or FeignException.
            .defaultStatusHandler(HttpStatusCode::isError, (request, response) -> {
                int status = response.getStatusCode().value();
                boolean retryable = response.getStatusCode().is5xxServerError();
                throw new OrderServiceException(status, retryable);
            })
            .build();

        // The proxy factory only adapts the configured client; it adds no retries.
        return HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(restClient))
            .build()
            .createClient(OrderClient.class);
    }
}

class OrderServiceException extends RuntimeException {
    final int status;
    final boolean retryable; // retry predicates check this flag, not the client library's type
    OrderServiceException(int status, boolean retryable) {
        super("Order service returned HTTP " + status);
        this.status = status;
        this.retryable = retryable;
    }
}

An HTTP interface whose base URL, timeouts, auth header and error mapping are all set on the auto-configured RestClient.Builder. Errors are translated into a domain exception so retry logic does not depend on any one client's exception types.

Testing each option

Three levels of test catch three different classes of bug. Skipping one leaves that class of bug for production to find.

Unit tests for callers: mock the interface

A service that depends on an HTTP interface or Feign client depends only on a Java interface, so its unit tests can mock that interface directly. There is no HTTP layer to stub and no JSON to write by hand: you return domain objects and assert on business logic. This is where declarative clients save the most time. A service built on raw WebClient call chains forces you to stub fluent builders or extract a wrapper first. These tests catch logic errors. They prove nothing about the wire format.

RestClient-backed clients: MockRestServiceServer

MockRestServiceServer binds to a RestClient.Builder and intercepts requests before they reach the network. You can assert on method, path, query parameters, headers and request body, then return a canned response. This catches wrong URLs, missing auth headers and bad parameter encoding quickly, without opening a socket. It does not work for WebClient or Feign. It also bypasses the real HTTP connector, so it cannot exercise connector-level timeout or connection behavior.

All three clients: a stub HTTP server

Running a local stub server on a random port tests what the other two levels skip:

  • Real serialization: unknown fields, date formats, nulls.
  • Error mapping: does a 404 become your domain exception, or a generic 4xx?
  • Timeouts against a deliberately slow response.

Because the test only touches the network boundary, the same test suite works across RestClient, WebClient and Feign. That also makes it a safety net during migration.

When OpenFeign still makes sense, and migrating away from it

OpenFeign remains a reasonable choice where it is already deeply embedded. Its Spring Cloud integration is mature: @FeignClient(name = "orders") resolves through service discovery and client-side load balancing with no extra wiring, per-client settings follow well-known spring.cloud.openfeign property conventions, and many teams already know how its interceptors, error decoders and logging levels behave. Rewriting dozens of working clients delivers no direct user value.

The maintainers, however, have described Spring Cloud OpenFeign as feature-complete. It will be maintained, but new capabilities will go into Spring's own HTTP interface support. For new projects, treat that as a signal about where long-term investment is going.

Migration path

Most of the change is mechanical because Feign clients in Spring Cloud already use Spring MVC parameter annotations.

Feign HTTP interface / RestClient
@FeignClient(name, url) @HttpExchange(url) plus a proxy built from RestClientAdapter
@GetMapping, @PostMapping @GetExchange, @PostExchange
@PathVariable, @RequestParam, @RequestHeader, @RequestBody Unchanged
RequestInterceptor ClientHttpRequestInterceptor on RestClient.Builder
ErrorDecoder defaultStatusHandler on the builder
Discovery by service name @LoadBalanced RestClient.Builder bean

Migrate one client at a time and keep the interface name, so callers and injection points do not change. Error handling is the main risk. Feign throws FeignException by default, while RestClient throws RestClientResponseException subtypes. Any catch block written for one will silently miss the other. Run the contract tests against recorded responses before and after each swap. That catches changed exception types, null-versus-empty bodies and timeout behaviour before production does.

Decision guide and impact on cost and engineering time

With that safety net in place, the choice comes down to the stack you run and what you already have in production.

Option Use it for Avoid it when
HTTP interface on RestClient Most synchronous service-to-service calls The call path is reactive end to end
HTTP interface on WebClient Genuinely reactive stacks You would call block() on every result
Raw RestClient / WebClient One-off calls, streaming, unusual request building Many endpoints share one contract
OpenFeign Existing Spring Cloud setups where migration costs more than it returns Starting a new service

Both staying and migrating cost engineering time. OpenFeign is in maintenance mode, so staying means few new features and gradual drift from newer Spring APIs. Migrating requires extending the contract tests, accepting some risk of behaviour changes, and retraining the team. You may also have to rebuild resilience behaviour that Spring Cloud wired in for you, such as retries and circuit breakers.

After migration, you save time in three places. Interfaces remove hand-written request plumbing. Timeouts, auth and observability are configured once on the underlying client, so there is one transport configuration model. Tests can mock the interface directly.

Illustrative estimate (assumed numbers, not a benchmark): break-even months = migration hours ÷ monthly hours saved

Assume 12 Feign clients × 6 hours each, plus 40 hours for resilience and retraining, for a total of 112 hours. Assume the migration saves 8 hours a month. That gives 112 ÷ 8 = 14 months. If the service will be retired before then, keep Feign.

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 http interface vs feign, 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.