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.
- 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.
| Method | Path | Purpose |
|---|---|---|
| POST | /api/tasks | Create a new task |
| GET | /api/tasks | List 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); }}
@Servicepublic 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) {}
@RestControllerAdvicepublic 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"}'Click Run to see what this code prints.
curl http://localhost:8080/api/tasks/99Click Run to see what this code prints.
curl -X POST http://localhost:8080/api/tasks \ -H "Content-Type: application/json" \ -d '{"title": ""}'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.