Advertisement
Verified Partner
Enterprise Cloud Infrastructure, GPU Clusters & AI APIs
Deploy high-throughput inference and quant trading pipelines with ultra-low latency.
Explore Platform β†’

Java 21 & Spring Boot 3: Building Production-Ready Cloud Microservices

By Dr. Erik Vandeberg β€’ Intermediate β€’ 15 min read β€’ Updated 2026-09-11

What You Will Master in This Tutorial

  • Leverage Java 21 Virtual Threads to handle high-throughput I/O with classic synchronous code.

1. High Concurrency with Java 21 Virtual Threads

Virtual Threads enable millions of lightweight concurrent tasks managed by the JVM.

JAVA
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
    @PostMapping("/process")
    public ResponseEntity<String> processPayment() {
        return ResponseEntity.ok("Processed via Virtual Thread");
    }
}
Note: Virtual threads allow writing standard readable synchronous code while achieving reactive non-blocking throughput.
Advertisement
Verified Partner
Quantitative Trading Systems & 30 AI Business Blueprints
Build predictable monthly recurring revenue with retainers & automated bots.
View Blueprints β†’

Knowledge Check: Test Your Understanding

1. What is the primary advantage of Virtual Threads in Java 21?

Frequently Asked Questions

Do I need to rewrite my Spring Boot code to use Virtual Threads?
No, in Spring Boot 3.2+, simply enable spring.threads.virtual.enabled=true.

2. Step by step: call a downstream service with RestClient

Virtual threads make concurrency cheap; they do not make downstream calls safe. The first real microservice skill is the outbound call, and Spring’s RestClient (the modern synchronous client in Spring Framework 6) is the tool. You will build an inventory client with explicit timeouts, call it from a controller, and keep the blocking style that virtual threads reward.

Step 1: enable virtual threads and bound the client

The FAQ below already gave you the switch β€” spring.threads.virtual.enabled=true in Spring Boot 3.2+. The client is where discipline starts: a RestClient built on the JDK HttpClient with a short connect timeout and a hard read timeout, so a sick downstream fails in seconds instead of accumulating thousands of parked virtual threads.

JAVA
@Service
public class InventoryClient {

    private final RestClient restClient;

    public InventoryClient(RestClient.Builder builder) {
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory();
        factory.setReadTimeout(Duration.ofSeconds(5));

        this.restClient = builder
                .baseUrl("http://inventory-service")
                .requestFactory(factory)
                .build();
    }

    public StockLevel getStock(String sku) {
        return restClient.get()
                .uri("/api/stock/{sku}", sku)
                .retrieve()
                .body(StockLevel.class);
    }
}

record StockLevel(String sku, int available) {}

Step 2: expose it through a controller

The controller stays exactly as plain as the PaymentController in section 1 β€” a synchronous method on a virtual thread. All the distributed-systems honesty lives in the client: timeouts, a typed record for the response, and exceptions that propagate instead of being swallowed into a null.

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

    private final InventoryClient inventory;

    public OrderController(InventoryClient inventory) {
        this.inventory = inventory;
    }

    @GetMapping("/stock/{sku}")
    public ResponseEntity<StockLevel> stock(@PathVariable String sku) {
        return ResponseEntity.ok(inventory.getStock(sku));
    }
}

3. A complete worked example

Assemble the flow: POST /api/orders checks stock, then charges through the payments service from section 1. The interesting part is the failure path. When the inventory service times out, the caller should receive a clean 502 with a ProblemDetail body (Spring 6 supports RFC 9457 natively) β€” not a stack trace and not a hung request:

JAVA
public class DownstreamUnavailableException extends RuntimeException {
    public DownstreamUnavailableException(String service, Throwable cause) {
        super(service + " is unavailable", cause);
    }
}

@RestControllerAdvice
class ApiErrorHandler {

    @ExceptionHandler(DownstreamUnavailableException.class)
    ProblemDetail handleDownstream(DownstreamUnavailableException ex) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_GATEWAY);
        problem.setTitle("Downstream service unavailable");
        problem.setDetail(ex.getMessage());
        return problem;
    }

    @ExceptionHandler(ResourceAccessException.class)
    ProblemDetail handleTimeout(ResourceAccessException ex) {
        return ProblemDetail.forStatusAndDetail(
                HttpStatus.GATEWAY_TIMEOUT, "A downstream call timed out");
    }
}

Wrap the client call in a try/catch that translates RestClient’s ResourceAccessException into DownstreamUnavailableException, and the example is complete: virtual threads absorb the concurrency, timeouts bound every call, and errors cross the service boundary as a documented contract β€” the three habits that separate a demo from a production microservice.

Step 3: make the dependency visible to Actuator

A service that calls downstream owes its operators an honest health endpoint. Spring Boot Actuator turns any bean implementing HealthIndicator into part of /actuator/health, so wire the inventory client into it β€” now load balancers and dashboards see the dependency the way your users experience it:

JAVA
@Component
public class InventoryHealthIndicator implements HealthIndicator {

    private final InventoryClient inventory;

    public InventoryHealthIndicator(InventoryClient inventory) {
        this.inventory = inventory;
    }

    @Override
    public Health health() {
        try {
            inventory.getStock("PING");
            return Health.up()
                    .withDetail("inventory-service", "reachable")
                    .build();
        } catch (Exception ex) {
            return Health.down(ex)
                    .withDetail("inventory-service", "unreachable")
                    .build();
        }
    }
}

Keep the probe cheap and cache-friendly: it runs on every health poll, so point it at the lightest read the downstream offers. And decide deliberately whether a dead dependency should report DOWN (remove this instance from rotation) or stay UP with a degraded detail β€” for a hard dependency like payments, DOWN is the truthful answer.

Complete the wiring in application.yml

Two configuration blocks make the code above real. The first is the virtual-threads switch from section 1, written where it belongs instead of passed on a command line; the second exposes the health endpoint β€” and only the safe endpoints β€” so the InventoryHealthIndicator is reachable at /actuator/health without opening the whole Actuator surface:

YAML
spring:
  application:
    name: order-service
  threads:
    virtual:
      enabled: true        # Spring Boot 3.2+ on Java 21

management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics
  endpoint:
    health:
      show-details: when-authorized

With show-details set to when-authorized, anonymous callers see only UP or DOWN while your operators get the per-dependency detail the indicator provides β€” the difference between a health endpoint and an information leak.

4. Troubleshooting

Going deeper: Spring Boot microservices in practice

Virtual threads change the economics of a Spring Boot service: on Java 21, a Spring Boot 3 application can hold very large numbers of concurrent, I/O-bound requests while keeping the plain synchronous controller code this tutorial writes. But production readiness lives in everything around the endpoint β€” knowing which workloads virtual threads actually help, where the real bottlenecks move once threads get cheap, and how each service behaves when a downstream dependency slows down. The teams that run this stack well treat concurrency as a property of the whole system, not a thread-pool setting.

Common mistakes

Frequently asked questions

Should I choose virtual threads or WebFlux?

If your codebase is classic synchronous Spring MVC, virtual threads deliver non-blocking-style throughput without rewriting everything into a reactive pipeline. WebFlux still earns its place for streaming and fully reactive stacks, but most CRUD-and-integration services are simpler to run β€” and debug β€” on virtual threads.

Do virtual threads make CPU-bound work faster?

No. Virtual threads shine when tasks spend their time waiting on I/O β€” database calls, HTTP requests, file access. CPU-bound computation is still bounded by the number of physical cores, no matter how lightweight the threads are.

Which versions do I need to follow this approach?

The tutorial builds on Java 21 and Spring Boot 3, which is the combination that ships virtual threads as a finished feature with first-class Spring support. Earlier Java versions only had them in preview form.

Get 1 AI agent blueprint every week

Join the free PVT newsletter: one practical AI agent or automation you can build and sell, every week. No spam.