Ultimate 2026 Guide to Modern Spring Boot REST APIs

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 (type URIs), and correlate errors using traceId.
  • 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 @Schema enable automatic model definitions in Swagger UI.
  • LocalDate fields 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&nbsp;A", "FL123", LocalTime.now().toString(), LocalTime.now().plusHours(2).toString()),
            new TravelOption(address, "Destination&nbsp;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 type URI, a fieldErrors array, and an optional traceId.

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.