Java 21 & Spring Boot 3: Building Production-Ready Cloud Microservices
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.
@RestController
@RequestMapping("/api/payments")
public class PaymentController {
@PostMapping("/process")
public ResponseEntity<String> processPayment() {
return ResponseEntity.ok("Processed via Virtual Thread");
}
}
Knowledge Check: Test Your Understanding
Frequently Asked Questions
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.
@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.
@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:
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:
@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:
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
- Throughput did not improve after enabling virtual threads. Look for pinning: a virtual thread blocked inside a synchronized block or a native call pins its carrier thread. Run with -Djdk.tracePinnedThreads=full during a load test, find the pinned stacks, and replace synchronized hot paths with ReentrantLock β the exact trap this tutorialβs deep-dive warns about.
- Requests hang for minutes when a downstream is down. No timeouts are set. The JDK factory defaults will wait far longer than your users will. Set a read timeout on the request factory (step 1) and a connect timeout on the underlying HttpClient; every outbound call in the system needs both.
- The service falls over at high concurrency despite cheap threads. Virtual threads removed the thread bottleneck, so the bottleneck moved: usually the database connection pool. Ten thousand virtual threads times one connection each is a math problem, not a threading model. Size the pool for the database, and bound fan-out with a Semaphore in front of scarce resources.
- 406 or empty bodies when calling another Spring service. The response record does not match the JSON: a renamed field, a nested object flattened, a missing default constructor for Jackson on an older setup. Log the raw body once (exchange() instead of retrieve() while debugging), align the record, and pin the contract with a test.
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
- Pinning virtual threads with synchronized blocks. When a virtual thread blocks inside a synchronized section it can pin its carrier thread, quietly erasing the scalability you adopted Java 21 for. Prefer ReentrantLock in hot paths and check your libraries for synchronized network calls.
- Splitting into microservices before the monolith hurts. Every service boundary adds distributed transactions, API versioning, and observability overhead. Teams regularly pay that tax from day one for a scale problem they do not yet have.
- Calling downstreams without timeouts. Cheap threads tempt you to fan out unbounded calls. One slow dependency with no timeout or backoff will still exhaust your service β it just exhausts connections and memory instead of a thread pool.
- Tuning giant thread pools out of habit. With virtual threads, concurrency is limited at the resource, not the pool: database connection counts and downstream rate limits become the knobs, ideally guarded with semaphores or bulkheads per dependency.
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.