Building REST APIs with Spring Boot 3 and Java 21: A Practical Guide

Spring Boot 3 and Java 21 represent a major leap forward for backend development. With virtual threads, record patterns, and improved observability, building REST APIs has never been more productive. In this guide, we’ll walk through creating a production-ready REST API from scratch.

Project Setup

Start with Spring Initializr or your preferred build tool. Here’s a minimal Maven configuration:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.0</version>
</parent>

<properties>
<java.version>21</java.version>
</properties>

<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
</dependencies>

Leveraging Java 21 Records for DTOs

One of the most practical uses of Java 21 records is for DTOs. They eliminate boilerplate while providing immutability out of the box:

1
2
3
4
5
6
7
8
9
10
11
12
public record CreateUserRequest(
@NotBlank String username,
@Email String email,
@Size(min = 8) String password
) {}

public record UserResponse(
Long id,
String username,
String email,
Instant createdAt
) {}

Virtual Threads for Better Throughput

Java 21 introduces virtual threads, which are lightweight threads that dramatically improve concurrency. Enable them in Spring Boot 3 with a single property:

1
2
3
4
spring:
threads:
virtual:
enabled: true

This single change can improve throughput by 2-5x for I/O-bound applications without any code changes. Under the hood, Spring Boot automatically wraps each request in a virtual thread instead of a platform thread.

Building the Controller

With records and virtual threads in place, your controller becomes clean and focused:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@RestController
@RequestMapping("/api/users")
public class UserController {

private final UserService userService;

@PostMapping
public ResponseEntity<UserResponse> createUser(
@Valid @RequestBody CreateUserRequest request) {
UserResponse user = userService.createUser(request);
return ResponseEntity.status(201).body(user);
}

@GetMapping("/{id}")
public ResponseEntity<UserResponse> getUser(@PathVariable Long id) {
return ResponseEntity.of(userService.findById(id));
}
}

Error Handling with Problem Details

Spring Boot 3 supports RFC 9457 Problem Details for standardized error responses:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@ControllerAdvice
public class GlobalExceptionHandler {

@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
var problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
problem.setTitle("Validation Error");
problem.setDetail("The request contains invalid fields");

var errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.toList();
problem.setProperty("errors", errors);

return problem;
}
}

Observability with Micrometer

Spring Boot 3 includes Micrometer Tracing out of the box. Enable it with:

1
2
3
4
5
6
7
8
management:
tracing:
sampling:
probability: 1.0
endpoints:
web:
exposure:
include: health,metrics,prometheus

Add the dependency:

1
2
3
4
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>

Now every request is automatically traced with distributed tracing, and you can visualize request flows in Grafana or Zipkin.

Testing the API

Spring Boot 3 makes testing straightforward:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@WebMvcTest(UserController.class)
class UserControllerTest {

@Autowired
private MockMvc mockMvc;

@Test
void shouldCreateUser() throws Exception {
var request = """
{
"username": "johndoe",
"email": "john@example.com",
"password": "securePass123"
}
""";

mockMvc.perform(post("/api/users")
.contentType(MediaType.APPLICATION_JSON)
.content(request))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.username").value("johndoe"));
}
}

Deployment with Docker

A production-ready Dockerfile is surprisingly minimal:

1
2
3
4
5
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Build the image with:

1
2
docker build -t my-api:latest .
docker run -p 8080:8080 my-api:latest

Key Takeaways