Spring’s declarative HTTP client lets you describe a remote API as a Java interface annotated with @HttpExchange. Spring generates the proxy, so callers use typed methods instead of hand-written request code, and the contract lives in one place.
Before you start
You should be comfortable with Spring beans and dependency injection, and know basic HTTP semantics. This article assumes Spring Framework 6 or later for @HttpExchange.
Step-by-step walkthrough
Step 1: Describe the API as an interface
Annotate the interface with @HttpExchange("/users") and each method with @GetExchange, @PostExchange and so on. Parameters map to path variables, query parameters or the request body, so the method signature documents the call.
Step 2: Create the proxy bean
Register a bean using HttpServiceProxyFactory with an underlying RestClient or WebClient, and set the base URL. Inject the interface wherever it is needed; callers depend on the interface, not on the HTTP library, which keeps them testable.
Step 3: Configure timeouts and error mapping
Set connect and read timeouts on the underlying client, and define how non-2xx responses map to exceptions. A default of no timeout can hang a request thread indefinitely, so timeouts are part of the client contract rather than an afterthought.
Worked scenario
The interface declares the remote call; Spring supplies the implementation.
@HttpExchange("/users")
public interface UserClient {
@GetExchange("/{id}")
User getById(@PathVariable String id);
}Walk through the example
@HttpExchange sets the base path, and @GetExchange("/{id}") maps the method to GET /users/{id}. Spring resolves id into the path and deserializes the response into User. A caller injects UserClient and calls getById as a normal method, so the HTTP details stay in the interface.
Common mistake
Leaving the underlying client without timeouts, which lets one slow dependency exhaust the request thread pool. Another is forgetting that this is a blocking client and calling it from a reactive context, where a non-blocking client is required.
Verify the behavior
Point the base URL at a mock server and assert the request path, method and body match the interface. Return a 500 and confirm it maps to the expected exception. Delay the mock response beyond the configured timeout and confirm the call fails with a timeout instead of hanging.
Interview exercise
Why put the timeout on the client bean rather than in each call site?
Answer and reasoning
A timeout is part of the client’s contract: every call to that dependency should stop waiting at the same point, and a default in one place avoids the call sites that forget it. Per-call overrides remain possible for known-long operations, but the safe default belongs on the shared bean so a hang cannot exhaust the thread pool because one developer omitted a value.
Continue learning
Compare outbound call resilience in HTTP client timeouts and Exception advice. Read the Spring declarative HTTP interfaces documentation and try the Spring Boot interview questions.