← Back to blog
SpringBoot14 min read

RestTemplate Deprecated: Migrate to RestClient (Boot 4.2)

Migrate off RestTemplate in Spring Boot 4.2: RestClient with per-client timeouts, @HttpExchange interfaces, idempotent writes, tracing, and a side-by-side rollout.

Illustration of a payment service sending requests through a new typed HTTP client layer, with timeout, trace and security checkpoints between it and downstream services

If you run Java services against Spring Boot 4.2, RestTemplate is on its way out and RestClient is where new code should go, optionally behind @HttpExchange interfaces. I am going to walk through the migration in the order I would do it on a payment service, including the parts that bite: error handling, idempotent writes, URI encoding, and running old and new clients side by side.

Before you plan a sprint around dates, read the Spring Framework 7.1 and Spring Boot 4.2 release notes and the RestTemplate Javadoc yourself. Coverage of the milestone has been inconsistent about when removal lands, and the notes are the authority. What follows does not depend on the exact date, because every item it fixes is a problem a legacy client already has.

I treat a framework deprecation warning as the cheap part. The expensive part is the set of habits the old API let slide. When I saw RestTemplate flagged, my question was not how to rename a type. It was which of my services call other services with no timeout whatsoever.

What actually changes between RestTemplate and RestClient

RestTemplate has been around since Spring 3. Its surface grew a method at a time: getForObject, getForEntity, postForLocation, exchange, execute, each with variants for URI templates and Map variables. Configuration is spread over request factories, interceptors, and a ResponseErrorHandler. RestClient (Spring Framework 6.1) is synchronous like its predecessor, but you describe a call as a chain and pick how much of the response you want:

Balance b = restClient.get()
    .uri("/accounts/{id}/balance", accountId)
    .retrieve()
    .body(Balance.class);

It stays blocking, so it pairs naturally with Java 21 virtual threads (see Java virtual threads in Spring Boot production). On top of it, Spring's HTTP interface support lets you declare an interface and have a proxy generated.

The alternatives I weighed

  • WebClient. Right when you want streaming or genuinely non-blocking composition. For plain request/response between services on virtual threads, it adds a reactive type to every signature for no gain.
  • JDK HttpClient directly. Fine for one-off calls, but you re-implement message conversion, base URLs, interceptors and error mapping. RestClient can use the JDK client as its transport anyway.
  • OpenFeign. Does the same declarative job as @HttpExchange, but it is a separate Spring Cloud dependency with its own configuration model. If you are already on it and happy, there is no emergency. For a greenfield Boot service, I prefer the interface support that ships in the framework.
  • Keep RestTemplate and use the bridge. RestClient.create(restTemplate) builds a client that reuses the template's request factory, interceptors, and converters. This matters most for the coexistence period, covered below.

What I audit in an old RestTemplate setup

I have worked on a platform handling around 130 million requests a day and on PSD2 open banking integrations where each downstream call touches money. In my experience the failures that hurt are not a dependency going dark; they are a client with a loose policy toward a dependency that is merely slow. So the audit starts with:

  1. Timeouts. The default SimpleClientHttpRequestFactory has no read timeout unless set. A stuck partner then holds a thread indefinitely.
  2. Retry behaviour. Anything retrying a payment POST without an idempotency key can double-pay.
  3. Pool limits. Per-route and total connection caps, and what happens at the cap.
  4. Trace propagation. Does the traceparent header actually leave the process?
  5. Error mapping. Is a 404 for a missing mandate distinguishable from a 503 from the partner?

Each of these has to be touched during the migration anyway, which is why I would not do it as a mechanical rename.

The "before": a typical RestTemplate client

This is a trimmed version of a shape I keep running into:

@Configuration
class LegacyClientConfig {

    @Bean
    RestTemplate ledgerRestTemplate(RestTemplateBuilder builder) {
        return builder
            .rootUri("https://ledger.internal")
            .additionalInterceptors((req, body, exec) -> {
                req.getHeaders().add("X-Tenant", TenantContext.current());
                return exec.execute(req, body);
            })
            .build();   // no timeouts, default pooling, default error handler
    }
}

@Service
class LedgerGateway {
    private final RestTemplate rest;

    LedgerGateway(RestTemplate ledgerRestTemplate) { this.rest = ledgerRestTemplate; }

    Balance balanceFor(String accountId) {
        return rest.getForObject("/accounts/{id}/balance", Balance.class, accountId);
    }
}

The interceptor adding a tenant header is a perfectly normal use of the mechanism, so I would keep that idea. The real defects are elsewhere: no connect or read timeout; a default error handler that surfaces every failure as a generic HttpClientErrorException or HttpServerErrorException, so callers end up matching on status codes scattered across the codebase; and one shared template that every dependency would be tempted to reuse, with the same limits for all.

Step one: RestClient with explicit timeouts and pooling

Each downstream gets its own client with its own limits. A ledger inside the cluster and a bank gateway across the internet should never share a timeout. The numbers below are illustrative starting points; derive yours from each dependency's measured p99, then add headroom.

@Configuration
class LedgerClientConfig {

    @Bean
    RestClient ledgerRestClient(RestClient.Builder builder,
                                @Value("${clients.ledger.base-url}") String baseUrl) {

        var requestFactory = new JdkClientHttpRequestFactory(
            HttpClient.newBuilder()
                .connectTimeout(Duration.ofMillis(300))
                .build());
        requestFactory.setReadTimeout(Duration.ofSeconds(2));

        return builder
            .baseUrl(baseUrl)
            .requestFactory(requestFactory)
            .requestInterceptor(new TenantHeaderInterceptor())
            .defaultStatusHandler(HttpStatusCode::is4xxClientError, (req, res) -> {
                if (res.getStatusCode().value() == 404) {
                    throw new AccountNotFoundException(req.getURI().getPath());
                }
                throw new LedgerRejectedException(res.getStatusCode().value());
            })
            .defaultStatusHandler(HttpStatusCode::is5xxServerError, (req, res) -> {
                throw new LedgerUnavailableException(res.getStatusCode().value());
            })
            .build();
    }
}

Status codes become domain exceptions at the boundary, so nothing upstream inspects raw HTTP. One behaviour to know: when a socket times out, you do not get a status handler call at all. The failure surfaces as ResourceAccessException wrapping an IOException. Your wrapper must treat that as its own category, "outcome unknown", which matters a great deal for writes (see step four).

On pooling, the transport you choose decides how connections are reused. The JDK client pools internally with limited tuning; with Apache HttpClient or Jetty you set max-total and max-per-route explicitly. Whatever you pick, cap it. An unbounded pool only moves the exhaustion onto the partner.

Using the Boot-provided builder

Inject RestClient.Builder rather than calling RestClient.create(). Boot pre-configures the injected builder with its message converters and observation support, so a hand-built client silently loses both. The builder bean is prototype-scoped, so each injection point gets a fresh copy and one client's settings do not leak into another.

Step two: declare the client as an @HttpExchange interface

Now I replace the gateway class of getForObject calls with a contract:

@HttpExchange("/accounts")
public interface LedgerApi {

    @GetExchange("/{id}/balance")
    Balance balance(@PathVariable String id);

    @PostExchange("/{id}/holds")
    Hold placeHold(@PathVariable String id,
                   @RequestHeader("Idempotency-Key") String idempotencyKey,
                   @RequestBody HoldRequest request);

    @DeleteExchange("/{id}/holds/{holdId}")
    void releaseHold(@PathVariable String id, @PathVariable String holdId);
}

Wire it to the client from step one:

@Configuration
class LedgerApiConfig {

    @Bean
    LedgerApi ledgerApi(RestClient ledgerRestClient) {
        var factory = HttpServiceProxyFactory
            .builderFor(RestClientAdapter.create(ledgerRestClient))
            .build();
        return factory.createClient(LedgerApi.class);
    }
}

A new teammate can read the ledger dependency in under a minute, and tests can stub a plain interface. Making Idempotency-Key a required parameter means no call site can forget it, which does more for safety than any retry library. The pattern ties back to transactional outbox and idempotent consumers.

The diagram shows the migrated layering, from payment service to downstream API:

Architecture diagram of the migrated client layer: payment service calls a typed HTTP interface, which uses RestClient with timeouts, trace propagation, a resilience wrapper and an SSRF address filter before reaching downstream services

Step three: trace propagation with Micrometer

Trace context only goes out on the wire if the client was built in a way Micrometer can instrument. Construct a RestClient by hand and the traceparent header disappears at that hop with no error. Rather than trusting the builder rule, prove it with a test:

@SpringBootTest
@AutoConfigureObservability
class LedgerClientTracingTest {

    @Autowired LedgerApi ledgerApi;
    @Autowired MockWebServer ledgerServer;   // test helper bean

    @Test
    void traceparentHeaderIsForwarded() throws Exception {
        ledgerServer.enqueue(new MockResponse().setBody("{\"amount\":10}"));

        ledgerApi.balance("acc-1");

        RecordedRequest recorded = ledgerServer.takeRequest();
        assertThat(recorded.getHeader("traceparent")).isNotBlank();
    }
}

When a refactor breaks propagation, this goes red in CI. For the wider setup, see distributed tracing with OpenTelemetry in Spring Boot; for agent workloads, observability and guardrails for agentic AI backends.

Step four: retries that respect money

Spring Framework 7 includes core retry support (@Retryable and RetryTemplate), and Resilience4j remains widely used. They overlap, so pick one per call path and do not stack them, otherwise a "3 retries" setting quietly becomes nine attempts. I use Resilience4j in this example because the circuit breaker comes with it. The policy matters more than the library:

  • Reads (GET) may be retried a couple of times with short, jittered backoff.
  • Writes (POST) are retried only if they carry an idempotency key, and only for connect failures or a 503. After a read timeout (ResourceAccessException) the outcome is unknown, so I do not retry automatically.
  • When a partner sends 429 or 503 with Retry-After, honour it instead of using my own backoff.
  • A circuit breaker stops traffic to a partner that is already failing.

The wrapper wraps the typed interface because the policy depends on the method, and an interceptor only sees raw requests:

@Component
class ResilientLedger {

    private final LedgerApi api;
    private final Retry readRetry;
    private final CircuitBreaker breaker;

    ResilientLedger(LedgerApi api, RetryRegistry retries, CircuitBreakerRegistry breakers) {
        this.api = api;
        this.readRetry = retries.retry("ledger-read");
        this.breaker = breakers.circuitBreaker("ledger");
    }

    Balance balance(String accountId) {
        return Decorators.ofSupplier(() -> api.balance(accountId))
            .withCircuitBreaker(breaker)
            .withRetry(readRetry)
            .get();
    }

    Hold placeHold(String accountId, HoldRequest request) {
        // key is derived from the business operation, so a replay is a no-op downstream
        String key = "hold-" + request.paymentId();
        try {
            return Decorators.ofSupplier(() -> api.placeHold(accountId, key, request))
                .withCircuitBreaker(breaker)
                .get();   // deliberately no automatic retry on the write path
        } catch (ResourceAccessException e) {
            // timeout or reset: the ledger may or may not have applied the hold
            throw new HoldOutcomeUnknownException(request.paymentId(), e);
        }
    }
}

The caller of placeHold knows the payment's state, so the caller decides what to do with HoldOutcomeUnknownException, typically by replaying with the same key, which is safe.

And a retry-config sketch for the read path:

resilience4j:
  retry:
    instances:
      ledger-read:
        max-attempts: 3
        wait-duration: 100ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        enable-randomized-wait: true
        retry-exceptions:
          - org.springframework.web.client.ResourceAccessException
          - com.example.payments.LedgerUnavailableException
        ignore-exceptions:
          - com.example.payments.AccountNotFoundException

Step five: SSRF guard, only where it applies

SSRF (server-side request forgery) is when an attacker gets your server to call an address it should never reach, such as an internal admin endpoint or a cloud metadata service. Be honest about where this applies. Calls to a fixed in-cluster ledger URL from configuration are not an SSRF surface; network policy and egress rules cover them. The exposure begins when a customer supplies the address: webhook targets, callback URLs, import-from-URL features.

Spring Boot 4.1 introduced address filtering for outbound clients; see Spring Boot 4.1 SSRF mitigation. Check the 4.1 and 4.2 reference docs for exact properties; I describe the design here and avoid guessing at property names.

For customer-supplied URLs, use a dedicated client. Reject loopback, link-local and private ranges, evaluated against the resolved IP rather than the hostname, because an attacker can point a public hostname at 169.254.169.254. Also disable redirects: a harmless-looking public URL can answer 302 to an internal one.

@Bean
RestClient webhookRestClient(RestClient.Builder builder, InetAddressFilter publicOnly) {
    // publicOnly: rejects loopback, link-local, and RFC 1918 private ranges
    var requestFactory = new JdkClientHttpRequestFactory(
        HttpClient.newBuilder()
            .connectTimeout(Duration.ofSeconds(1))
            .followRedirects(HttpClient.Redirect.NEVER)   // a redirect can point inside
            .build());
    requestFactory.setReadTimeout(Duration.ofSeconds(3));
    return builder.requestFactory(requestFactory).build();
}

How the filter bean is wired depends on the Boot version, so confirm it against the official docs.

Coexistence: running RestTemplate and RestClient together

The hard part of this migration is the weeks when both exist. Two things help. First, RestClient.create(existingRestTemplate) lets new code share the legacy template's factory, interceptors and converters, so you migrate call sites before you migrate configuration. Second, in tests, MockRestServiceServer works with both: bind it to a RestTemplate with bindTo(restTemplate), and to a RestClient.Builder with bindTo(builder) before building the client. Reusing the same expectations against both is a cheap equivalence check.

Gotchas that cost me time

  • Error handler semantics. RestTemplate's ResponseErrorHandler throws for any 4xx/5xx by default. RestClient.retrieve() also throws RestClientResponseException subclasses (HttpClientErrorException, HttpServerErrorException), but status handlers registered on the builder replace that behaviour per category. If you rely on catching HttpClientErrorException.NotFound anywhere, grep for it before you register handlers that throw something else.
  • Null bodies. body(Foo.class) can return null for an empty response, as getForObject did. Decide explicitly what an empty 200 means for your domain.
  • URI template encoding. The two APIs build URIs from templates through the same UriBuilderFactory machinery, but the default encoding mode can differ depending on how you configured the old template (for example one with a custom DefaultUriBuilderFactory). Path variables with +, / or % are the ones to test, since a mismatch there routes to the wrong resource without an exception.
  • Message converter parity. The injected builder gives you Boot's ObjectMapper settings. A template built with new RestTemplate() may have used different defaults (date format, unknown-property handling). Diff the JSON on the wire.
  • Interceptor order. Interceptors run in registration order. A tenant header interceptor and a logging interceptor swapped around will change what gets logged.

Rolling it out: the Strangler pattern with contract tests

I do not ship this in one pull request. The Strangler pattern, named after a vine that grows around a tree and eventually replaces it, means new code wraps the old and takes over one piece at a time.

  1. Freeze behaviour with contract tests. Record, for each downstream call, the method, path, headers, body shape and error responses. Spring Cloud Contract or Pact fit here. These are your safety net.
  2. Add the typed interface beside the old gateway. LedgerGateway keeps using RestTemplate; ResilientLedger uses LedgerApi. Both exist.
  3. Route a slice of traffic. A flag sends a few percent of balance reads down the new path. Compare latency and error rate on a dashboard. Reads go first because they are safe.
  4. Migrate writes last. Holds and payments move only after reads have been quiet for a week and idempotency keys are verified in the contract tests.
  5. Delete the old path. Once the flag is at 100 percent and quiet, remove the RestTemplate configuration entirely. Skipping this step is how teams still carry deprecated code a year later.

A caution: I have seen a community write-up claiming that swapping the default transport changed parallel-call performance noticeably. That is one author's anecdote and the cause may be classpath-related, but it is the reason for the percentage rollout. Measure your own numbers.

Where this sits in the 4.2 picture

RestTemplate is one item in a bigger release. The other headline is AMQP 1.0 support, a broker-neutral protocol distinct from the 0.9.1 flavour classic RabbitMQ users know. From the 4.2 reference I read, there is generic AMQP 1.0 support built on the Qpid ProtonJ2 client alongside the classic RabbitMQ path. The starter names appeared to differ between snapshot and release documentation, and the Spring AMQP reference describes its 1.0 client as still in development, so evaluate it rather than adopting it blindly. If your payment pipelines lean on queues, compare it with Kafka share groups.

Both changes nudge service-to-service communication toward explicit, typed, configurable clients, which is the direction I want after debugging incidents caused by invisible defaults.

What I would do differently next time

Contract tests first. On this migration I began with the RestClient configuration because it was more interesting, then lost an afternoon reconstructing what the old client had really been sending. I would also derive timeouts from measured latency percentiles from day one instead of copying round numbers between services.

If you run Java 21 services in Europe under PSD2 or DORA-style scrutiny, the tracing and error-handling gains double as audit evidence: you can show which calls are made, with what limits, and how failures are handled. My background is in the experience section, and the projects section covers the live trading and market-data work where client timeouts decide whether a feed survives a bad hour.

Your migration checklist

  • Read the official Spring Framework 7.1 and Boot 4.2 release notes and the Javadoc for exact deprecation scope.
  • Inventory every RestTemplate bean and list its timeouts (many will show none).
  • Write contract tests for each downstream call.
  • Build one RestClient per downstream with explicit connect and read timeouts and a bounded pool.
  • Inject RestClient.Builder; assert the traceparent header in a test.
  • Map status codes to domain exceptions, and handle ResourceAccessException separately as "outcome unknown".
  • Replace gateway classes with @HttpExchange interfaces, one dependency at a time.
  • Choose one retry mechanism per path, and never auto-retry writes without an idempotency key.
  • Give customer-supplied URLs their own client with an address filter and redirects off.
  • Check null bodies, URI encoding and converter output against the old client.
  • Roll out reads first behind a flag, then writes, then delete the old code.

Pick the one downstream dependency in your busiest service that has no timeout today, write its contract test this week, and migrate that client first. If you want a second pair of eyes on a migration plan, reach out through the contact section.

Related Articles

FAQ

Related: ASEAN Cross-Border QR Payments: Building an EMVCo QR Router Across PayNow, DuitNow, PromptPay and QRIS in Spring Boot

Is RestTemplate removed in Spring Boot 4.2?

No. It is deprecated, not removed, and it keeps working. Check the official release notes and Javadoc for the exact removal timeline before planning the work.

Should I use RestClient or WebClient to replace RestTemplate?

For ordinary blocking service-to-service calls, RestClient is the closer match, and it works well with Java 21 virtual threads. WebClient is the better fit when you need reactive, non-blocking streams.

How do I keep trace headers when migrating to RestClient?

Inject the auto-configured RestClient.Builder instead of calling RestClient.create(). Then add a test that asserts the traceparent header reaches the downstream service.

Is it safe to retry payment POST requests?

Only with an idempotency key, and never automatically after a read timeout, because you cannot tell whether the partner already processed the request. Keep retries on the write path as an explicit caller decision.