LearnAI ToolsCareerPractice BuildsPlayContact
Lesson 3226 min read

Real-World Project: Building a Full REST API

A capstone walkthrough that plans and builds a complete Task Manager REST API, tying together JPA, validation, DTOs, and centralized exception handling.

Introduction

This final lesson is a capstone project that pulls together everything covered across this course - auto-configuration, REST controllers, Spring Data JPA, Bean Validation, DTOs, and centralized exception handling - into one complete, working Task Manager REST API. Rather than introducing new concepts, the goal here is to see how the pieces you already know fit together in a realistic project.

What You Will Build
  • A Task Manager API with full CRUD operations.
  • A JPA entity and Spring Data repository.
  • Request and response DTOs with Bean Validation.
  • A service layer containing the business logic.
  • A REST controller exposing clean, well-structured endpoints.
  • Centralized exception handling with @ControllerAdvice.

Planning the Task Manager API

Before writing code, it helps to sketch the API surface. The Task Manager needs to create, list, retrieve, update, and delete tasks, and each task needs a title, a completion flag, and a due date.

MethodPathPurpose
POST/api/tasksCreate a new task
GET/api/tasksList all tasks
GET/api/tasks/{id}Get a single task
PUT/api/tasks/{id}Update a task
DELETE/api/tasks/{id}Delete a task

Project Setup

The project needs Spring Web, Spring Data JPA, Bean Validation, and a database driver - the same starters used throughout this course.

<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.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>

The Entity and Repository

The Task entity maps directly to a database table, and the repository interface gives it full CRUD support with zero implementation code, courtesy of Spring Data JPA.

@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private boolean done;
private LocalDate dueDate;
// getters and setters
}
public interface TaskRepository extends JpaRepository<Task, Long> {
List<Task> findByDoneFalse();
}

DTOs and Validation

Following the DTO pattern from the previous lesson, the entity is never exposed directly. A request DTO validates incoming data; a response DTO shapes the outgoing JSON.

public record TaskRequest(
@NotBlank(message = "Title is required") String title,
@FutureOrPresent(message = "Due date cannot be in the past") LocalDate dueDate
) {}
public record TaskResponse(Long id, String title, boolean done, LocalDate dueDate) {
public static TaskResponse from(Task task) {
return new TaskResponse(task.getId(), task.getTitle(), task.isDone(), task.getDueDate());
}
}

The Service Layer

The service layer holds the actual business logic and is the only class that touches the repository directly. A custom TaskNotFoundException keeps "task does not exist" a distinct, meaningful case instead of a generic error.

public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(Long id) {
super("Task not found with id " + id);
}
}
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public List<Task> findAll() {
return taskRepository.findAll();
}
public Task findById(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException(id));
}
public Task create(TaskRequest request) {
Task task = new Task();
task.setTitle(request.title());
task.setDueDate(request.dueDate());
task.setDone(false);
return taskRepository.save(task);
}
public Task update(Long id, TaskRequest request) {
Task task = findById(id);
task.setTitle(request.title());
task.setDueDate(request.dueDate());
return taskRepository.save(task);
}
public void delete(Long id) {
Task task = findById(id);
taskRepository.delete(task);
}
}

The Controller

The controller stays thin: it delegates to the service, validates incoming requests with @Valid, and maps entities to response DTOs before returning them.

@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@GetMapping
public List<TaskResponse> getAllTasks() {
return taskService.findAll().stream()
.map(TaskResponse::from)
.toList();
}
@GetMapping("/{id}")
public TaskResponse getTask(@PathVariable Long id) {
return TaskResponse.from(taskService.findById(id));
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public TaskResponse createTask(@Valid @RequestBody TaskRequest request) {
return TaskResponse.from(taskService.create(request));
}
@PutMapping("/{id}")
public TaskResponse updateTask(@PathVariable Long id, @Valid @RequestBody TaskRequest request) {
return TaskResponse.from(taskService.update(id, request));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteTask(@PathVariable Long id) {
taskService.delete(id);
}
}

Centralized Exception Handling

A single @ControllerAdvice class translates both TaskNotFoundException and validation failures into clean, consistent JSON error responses across every endpoint.

public record ApiError(String message, int status) {}
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<ApiError> handleNotFound(TaskNotFoundException ex) {
ApiError error = new ApiError(ex.getMessage(), HttpStatus.NOT_FOUND.value());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidation(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors()
.forEach(error -> errors.put(error.getField(), error.getDefaultMessage()));
return ResponseEntity.badRequest().body(errors);
}
}

Trying It End to End

With every layer in place, here is the full request lifecycle - creating a task, fetching it, and hitting the not-found case.

curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Ship the API", "dueDate": "2026-08-20"}'
Response

Click Run to see what this code prints.

curl http://localhost:8080/api/tasks/99
Response

Click Run to see what this code prints.

curl -X POST http://localhost:8080/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": ""}'
Response

Click Run to see what this code prints.

Where to Go From Here

This Task Manager API is a solid, complete foundation, and it is deliberately small enough to extend on your own. A few natural next steps, drawing on lessons throughout this course, would strengthen it further.

  • Add Spring Security so tasks belong to authenticated users, using the login flow from an earlier lesson.
  • Add pagination to GET /api/tasks with Spring Data's Pageable support once the task list grows large.
  • Add Actuator and wire /actuator/health into a real deployment health check.
  • Package it as an executable jar and containerize it with the Docker steps covered earlier.
  • Write unit tests for TaskService and a MockMvc test for TaskController.

Key Takeaways

  • A well-structured Spring Boot API separates entity, DTO, service, and controller responsibilities cleanly.
  • Bean Validation on request DTOs catches bad input before it ever reaches business logic.
  • A custom exception like TaskNotFoundException keeps meaningful failure cases distinct from generic errors.
  • @RestControllerAdvice centralizes error handling into consistent JSON responses across every endpoint.
  • This same pattern - entity, repository, DTOs, service, controller, exception handler - scales to almost any real Spring Boot API.

Summary

This capstone project brought together auto-configuration, JPA, DTOs, validation, and centralized exception handling into one complete, working API - the same pattern you can now apply to build real Spring Boot applications of your own.

Spring Boot Course Completed!

You have successfully completed all 32 lessons - from auto-configuration and your first REST controller through Spring Data JPA, validation, security, and testing. You now have a genuinely production-ready Spring Boot skill set.

Browse All Courses →