Tutorials

Build a Production REST API with Spring Boot 3 and Java 21 (2026)

Step-by-step guide to a complete CRUD REST API with Spring Boot 3, Java 21, PostgreSQL, validation, exception handling, and Docker. No toy examples.

April 20, 202612 min read
Share:
Build a Production REST API with Spring Boot 3 and Java 21 (2026)

Spring Boot 3 with Java 21 is the current gold standard for building production REST APIs on the JVM. Record types, virtual threads, pattern matching — Java 21 is a genuinely modern language, and Spring Boot 3 takes full advantage of it.

This guide builds a complete Task Manager API from scratch: full CRUD, validation, global exception handling, PostgreSQL, and Docker. Everything is real, production-grade code you can use as a starting point.

If you want to add AI capabilities to this API, see Spring Boot + Claude API integration.

What We're Building

A Task Manager REST API with the following endpoints:

MethodEndpointDescription
GET/api/tasksList all tasks (paginated)
GET/api/tasks/{id}Get task by ID
POST/api/tasksCreate task
PUT/api/tasks/{id}Update task
PATCH/api/tasks/{id}/statusUpdate status only
DELETE/api/tasks/{id}Delete task

Project Setup

Go to start.spring.io and generate a project with:

  • Spring Boot: 3.3.x
  • Java: 21
  • Build: Maven
  • Dependencies: Spring Web, Spring Data JPA, PostgreSQL Driver, Validation, Lombok, Spring Boot Actuator

Or add to pom.xml directly:

<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-data-jpa</artifactId>
  </dependency>
  <dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>postgresql</artifactId>
    <scope>runtime</scope>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-validation</artifactId>
  </dependency>
  <dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-actuator</artifactId>
  </dependency>
  <dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
  </dependency>
</dependencies>

Application Configuration

src/main/resources/application.yml:

spring:
  datasource:
    url: jdbc:postgresql://localhost:5432/tasks_db
    username: ${DB_USER:admin}
    password: ${DB_PASSWORD:secret}
    hikari:
      maximum-pool-size: 10
      connection-timeout: 30000
 
  jpa:
    hibernate:
      ddl-auto: validate
    show-sql: false
    properties:
      hibernate:
        format_sql: true
        dialect: org.hibernate.dialect.PostgreSQLDialect
 
  flyway:
    enabled: true
 
server:
  port: 8080
  error:
    include-message: always
 
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics

Database Migration

Create src/main/resources/db/migration/V1__create_tasks_table.sql:

CREATE TYPE task_status AS ENUM ('PENDING', 'IN_PROGRESS', 'DONE', 'CANCELLED');
 
CREATE TABLE tasks (
    id          BIGSERIAL PRIMARY KEY,
    title       VARCHAR(255) NOT NULL,
    description TEXT,
    status      task_status  NOT NULL DEFAULT 'PENDING',
    priority    INT          NOT NULL DEFAULT 0,
    due_date    DATE,
    created_at  TIMESTAMP    NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP    NOT NULL DEFAULT NOW()
);
 
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_created_at ON tasks(created_at DESC);

Using Flyway gives you versioned, reproducible migrations. Each change is a new migration file — never modify existing ones.

The Task Entity

package com.example.tasks.entity;
 
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.CreationTimestamp;
import org.hibernate.annotations.UpdateTimestamp;
 
import java.time.LocalDate;
import java.time.LocalDateTime;
 
@Entity
@Table(name = "tasks")
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Task {
 
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
 
    @Column(nullable = false, length = 255)
    private String title;
 
    @Column(columnDefinition = "TEXT")
    private String description;
 
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    @Builder.Default
    private TaskStatus status = TaskStatus.PENDING;
 
    @Column(nullable = false)
    @Builder.Default
    private Integer priority = 0;
 
    private LocalDate dueDate;
 
    @CreationTimestamp
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;
 
    @UpdateTimestamp
    @Column(nullable = false)
    private LocalDateTime updatedAt;
}
package com.example.tasks.entity;
 
public enum TaskStatus {
    PENDING, IN_PROGRESS, DONE, CANCELLED
}

DTOs

Use separate request and response DTOs — never expose entities directly. This protects against mass assignment and gives you full control over what your API contract looks like.

package com.example.tasks.dto;
 
import com.example.tasks.entity.TaskStatus;
import jakarta.validation.constraints.*;
import lombok.Data;
 
import java.time.LocalDate;
 
@Data
public class TaskRequest {
 
    @NotBlank(message = "Title is required")
    @Size(min = 1, max = 255, message = "Title must be between 1 and 255 characters")
    private String title;
 
    private String description;
 
    private TaskStatus status;
 
    @Min(value = 0, message = "Priority must be 0 or higher")
    @Max(value = 10, message = "Priority cannot exceed 10")
    private Integer priority;
 
    @Future(message = "Due date must be in the future")
    private LocalDate dueDate;
}
package com.example.tasks.dto;
 
import com.example.tasks.entity.TaskStatus;
import lombok.Builder;
import lombok.Data;
 
import java.time.LocalDate;
import java.time.LocalDateTime;
 
@Data
@Builder
public class TaskResponse {
    private Long id;
    private String title;
    private String description;
    private TaskStatus status;
    private Integer priority;
    private LocalDate dueDate;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;
}

Repository

package com.example.tasks.repository;
 
import com.example.tasks.entity.Task;
import com.example.tasks.entity.TaskStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
 
import java.util.List;
 
public interface TaskRepository extends JpaRepository<Task, Long> {
 
    Page<Task> findAllByOrderByCreatedAtDesc(Pageable pageable);
 
    List<Task> findByStatusOrderByPriorityDesc(TaskStatus status);
 
    @Query("SELECT t FROM Task t WHERE " +
           "(:status IS NULL OR t.status = :status) AND " +
           "(:priority IS NULL OR t.priority >= :priority)")
    Page<Task> findWithFilters(
        @Param("status") TaskStatus status,
        @Param("priority") Integer priority,
        Pageable pageable
    );
}

Service Layer

The service is where business logic lives. Controllers are thin — they delegate to services.

package com.example.tasks.service;
 
import com.example.tasks.dto.TaskRequest;
import com.example.tasks.dto.TaskResponse;
import com.example.tasks.entity.Task;
import com.example.tasks.entity.TaskStatus;
import com.example.tasks.exception.ResourceNotFoundException;
import com.example.tasks.repository.TaskRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
 
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class TaskService {
 
    private final TaskRepository repository;
 
    public Page<TaskResponse> findAll(TaskStatus status, Integer minPriority, Pageable pageable) {
        return repository.findWithFilters(status, minPriority, pageable)
                .map(this::toResponse);
    }
 
    public TaskResponse findById(Long id) {
        return repository.findById(id)
                .map(this::toResponse)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
    }
 
    @Transactional
    public TaskResponse create(TaskRequest request) {
        Task task = Task.builder()
                .title(request.getTitle())
                .description(request.getDescription())
                .status(request.getStatus() != null ? request.getStatus() : TaskStatus.PENDING)
                .priority(request.getPriority() != null ? request.getPriority() : 0)
                .dueDate(request.getDueDate())
                .build();
        return toResponse(repository.save(task));
    }
 
    @Transactional
    public TaskResponse update(Long id, TaskRequest request) {
        Task task = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
 
        task.setTitle(request.getTitle());
        task.setDescription(request.getDescription());
        if (request.getStatus() != null) task.setStatus(request.getStatus());
        if (request.getPriority() != null) task.setPriority(request.getPriority());
        task.setDueDate(request.getDueDate());
 
        return toResponse(repository.save(task));
    }
 
    @Transactional
    public TaskResponse updateStatus(Long id, TaskStatus status) {
        Task task = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
        task.setStatus(status);
        return toResponse(repository.save(task));
    }
 
    @Transactional
    public void delete(Long id) {
        if (!repository.existsById(id)) {
            throw new ResourceNotFoundException("Task not found with id: " + id);
        }
        repository.deleteById(id);
    }
 
    private TaskResponse toResponse(Task task) {
        return TaskResponse.builder()
                .id(task.getId())
                .title(task.getTitle())
                .description(task.getDescription())
                .status(task.getStatus())
                .priority(task.getPriority())
                .dueDate(task.getDueDate())
                .createdAt(task.getCreatedAt())
                .updatedAt(task.getUpdatedAt())
                .build();
    }
}

REST Controller

package com.example.tasks.controller;
 
import com.example.tasks.dto.TaskRequest;
import com.example.tasks.dto.TaskResponse;
import com.example.tasks.entity.TaskStatus;
import com.example.tasks.service.TaskService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
 
@RestController
@RequestMapping("/api/tasks")
@RequiredArgsConstructor
public class TaskController {
 
    private final TaskService service;
 
    @GetMapping
    public Page<TaskResponse> findAll(
            @RequestParam(required = false) TaskStatus status,
            @RequestParam(required = false) Integer minPriority,
            @PageableDefault(size = 20, sort = "createdAt") Pageable pageable) {
        return service.findAll(status, minPriority, pageable);
    }
 
    @GetMapping("/{id}")
    public TaskResponse findById(@PathVariable Long id) {
        return service.findById(id);
    }
 
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public TaskResponse create(@Valid @RequestBody TaskRequest request) {
        return service.create(request);
    }
 
    @PutMapping("/{id}")
    public TaskResponse update(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
        return service.update(id, request);
    }
 
    @PatchMapping("/{id}/status")
    public TaskResponse updateStatus(@PathVariable Long id, @RequestParam TaskStatus status) {
        return service.updateStatus(id, status);
    }
 
    @DeleteMapping("/{id}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable Long id) {
        service.delete(id);
    }
}

Exception Handling

A global exception handler keeps controllers clean and ensures consistent error responses.

package com.example.tasks.exception;
 
public class ResourceNotFoundException extends RuntimeException {
    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.tasks.exception;
 
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
 
import java.util.Map;
import java.util.stream.Collectors;
 
@RestControllerAdvice
public class GlobalExceptionHandler {
 
    @ExceptionHandler(ResourceNotFoundException.class)
    public ProblemDetail handleNotFound(ResourceNotFoundException ex) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
        detail.setTitle("Resource Not Found");
        return detail;
    }
 
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        Map<String, String> errors = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .collect(Collectors.toMap(
                        FieldError::getField,
                        f -> f.getDefaultMessage() != null ? f.getDefaultMessage() : "Invalid value",
                        (a, b) -> a
                ));
 
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Validation failed");
        detail.setTitle("Validation Error");
        detail.setProperty("errors", errors);
        return detail;
    }
 
    @ExceptionHandler(Exception.class)
    public ProblemDetail handleGeneral(Exception ex) {
        ProblemDetail detail = ProblemDetail.forStatusAndDetail(
                HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred");
        detail.setTitle("Internal Server Error");
        return detail;
    }
}

Spring Boot 3 uses ProblemDetail (RFC 7807) for error responses — structured, consistent, and standards-compliant.

Docker Setup

docker-compose.yml:

version: "3.8"
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: tasks_db
      POSTGRES_USER: admin
      POSTGRES_PASSWORD: secret
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U admin -d tasks_db"]
      interval: 10s
      timeout: 5s
      retries: 5
 
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DB_USER: admin
      DB_PASSWORD: secret
      SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/tasks_db
    depends_on:
      postgres:
        condition: service_healthy
 
volumes:
  postgres_data:

Dockerfile:

FROM eclipse-temurin:21-jre-alpine AS runtime
 
WORKDIR /app
 
COPY target/*.jar app.jar
 
EXPOSE 8080
 
ENTRYPOINT ["java", "-jar", "app.jar"]

Multi-stage build for production:

FROM maven:3.9-eclipse-temurin-21-alpine AS build
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline -q
COPY src ./src
RUN mvn package -DskipTests -q
 
FROM eclipse-temurin:21-jre-alpine AS runtime
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

Run it:

docker compose up -d

Testing

package com.example.tasks.controller;
 
import com.example.tasks.dto.TaskRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.http.MediaType;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.transaction.annotation.Transactional;
 
import static org.hamcrest.Matchers.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
 
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles("test")
@Transactional
class TaskControllerTest {
 
    @Autowired MockMvc mockMvc;
    @Autowired ObjectMapper objectMapper;
 
    @Test
    void createTask_validRequest_returns201() throws Exception {
        var request = new TaskRequest();
        request.setTitle("Write unit tests");
        request.setDescription("Cover all service methods");
 
        mockMvc.perform(post("/api/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andExpect(jsonPath("$.id", notNullValue()))
                .andExpect(jsonPath("$.title", is("Write unit tests")))
                .andExpect(jsonPath("$.status", is("PENDING")));
    }
 
    @Test
    void createTask_missingTitle_returns400() throws Exception {
        var request = new TaskRequest(); // no title
 
        mockMvc.perform(post("/api/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isBadRequest())
                .andExpect(jsonPath("$.errors.title", containsString("required")));
    }
 
    @Test
    void findById_notFound_returns404() throws Exception {
        mockMvc.perform(get("/api/tasks/99999"))
                .andExpect(status().isNotFound());
    }
 
    @Test
    void updateStatus_validTransition_returns200() throws Exception {
        // Create first
        var request = new TaskRequest();
        request.setTitle("Test task");
 
        var result = mockMvc.perform(post("/api/tasks")
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
                .andExpect(status().isCreated())
                .andReturn();
 
        var id = objectMapper.readTree(result.getResponse().getContentAsString()).get("id").asLong();
 
        // Then update status
        mockMvc.perform(patch("/api/tasks/{id}/status", id)
                .param("status", "IN_PROGRESS"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.status", is("IN_PROGRESS")));
    }
}

Add src/test/resources/application-test.yml:

spring:
  datasource:
    url: jdbc:h2:mem:testdb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
    driver-class-name: org.h2.Driver
  jpa:
    hibernate:
      ddl-auto: create-drop
  flyway:
    enabled: false

Add H2 to test dependencies:

<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>test</scope>
</dependency>

Virtual Threads (Java 21)

Enable virtual threads for better throughput under I/O load — zero code changes required:

spring:
  threads:
    virtual:
      enabled: true

With virtual threads, blocking I/O operations (database calls, HTTP requests) no longer pin platform threads. Your API handles significantly more concurrent connections with the same hardware.

Production Checklist

Before shipping to production:

  • Environment variables for all secrets (never hardcode credentials)
  • Database connection pool sized for expected load (Hikari default is fine for most apps)
  • Actuator endpoints secured (don't expose /actuator/env publicly)
  • Flyway migrations tested on a staging database first
  • Rate limiting (Spring Security or a gateway layer)
  • Structured logging (Logback JSON encoder for log aggregation)
  • Health check endpoint for container orchestration
  • Graceful shutdown configured
server:
  shutdown: graceful
 
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s

Extending with AI

Once your REST API is running, adding Claude AI capabilities is straightforward. Check out the complete Spring Boot + Claude API guide which shows how to add AI features like natural language task creation, smart prioritization, and automated summaries to this exact architecture.

For self-hosting your backend infrastructure, the n8n self-host guide covers setting up a $6/month VPS with Docker that works for any Spring Boot application.

Conclusion

This is a complete, production-grade REST API foundation: proper layering, validation, error handling, database migrations, Docker setup, and tests.

The patterns here — thin controllers, service layer transactions, global exception handlers, ProblemDetail error responses — scale from simple task managers to large microservice architectures without changes.

Spring Boot 3 with Java 21 is fast, stable, and well-supported. Virtual threads and modern Java features make it genuinely competitive with Node.js and Go for I/O-heavy API workloads. If your team knows Java, there's no reason to rewrite in something else.

Build on this foundation, add your business logic, and ship.

#spring-boot#java#rest-api#postgresql#docker#tutorials
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.