This guide walks you through building a clean, production‑ready Spring Boot REST API powered by Java 21 and Spring Boot 3.5.0. By the end, you’ll understand:
- Why Java 21 LTS & Spring Boot 3.5.0? Leverage the latest JDK language enhancements (such as sealed types and string templates), and Spring’s most up‑to‑date dependency management and features.
- Java Records for DTOs: Eliminate boilerplate code with immutable data carriers, enabling concise, self‑documenting request and response objects.
- Bean Validation (JSR‑380): Automatically enforce input constraints and return precise validation errors, reducing manual checks and improving API robustness.
- Centralized RFC 7807 ProblemDetail Handling: Standardize your error payloads so clients can reliably parse, link to documentation (
typeURIs), and correlate errors usingtraceId. - Automatic OpenAPI Documentation: Expose interactive Swagger UI and comprehensive JSON specs out‑of‑the‑box using
springdoc-openapi, making your API instantly discoverable and self‑documenting.
Whether you’re starting a new microservice or modernizing an existing codebase, this step‑by‑step guide equips you with best‑in‑class practices for 2026 and beyond.
Prerequisites
- Java 21
- Maven 3.6+ (or Gradle alternative)
- IDE (IntelliJ IDEA, VS Code, Eclipse)
- cURL or Postman for testing
Create a New Spring Boot Project
You can bootstrap with https://start.spring.io or manually with Maven:
pom.xml (excerpt):
<project xmlns="http://maven.apache.org/POM/4.0.0" ...>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>rest-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<spring.boot.version>3.5.0</spring.boot.version>
</properties>
<dependencies>
<!-- Spring Web MVC -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Validation (Jakarta Bean Validation) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- OpenAPI / Swagger UI -->
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Lombok (optional) -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<scope>provided</scope>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
Directory Structure
rest-demo/
├── pom.xml
└── src/
└── main/
├── java/
│ └── com.example.restdemo/
│ ├── RestDemoApplication.java
│ ├── controller/
│ │ └── TravelController.java
│ ├── dto/
│ │ ├── TravelSearchRequest.java
│ │ └── TravelOption.java
│ ├── exception/
│ │ └── GlobalExceptionHandler.java
│ └── service/
│ ├── TravelService.java
│ └── TravelServiceImpl.java
└── resources/
└── application.yml
Application Entry Point
package com.example.restdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class RestDemoApplication {
public static void main(String[] args) {
SpringApplication.run(RestDemoApplication.class, args);
}
}
DTOs as Records + Validation + OpenAPI Annotations
// TravelSearchRequest.java
package com.example.restdemo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDate;
@Schema(name = "TravelSearchRequest", description = "Request payload to search travel options")
public record TravelSearchRequest(
@Schema(description = "Starting address", example = "Berlin")
@NotBlank(message = "Address must not be empty")
String address,
@Schema(description = "Travel date (ISO format)", example = "2025-08-05")
@NotNull(message = "Date must not be null")
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE)
LocalDate date
) {}
// TravelOption.java
package com.example.restdemo.dto;
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(name = "TravelOption", description = "Available travel option details")
public record TravelOption(
@Schema(description = "Departure location") String from,
@Schema(description = "Arrival location") String to,
@Schema(description = "Flight number", example = "FL123") String flightNumber,
@Schema(description = "Departure time") String departureTime,
@Schema(description = "Arrival time") String arrivalTime
) {}
- Records with
@Schemaenable automatic model definitions in Swagger UI. LocalDatefields parsed from ISO date strings.
Service Layer (Typed Date)
// TravelService.java
package com.example.restdemo.service;
import com.example.restdemo.dto.TravelOption;
import java.time.LocalDate;
import java.util.List;
public interface TravelService {
List<TravelOption> findOptions(String address, LocalDate date);
}
// TravelServiceImpl.java (dummy implementation)
package com.example.restdemo.service;
import com.example.restdemo.dto.TravelOption;
import org.springframework.stereotype.Service;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;
@Service
public class TravelServiceImpl implements TravelService {
@Override
public List<TravelOption> findOptions(String address, LocalDate date) {
// In a real app, query DB or external API using typed LocalDate
return List.of(
new TravelOption(address, "Destination A", "FL123", LocalTime.now().toString(), LocalTime.now().plusHours(2).toString()),
new TravelOption(address, "Destination B", "FL456", LocalTime.now().toString(), LocalTime.now().plusHours(3).toString())
);
}
}
REST Controller
package com.example.restdemo.controller;
import com.example.restdemo.dto.TravelOption;
import com.example.restdemo.dto.TravelSearchRequest;
import com.example.restdemo.service.TravelService;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/travel")
@Validated
public class TravelController {
private final TravelService travelService;
public TravelController(TravelService travelService) {
this.travelService = travelService;
}
@PostMapping("/search")
public ResponseEntity<List<TravelOption>> search(
@Valid @RequestBody TravelSearchRequest request
) {
var options = travelService.findOptions(request.address(), request.date());
return ResponseEntity.ok(options);
}
@GetMapping("/health")
public ResponseEntity<Void> health() {
return ResponseEntity.noContent().build();
}
}
Centralized Error Handling (Enriched ProblemDetail)
package com.example.restdemo.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.net.URI;
import java.util.stream.Collectors;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY)
@ResponseBody
public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.UNPROCESSABLE_ENTITY);
pd.setType(URI.create("https://example.com/problem/validation-error"));
pd.setTitle("Validation failed");
var errors = ex.getBindingResult().getFieldErrors().stream()
.map(fe -> fe.getField() + ": " + fe.getDefaultMessage())
.collect(Collectors.toList());
pd.setDetail("Invalid request payload");
pd.setProperty("fieldErrors", errors);
// Attach traceId from request if available
var attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
var trace = attrs.getRequest().getHeader("traceId");
pd.setProperty("traceId", trace != null ? trace : "");
}
return pd;
}
@ExceptionHandler(IllegalArgumentException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
@ResponseBody
public ProblemDetail handleBadArgs(IllegalArgumentException ex) {
var pd = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
pd.setType(URI.create("https://example.com/problem/invalid-argument"));
pd.setTitle("Invalid argument");
pd.setDetail(ex.getMessage());
var attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs != null) {
var trace = attrs.getRequest().getHeader("traceId");
pd.setProperty("traceId", trace != null ? trace : "");
}
return pd;
}
}
- Each error now has a
typeURI, afieldErrorsarray, and an optionaltraceId.
Configuration (Optional)
application.yml
server:
port: 8080
spring:
mvc:
pathmatch:
matching-strategy: ant_path_matcher
OpenAPI / Swagger UI
With the springdoc-openapi-starter-webmvc-ui dependency, Spring Boot auto-generates an interactive Swagger UI at /swagger-ui.html and OpenAPI JSON at /v3/api-docs. No further configuration is required; your @Schema annotations on records will appear in the UI.
Run & Test
# build and run
mvn spring-boot:run
# valid request
echo '{"address":"Berlin","date":"2025-08-05"}' \
| curl -X POST -H "Content-Type: application/json" \
-d @- http://localhost:8080/api/travel/search
# invalid request (empty address)
echo '{"address":"","date":"2025-08-05"}' \
| curl -i -X POST -H "Content-Type: application/json" \
-d @- http://localhost:8080/api/travel/search
Expect a 422 Unprocessable Entity with an enriched ProblemDetail JSON.