Your orders API already returns a JSON record. Then checkout needs stock from inventory, and the HTTP call is the part nobody designed: RestClient.create(), no timeout, a String body, and a downstream 503 that becomes your 500. RestTemplate is in maintenance mode; this post starts from RestClient.

Inject the managed Builder, bound the wait, map JSON to a record, and handle status codes on purpose.

This continues the in-memory orders API. The new hop is the same inventory lookup sketched in virtual threads and Actuator observability: a second base URL, a stock JSON document, and a wait you must cap.

Inject the Builder, not RestClient.create()

Spring Boot auto-configures a prototype RestClient.Builder with Jackson message converters and observation filters. Inject that builder, set the inventory base URL, and build() a client for that dependency:

InventoryClient(RestClient.Builder builder, InventoryProperties inventory) {
    this.client = builder
            .baseUrl(inventory.baseUrl())
            .build();
}

Do not call RestClient.create() for application traffic. That factory skips Boot’s converters and interceptors. Outbound traceparent and client metrics come from the managed builder — the observability post already shows that path; this post will not re-lecture traces.

Note: Do not replace Boot’s builder with a @Bean RestClient.Builder that returns RestClient.builder() from scratch. Customize the injected prototype (or a RestClientCustomizer) so you keep Jackson and observation. The next section adds timeouts on that same instance.

The inventory hop

Orders stays the system of record. Inventory answers “how many of this SKU exist right now?” on its own host:

Client
  |
  v
GET /api/orders/{id}
  |
  +--> OrderService (in-memory Order)
  |
  +--> RestClient  GET {inventory.base-url}/api/stock/{sku}
  |
  v
OrderDetail record (order + stock)

Production inventory is a second service (http://inventory:8082). The laptop lab hosts a stub on the same port and points inventory.base-url at http://localhost:8080 so one spring-boot:run is enough.

A same-process call uses a second Tomcat worker for the outbound GET. One curl is fine. Do not load-test this stub-in-process layout.

Bound connect and read timeouts

An unbounded HTTP call is a thread you cannot get back. Set connect and read timeouts on the inventory client before you write the first GET.

On Boot 3.4+, global defaults for the auto-configured request factory look like this:

spring:
  http:
    client:
      connect-timeout: 2s
      read-timeout: 3s

inventory:
  base-url: http://localhost:8080

A named client can still be tighter than the global default. Keep Boot’s builder, then attach a JDK request factory with connect and read limits:

package com.geekmonks.orders;

import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.RestClient;

@Configuration
class InventoryClientConfig {

    @Bean
    InventoryClient inventoryClient(
            RestClient.Builder builder,
            InventoryProperties inventory) {

        HttpClient jdk = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(2))
                .build();

        var factory = new JdkClientHttpRequestFactory(jdk);
        factory.setReadTimeout(Duration.ofSeconds(3));

        RestClient client = builder
                .baseUrl(inventory.baseUrl())
                .requestFactory(factory)
                .build();

        return new InventoryClient(client);
    }
}

@ConfigurationProperties(prefix = "inventory")
record InventoryProperties(String baseUrl) {}

connectTimeout fails fast when inventory is unreachable. readTimeout fails fast when the TCP connection exists but the body never arrives. Both are cheaper than a hanging servlet thread.

How many concurrent calls you allow — max connections, not just timeouts — is the virtual-threads sizing job. This post owns the per-call wait.

GET JSON into a record

The stock document is a transport value. Use a record, the same boundary as REST records as DTOs; this post will not re-lecture Jackson accessors or @Valid.

package com.geekmonks.orders;

public record InventoryStock(String sku, int available) {}

A fluent GET names the method, the URI template, and the body type. retrieve() decodes a 2xx body through the builder’s converters:

package com.geekmonks.orders;

import org.springframework.http.MediaType;
import org.springframework.web.client.RestClient;

class InventoryClient {

    private final RestClient client;

    InventoryClient(RestClient client) {
        this.client = client;
    }

    InventoryStock getStock(String sku) {
        return client.get()
                .uri("/api/stock/{sku}", sku)
                .accept(MediaType.APPLICATION_JSON)
                .retrieve()
                .body(InventoryStock.class);
    }
}

URI variables stay in .uri(...). Do not concatenate the SKU into a string; encoding and logging stay honest.

With the default status handler, 4xx and 5xx throw RestClientResponseException instead of returning null. That is the right default until you map those statuses yourself.

POST when inventory must reserve stock

Creating an order is not only a local Map.put. Ask inventory to reserve quantity with a JSON body record:

package com.geekmonks.orders;

public record ReserveStock(String sku, int quantity) {}

public record StockReservation(String sku, int reserved, String reservationId) {}

The fluent POST sets content type, writes the body, and still uses retrieve() for the 2xx record:

StockReservation reserve(String sku, int quantity) {
    return client.post()
            .uri("/api/stock/{sku}/reservations", sku)
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON)
            .body(new ReserveStock(sku, quantity))
            .retrieve()
            .body(StockReservation.class);
}

Headers that every inventory call needs belong on the builder (defaultHeader, defaultHeaders), not copied into each method.

retrieve for the happy path, exchange when you need the status

retrieve() is the 2xx decoder. Use it when the body type is the whole answer and errors should throw.

exchange() gives you the status, headers, and a convertible body in one callback. Use it when 404 is a domain result, not an exception:

import java.util.Optional;
import org.springframework.http.HttpStatus;

Optional<InventoryStock> findStock(String sku) {
    return client.get()
            .uri("/api/stock/{sku}", sku)
            .accept(MediaType.APPLICATION_JSON)
            .exchange((request, response) -> {
                if (response.getStatusCode().is2xxSuccessful()) {
                    return Optional.of(
                            response.bodyTo(InventoryStock.class));
                }
                if (response.getStatusCode() == HttpStatus.NOT_FOUND) {
                    return Optional.empty();
                }
                throw new InventoryUnavailableException(sku);
            });
}

retrieve() plus onStatus is usually enough. Reach for exchange() when you must branch on status without the default exception, or when you need headers (ETag, Retry-After) as well as the body.

Note: exchange() does not apply retrieve()’s default 4xx/5xx handler. If you ignore the status, you will decode an error payload as InventoryStock or return null and call it success.

Map status codes with onStatus

Inventory 404 and inventory 503 are different failures. Map them next to the call so the orders API does not leak RestClient exceptions:

import org.springframework.http.HttpStatus;
import org.springframework.http.HttpStatusCode;
import org.springframework.web.server.ResponseStatusException;

InventoryStock getStock(String sku) {
    return client.get()
            .uri("/api/stock/{sku}", sku)
            .accept(MediaType.APPLICATION_JSON)
            .retrieve()
            .onStatus(status -> status.value() == 404, (request, response) -> {
                throw new ResponseStatusException(
                        HttpStatus.UNPROCESSABLE_ENTITY,
                        "SKU not in inventory: " + sku);
            })
            .onStatus(HttpStatusCode::is5xxServerError, (request, response) -> {
                throw new ResponseStatusException(
                        HttpStatus.BAD_GATEWAY,
                        "Inventory unavailable for SKU: " + sku);
            })
            .body(InventoryStock.class);
}

Client mistakes stay 4xx. Inventory outages become 502 Bad Gateway, not an uncaught 500. Timeouts surface as ResourceAccessException; map those to 504 in @RestControllerAdvice if the lab grows an advice class.

A 5xx policy that applies to every inventory call belongs on the builder as defaultStatusHandler. Keep 404 handling on the method so findStock can still return Optional.empty().

Wire inventory into the orders lab

Reuse the in-memory OrderService from the records post. Enrich GET /api/orders/{id} with stock for the first line so the HTTP client stays visible:

package com.geekmonks.orders;

import java.util.UUID;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("/api/orders")
class OrderController {

    private final OrderService orders;
    private final InventoryClient inventory;

    OrderController(OrderService orders, InventoryClient inventory) {
        this.orders = orders;
        this.inventory = inventory;
    }

    @GetMapping("/{id}")
    OrderDetail findById(@PathVariable UUID id) {
        OrderService.Order order = orders.findById(id);
        String sku = order.lines().get(0).sku();
        InventoryStock stock = inventory.getStock(sku);
        return new OrderDetail(
                order.id(),
                order.customerId(),
                sku,
                order.lines().get(0).quantity(),
                stock.available(),
                order.status().name());
    }

    public record OrderDetail(
            UUID id,
            String customerId,
            String sku,
            int orderedQty,
            int available,
            String status
    ) {}
}

POST /api/orders can call inventory.reserve(sku, quantity) before orders.create(...). If reservation fails, skip the local insert. Annotate the application class with @EnableConfigurationProperties(InventoryProperties.class).

Stub inventory in the same process

The stub is a stand-in for http://inventory:8082. Known SKU java-records is in stock. missing returns 404. down returns 503 so you can exercise onStatus:

package com.geekmonks.orders;

import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class InventoryStubController {

    @GetMapping("/api/stock/{sku}")
    ResponseEntity<InventoryStock> stock(@PathVariable String sku) {
        if ("down".equals(sku)) {
            return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).build();
        }
        if ("missing".equals(sku)) {
            return ResponseEntity.notFound().build();
        }
        int available = Map.of("java-records", 12)
                .getOrDefault(sku, 4);
        return ResponseEntity.ok(new InventoryStock(sku, available));
    }

    @PostMapping("/api/stock/{sku}/reservations")
    StockReservation reserve(
            @PathVariable String sku,
            @RequestBody ReserveStock body) {

        return new StockReservation(
                sku, body.quantity(), UUID.randomUUID().toString());
    }
}

spring-boot-starter-web already pulls RestClient. No extra client starter is required for this lab.

Run the lab

Start the application:

./mvnw spring-boot:run

Create an order with the records-post payload so the first line SKU is java-records:

curl -i -X POST http://localhost:8080/api/orders \
  -H 'Content-Type: application/json' \
  -d '{
    "customerId": "customer-42",
    "items": [
      {"sku": "java-records", "quantity": 2}
    ]
  }'

Typical create response:

HTTP/1.1 201
Location: /api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
Content-Type: application/json

Fetch that order. RestClient should GET /api/stock/java-records and fill available:

curl -sS \
  http://localhost:8080/api/orders/7da2aab5-aeba-46f3-9610-39f0aa46098a
{
  "id": "7da2aab5-aeba-46f3-9610-39f0aa46098a",
  "customerId": "customer-42",
  "sku": "java-records",
  "orderedQty": 2,
  "available": 12,
  "status": "CREATED"
}

Hit the stub directly to see the three status paths your onStatus handlers care about:

curl -i http://localhost:8080/api/stock/java-records
curl -i http://localhost:8080/api/stock/missing
curl -i http://localhost:8080/api/stock/down
HTTP/1.1 200
{"sku":"java-records","available":12}

HTTP/1.1 404

HTTP/1.1 503

Post a reservation against the stub when you wire reserve into create:

curl -sS -X POST http://localhost:8080/api/stock/java-records/reservations \
  -H 'Content-Type: application/json' \
  -d '{"sku":"java-records","quantity":2}'

Cheat sheet

Client
  Inject RestClient.Builder (prototype, Boot-managed)
  builder.baseUrl(...).requestFactory(...).build()
  Never RestClient.create() for app traffic
  Never replace Builder with RestClient.builder() from scratch

Timeouts
  Connect: fail if inventory is unreachable
  Read:    fail if the body never arrives
  Boot 3.4+: spring.http.client.connect-timeout / read-timeout
  Connection caps: virtual-threads post, not here

Calls
  GET  client.get().uri(...).retrieve().body(Record.class)
  POST client.post().body(record).retrieve().body(Record.class)
  retrieve()  -> 2xx body, default 4xx/5xx exceptions
  onStatus()  -> map 404 vs 5xx at the call
  exchange()  -> you own status, headers, and body

Errors
  Inventory 404 -> your 422 (or Optional.empty)
  Inventory 5xx -> 502
  Timeout       -> 504 (advice), not a hung thread

Wrap-up

RestClient is the Boot 3 synchronous HTTP client: one injected builder, a fluent GET or POST, JSON into a record, and status handling you wrote. Timeouts make the wait finite. onStatus keeps inventory’s 404 and 503 from becoming an uncaught 500 on orders.

Keep observation on the managed builder, keep virtual-thread connection caps in that post, and keep request/response records as DTOs in the API-boundary post. This lab only adds the inventory hop — a second base URL with a wait and errors you handle.

Next optional step Version the schema with Flyway instead of letting Hibernate rewrite it. Flyway Without the ddl-auto Roulette