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:
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/tasks | List all tasks (paginated) |
| GET | /api/tasks/{id} | Get task by ID |
| POST | /api/tasks | Create task |
| PUT | /api/tasks/{id} | Update task |
| PATCH | /api/tasks/{id}/status | Update 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,metricsDatabase 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 -dTesting
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: falseAdd 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: trueWith 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/envpublicly) - 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: 30sExtending 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.