Build a Task Tracker REST API
Build a CRUD REST API with Spring Boot using layered architecture and validation.
Introduction
30 Second Summary
Every app you use daily relies on a backend API to create, store, and retrieve data. When you check your tasks, update a status, or delete a completed item, a REST API is handling those requests behind the scenes.
In this project, you will build a Task Tracker REST API using Spring Boot that can create, list, update, and delete tasks. You'll structure it with production-grade patterns that hiring managers look for in Java backend roles.
What You'll Build
You'll fire up curl commands and watch your API respond with clean JSON. Create a task, update its status from TODO to DONE, send bad data, and see a structured error message explain exactly what went wrong.
By the end of this project, you'll have:
- A running REST API where you can create tasks with a title, priority, and description, then retrieve them all with a single GET request.
- Bean Validation that catches bad input and returns structured JSON error messages instead of ugly stack traces.
- A layered architecture (Controller, Service, Repository, Domain) that separates concerns the way production Spring Boot apps do.
- Secret Mission: Add filtering to your GET endpoint so you can retrieve tasks by status or priority.
Are there any prerequisites?
You need basic Java knowledge (classes, interfaces, annotations). No prior Spring experience is required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Spring Boot Project
Every Spring Boot application starts with a skeleton project that wires together the right dependencies. Without these dependencies, your code has no web server, no database layer, and no validation support.
In this step you will generate a pre-configured project from Spring Initializr, open it in VS Code, and confirm the application starts successfully.
In this step, get ready to:
- Generate a Spring Boot project from Spring Initializr with the correct dependencies.
- Open the project in VS Code and explore the generated structure.
- Configure the H2 database and run the application.
Generate your project from Spring Initializr
Spring Initializr is a web tool that generates a ready-to-run Spring Boot project with all the boilerplate files in place. You pick your dependencies on a form, and it hands you a ZIP file containing the entire project structure.
- Open your browser and navigate to https://start.spring.io.
- Set the following project metadata:
- Project: Maven.
- Language: Java.
- Spring Boot: 4.1.0.
- Group: com.example.
- Artifact: tasktracker.
- Packaging: Jar.
- Java: 17.
- Click ADD DEPENDENCIES and add each of the following, one at a time:
- Spring Web.
- Spring Data JPA.
- H2 Database.
- Validation.
- Click GENERATE to download the ZIP file.
- Locate the downloaded tasktracker.zip file and unzip it to your Desktop.
What is Spring Initializr?
Spring Initializr is the official project generator for Spring Boot applications. It creates a complete Maven (or Gradle) project including a pom.xml, a main application class, a Maven Wrapper, and the folder structure you need.
The Maven Wrapper (mvnw) is a script bundled with the project that downloads and runs the correct Maven version automatically. This means you do not need to install Maven separately.
Open the project in VS Code
- Switch back to VS Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select the unzipped tasktracker folder on your Desktop, then click Open.
You should see the project structure in the Explorer sidebar, including pom.xml, mvnw, and the src directory.
What did Spring Initializr generate?
- pom.xml lists your project dependencies (Spring Web, Spring Data JPA, H2, Validation).
- src/main/java/com/example/tasktracker/TaskTrackerApplication.java is the entry point annotated with @SpringBootApplication.
- src/main/resources/application.properties is where you configure database connections, server settings, and more.
- mvnw (macOS/Linux) and mvnw.cmd (Windows) are the Maven Wrapper scripts that run builds without installing Maven.
Configure the H2 database and run the application
The generated application.properties file contains only the app name. You need to tell Spring Boot where to store data and how to set up the database.
- In the Explorer sidebar, expand src > main > resources.
- Click application.properties to open it.
- Replace the entire contents of the file with the following configuration:
spring.application.name=tasktracker
spring.datasource.url=jdbc:h2:mem:taskdb;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
What does this configuration do?
- spring.datasource.url points to an in-memory H2 database called taskdb. No external database server needed.
- spring.jpa.hibernate.ddl-auto=update tells Hibernate to automatically create or update database tables based on your Java entities.
- spring.jpa.show-sql=true prints every SQL statement to the terminal so you can see exactly what the database is doing.
- spring.h2.console.enabled=true enables a browser-based database viewer at /h2-console so you can inspect your data.
- Save application.properties.
✔️ Awesome, I've got everything!
Great. Make sure you have saved application.properties with all eight lines above.
ⓧ I'd like to double check the full code
Your application.properties should look exactly like this:
spring.application.name=tasktracker
spring.datasource.url=jdbc:h2:mem:taskdb;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
Now it is time to start the application. Open the integrated terminal in VS Code by pressing Ctrl+` (macOS and Windows).
Before you run the next command, what do you think will happen? The application has no endpoints or entities yet.
- Start the application by running this command:
macOS
./mvnw spring-boot:run
Windows
mvnw.cmd spring-boot:run
What does this command do?
The Maven Wrapper (mvnw) downloads the correct Maven version if needed, compiles your code, and launches the embedded Tomcat server. You do not need Maven installed on your machine.
After a few seconds of downloading dependencies and compiling, you should see a line in the terminal that includes Started TaskTrackerApplication and a message showing Tomcat is running on port 8080.
Don't see the application start?
- If you see a permission denied error on macOS, make the wrapper executable by running chmod +x mvnw and try again.
- If you see a port already in use error, another application is using port 8080. Close it or stop that process, then re-run the command.
- If Maven downloads fail, check your internet connection. The first run downloads all dependencies and may take a minute or two.
Help me troubleshoot my Spring Boot application startup
Your Spring Boot skeleton is alive and running. Next up, you will create your first entity and get data flowing into the H2 database.
Create the Domain Model and Get Data Flowing
Your Spring Boot app starts and serves on port 8080, but it has nothing to store or manage yet. Without a domain model, there is no data to persist and nothing for future API endpoints to operate on.
In this step, you will define a Task entity with JPA annotations so that Hibernate auto-creates your database table. You will also create a Spring Data JPA repository that gives you full CRUD operations for free.
In this step, get ready to:
- Create enums for task priority and status.
- Build a Task entity class with JPA annotations that map to a database table.
- Create a TaskRepository interface that auto-implements CRUD operations.
Create the Task entity class with JPA annotations
A JPA entity is a plain Java class annotated so that Hibernate knows how to map it to a database table. Your Task entity needs supporting enums for Priority and Status first.
- In the VS Code Explorer sidebar, right-click the src/main/java/com/example/tasktracker folder.
- Create a new folder called domain.
- Inside domain, create a file called Priority.java and paste the following code:
package com.example.tasktracker.domain;
public enum Priority {
LOW, MEDIUM, HIGH
}
- In the same domain folder, create a file called Status.java and paste the following code:
package com.example.tasktracker.domain;
public enum Status {
TODO, IN_PROGRESS, DONE
}
What do these enums do?
These enums define the allowed values for a task's priority and status. Using enums instead of plain strings means the compiler catches typos for you, and the database stores them as readable text (thanks to @Enumerated(EnumType.STRING) which you will add next).
- Save both files.
- Now create a file called Task.java in the same domain folder and paste the following code:
package com.example.tasktracker.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.time.LocalDateTime;
@Entity
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private String description;
@Enumerated(EnumType.STRING)
private Priority priority;
@Enumerated(EnumType.STRING)
private Status status;
private LocalDateTime createdAt;
}
What do these annotations do?
- @Entity tells Hibernate this class maps to a database table called TASK.
- @Id marks id as the primary key.
- @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment the ID for each new row.
- @Enumerated(EnumType.STRING) stores enum values as readable text (HIGH, TODO) instead of numbers.
- Save Task.java.
- Run the application to confirm Hibernate creates the table by running this in your terminal:
./mvnw spring-boot:run
In the terminal output, look for Hibernate SQL that begins with create table task. This confirms Hibernate scanned your @Entity and created the database table from your field definitions.
Don't see the create table SQL?
- Make sure spring.jpa.show-sql=true is in your application.properties.
- Confirm Task.java is inside com.example.tasktracker.domain (the package must be under the main application's package for Spring Boot to scan it).
- Check that the class has @Entity at the top. Without it, Hibernate ignores the class entirely.
Still stuck? Why isn't Hibernate creating my TASK table?
- Stop the application with Ctrl+C in the terminal.
- Back in Task.java, find the closing brace } of the class.
- Add the constructor and getter/setter methods above that closing brace by pasting the following code:
public Task() {
this.status = Status.TODO;
this.createdAt = LocalDateTime.now();
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Priority getPriority() { return priority; }
public void setPriority(Priority priority) { this.priority = priority; }
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
What does the constructor do?
The no-argument constructor sets sensible defaults: every new task starts with status TODO and records the current time as createdAt. JPA requires a no-arg constructor to instantiate entities when loading them from the database.
The getters and setters give the service layer (which you will build next step) a way to read and write each field.
- Save Task.java.
✔️ Awesome, I've got everything!
Great. Confirm you have saved all three files: Priority.java, Status.java, and Task.java.
ⓧ I'd like to double check the full code
Your Task.java file exceeds 30 lines, so confirm the following sections are present in order:
- Package declaration and all 8 imports (jakarta.persistence.* and java.time.LocalDateTime).
- @Entity annotation above public class Task {.
- Six fields: id, title, description, priority, status, createdAt with their annotations.
- No-arg constructor setting status = Status.TODO and createdAt = LocalDateTime.now().
- Getter and setter pairs for all six fields.
Create the TaskRepository interface
Spring Data JPA lets you declare a repository interface and get full CRUD operations without writing a single query. The framework reads the interface's generic types and generates the implementation at startup.
- In the VS Code Explorer sidebar, right-click src/main/java/com/example/tasktracker and create a new folder called repository.
- Inside repository, create a file called TaskRepository.java and paste the following code:
package com.example.tasktracker.repository;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.domain.Priority;
import com.example.tasktracker.domain.Status;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
What does this interface give you?
By extending JpaRepository<Task, Long>, you inherit methods like findAll(), findById(), save(), and delete() without writing any SQL.
The Long type parameter tells Spring that the Task entity's primary key is a Long. Spring Data generates the full implementation at runtime.
- Save TaskRepository.java.
✔️ Awesome, I've got everything!
Great. You now have the full domain layer in place.
ⓧ I'd like to double check the full code
package com.example.tasktracker.repository;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.domain.Priority;
import com.example.tasktracker.domain.Status;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
Run the app and verify the TASK table in H2
Before you run, what do you expect to see in the H2 database? Think about what Hibernate does with an @Entity class when ddl-auto=update is set.
- Start the application by running:
./mvnw spring-boot:run
- Wait for the Started TaskTrackerApplication message in the terminal.
- Open your browser and navigate to http://localhost:8080/h2-console.
- In the JDBC URL field, enter jdbc:h2:mem:taskdb.
- Leave User Name as sa and Password blank.
- Click Connect.
- In the SQL input area, run this query:
SELECT * FROM TASK;
You should see an empty result set with columns matching your entity fields: ID, TITLE, DESCRIPTION, PRIORITY, STATUS, and CREATED_AT. The table exists because Hibernate's auto-DDL read your @Entity class and created it automatically.
Seeing "Table TASK not found"?
- Double-check the JDBC URL is exactly jdbc:h2:mem:taskdb. A typo connects you to a different (empty) in-memory database.
- Make sure Task.java has @Entity at the class level. Without it, Hibernate does not create the table.
- Confirm the application is still running in the terminal. If you stopped it, H2's in-memory database is gone.
Need more help? Why can't I see the TASK table in the H2 console?
Your domain model is wired and data is flowing into H2. Next up, you will build a REST controller so you can create and list tasks over HTTP.
Build the REST Controller and Hit Your First Endpoint
Your domain model is in place and the H2 database is ready to store tasks. But right now, there is no way to interact with your application over HTTP.
In this step, you will build a service layer and a REST controller that expose create and list endpoints. By the end, you will be able to send curl commands and see tasks flowing through your API.
In this step, get ready to:
- Create a service layer that sits between the controller and repository.
- Create a REST controller with POST and GET endpoints.
- Test both endpoints with curl and see your first saved task.
Create the TaskService class
In a layered architecture, the controller should never talk directly to the repository. A service class sits in between, holding your business logic and keeping each layer focused on one job.
- In VS Code, create a new folder called service inside src/main/java/com/example/tasktracker/.
- Create a new file called TaskService.java inside the service folder with the following code:
package com.example.tasktracker.service;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
// Marks this class as a Spring-managed service bean
@Service
public class TaskService {
// Spring injects the TaskRepository automatically
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
// Saves a new task to the database
public Task createTask(Task task) {
return taskRepository.save(task);
}
// Returns every task in the database
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
What does this code do?
@Service tells Spring Boot to create and manage an instance of this class automatically. When the controller needs a TaskService, Spring hands it one that already has a TaskRepository wired in.
The two methods delegate directly to the repository for now. In a real application, this is where validation rules, permission checks, and business logic would live.
- Save TaskService.java.
Why a separate service layer?
The controller handles HTTP concerns (status codes, request bodies). The repository handles database concerns (queries, persistence). The service owns the business rules that connect them.
Keeping these responsibilities separate means you can swap your database or change your HTTP contract without rewriting your business logic.
Create the TaskController class
The controller is the front door of your API. It maps HTTP requests to service methods and decides what status code to return.
- Create a new folder called controller inside src/main/java/com/example/tasktracker/.
- Create a new file called TaskController.java inside the controller folder.
- Add the class shell with imports and constructor by pasting this code:
package com.example.tasktracker.controller;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.service.TaskService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
// Combines @Controller + @ResponseBody: every method returns JSON
@RestController
// All endpoints in this class start with /api/tasks
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
}
What do these annotations do?
- @RestController tells Spring this class handles HTTP requests and returns JSON responses (not HTML templates).
- @RequestMapping("/api/tasks") sets the base URL path for every endpoint in this controller.
- The constructor accepts a TaskService parameter. Spring sees there is only one constructor and injects the service automatically.
- Now add the POST and GET endpoint methods inside the class, just before the closing }:
// Handles POST /api/tasks - creates a new task
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task createTask(@RequestBody Task task) {
return taskService.createTask(task);
}
// Handles GET /api/tasks - returns all tasks
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
What do these endpoints do?
- @PostMapping maps HTTP POST requests to the createTask method. @RequestBody tells Spring to parse the incoming JSON into a Task object.
- @ResponseStatus(HttpStatus.CREATED) changes the response from the default 200 OK to 201 Created, which is the correct status for resource creation.
- @GetMapping maps HTTP GET requests to getAllTasks, which returns the full list as JSON.
- Save TaskController.java.
- Switch back to the terminal where your app is running and press Ctrl+C to stop it.
- Restart the application by running:
./mvnw spring-boot:run
- Confirm you see Started TaskTrackerApplication in the terminal output with no errors.
Seeing a compilation error?
- Check that the service and controller folders are inside src/main/java/com/example/tasktracker/. If they are in the wrong location, Spring cannot find them.
- Verify the package declaration at the top of each file matches the folder path exactly.
- Make sure TaskController.java imports com.example.tasktracker.service.TaskService and TaskService.java imports com.example.tasktracker.repository.TaskRepository.
Help me debug this Spring Boot compilation error
Test both endpoints with curl
Your API is live. Time to prove it works by sending real HTTP requests.
- Open a second terminal in VS Code (click the + icon in the terminal panel).
Before you run the next command, what do you think will come back? A 200 OK, a 201 Created, or an error?
- Create your first task by running this command:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","priority":"HIGH"}'
You should see a 201 Created response with a JSON body that includes a generated id, the title you sent, a status of TODO (set by the constructor default), and a createdAt timestamp.
- Now list all tasks by running:
curl http://localhost:8080/api/tasks
You should see a JSON array containing the task you just created.
Getting connection refused?
- Make sure the app is still running in the other terminal. Look for Started TaskTrackerApplication in its output.
- Confirm you are using port 8080 in the URL. If another process is using that port, the app would have failed to start.
- On Windows, if curl is not recognized, use PowerShell's Invoke-WebRequest or install curl from the official site.
Help me debug why curl cannot connect to my Spring Boot app
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before moving on.
ⓧ I'd like to double check the full code
Here is the complete TaskService.java:
package com.example.tasktracker.service;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Task createTask(Task task) {
return taskRepository.save(task);
}
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
For TaskController.java, scroll up and confirm these pieces are present in order: the package and import block, the @RestController and @RequestMapping annotations, the constructor, the createTask method with @PostMapping, and the getAllTasks method with @GetMapping.
You just brought your API to life. Next up, you will add validation to reject bad data and see what happens when Spring handles those errors by default.
Add Validation and Watch It Break
Your API currently accepts anything. A blank title, a missing priority, no description at all. It happily saves every request without complaint.
In production, bad data is inevitable. Clients send incomplete forms, automated scripts skip fields, and typos happen. You need validation rules that reject bad input before it reaches your database. In this step, you will add those rules and see what Spring Boot does with invalid requests by default.
In this step, get ready to:
- Create a DTO with validation constraints that define what a valid task request looks like.
- Wire the DTO into your controller and service so every incoming request is validated.
- Send invalid data and observe Spring Boot's default error response format.
Create the CreateTaskRequest DTO
A DTO (Data Transfer Object) is a class whose only job is to carry data between layers. By separating your API contract from your domain entity, you can validate incoming requests independently of your database model.
- In VS Code, create a new folder called dto inside src/main/java/com/example/tasktracker/.
- Create a new file called CreateTaskRequest.java inside the dto folder, then paste in the following code:
package com.example.tasktracker.dto;
import com.example.tasktracker.domain.Priority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class CreateTaskRequest {
// Title cannot be blank (null or empty)
@NotBlank(message = "Title is required")
private String title;
// Description is optional but capped at 500 chars
@Size(max = 500, message = "Description must be 500 characters or fewer")
private String description;
// Priority must be provided
@NotNull(message = "Priority is required")
private Priority priority;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Priority getPriority() { return priority; }
public void setPriority(Priority priority) { this.priority = priority; }
}
What does this code do?
Each annotation defines a constraint that Spring checks automatically before your code runs:
- @NotBlank on title rejects null, empty strings, and whitespace-only strings.
- @Size(max = 500) on description allows null (it is optional) but caps the length if provided.
- @NotNull on priority requires a value but does not check emptiness (enums are either present or null).
The message attribute on each annotation defines the error text returned when validation fails.
- Save CreateTaskRequest.java.
Why a DTO instead of validating the Task entity directly?
Your Task entity has fields the client should never set (like id, status, and createdAt). A DTO exposes only the fields the client controls. If you later add internal fields to Task, your API contract stays unchanged.
Wire the DTO into your service and controller
The DTO exists, but nothing uses it yet. You need to update TaskService to accept a CreateTaskRequest and map its fields onto a new Task entity. Then you need to update TaskController to validate the incoming DTO with @Valid.
- Open TaskService.java and replace the entire file contents with the following code:
package com.example.tasktracker.service;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.dto.CreateTaskRequest;
import com.example.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
// Accept the DTO, map fields to a new Task entity
public Task createTask(CreateTaskRequest request) {
Task task = new Task();
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
task.setPriority(request.getPriority());
return taskRepository.save(task);
}
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
What changed in the service?
The createTask method now accepts a CreateTaskRequest instead of a raw Task. It creates a fresh Task entity and maps only the fields the client is allowed to set. The constructor in Task already sets status to TODO and createdAt to the current time automatically.
- Save TaskService.java.
- Open TaskController.java and replace the entire file contents with the following code:
package com.example.tasktracker.controller;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.dto.CreateTaskRequest;
import com.example.tasktracker.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
// @Valid triggers Bean Validation on the request body
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task createTask(@Valid @RequestBody CreateTaskRequest request) {
return taskService.createTask(request);
}
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
}
What does @Valid do?
The @Valid annotation on the method parameter tells Spring to run Bean Validation before calling your method. If any constraint fails, Spring throws a MethodArgumentNotValidException and your method never executes. The request never reaches the service or database.
- Save TaskController.java.
- Stop the running application with Ctrl+C in the terminal, then restart it by running:
./mvnw spring-boot:run
- Wait until you see "Started TaskTrackerApplication" in the terminal output.
Compilation error on startup?
- Check that your TaskService.createTask method accepts CreateTaskRequest (not Task) and that TaskController passes request (not task) to the service.
- Verify the import import com.example.tasktracker.dto.CreateTaskRequest; is present in both files.
- Make sure CreateTaskRequest.java is in the dto folder with the correct package com.example.tasktracker.dto; declaration.
Still stuck? Help me debug a compilation error in my Spring Boot project after adding the CreateTaskRequest DTO.
Send invalid requests and observe the default error response
Validation is now active. Before you test, think about what should happen when you send a task with a blank title.
- What do you expect the response to look like when you POST a task with an empty title? Will it succeed or fail? What HTTP status code would make sense for invalid input?
- Open a new terminal and send an invalid request by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","priority":"HIGH"}'
You get a 400 status back. Validation is working. But look at the response body.
Spring returns a generic JSON blob with a timestamp, status, error, and path. It does not include a clean per-field breakdown of what went wrong. An API consumer reading this response cannot easily tell which field failed or what the exact constraint was.
Why is this a problem?
Imagine you are building a frontend form that calls this API. When submission fails, your form needs to highlight the exact fields that have errors and show the user a helpful message for each one. Spring's default error response makes that difficult because it does not map errors cleanly to individual fields.
This is the gap you will fix in the next step with a @ControllerAdvice that gives you full control over the error response shape.
Verify that valid requests still work
- Send a valid request to confirm the DTO layer did not break anything by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","priority":"HIGH"}'
You should see a 201 response with the saved task, including a generated id, status set to TODO, and a createdAt timestamp.
Getting a 400 on valid requests too?
- Check that your JSON field names match the DTO exactly: title and priority (lowercase).
- Verify that the priority value is one of LOW, MEDIUM, or HIGH (all caps, matching the enum).
- Make sure the Content-Type header is set to application/json in your curl command.
Still stuck? Help me figure out why my valid task request is returning a 400 error.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: CreateTaskRequest.java, TaskService.java, and TaskController.java.
ⓧ I'd like to double check the full code
Here is each file you created or modified in this step. Scroll through and confirm yours matches.
CreateTaskRequest.java:
package com.example.tasktracker.dto;
import com.example.tasktracker.domain.Priority;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
public class CreateTaskRequest {
@NotBlank(message = "Title is required")
private String title;
@Size(max = 500, message = "Description must be 500 characters or fewer")
private String description;
@NotNull(message = "Priority is required")
private Priority priority;
public String getTitle() { return title; }
public void setTitle(String title) { this.title = title; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public Priority getPriority() { return priority; }
public void setPriority(Priority priority) { this.priority = priority; }
}
TaskService.java:
package com.example.tasktracker.service;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.dto.CreateTaskRequest;
import com.example.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
public class TaskService {
private final TaskRepository taskRepository;
public TaskService(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
public Task createTask(CreateTaskRequest request) {
Task task = new Task();
task.setTitle(request.getTitle());
task.setDescription(request.getDescription());
task.setPriority(request.getPriority());
return taskRepository.save(task);
}
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
}
TaskController.java:
package com.example.tasktracker.controller;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.dto.CreateTaskRequest;
import com.example.tasktracker.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task createTask(@Valid @RequestBody CreateTaskRequest request) {
return taskService.createTask(request);
}
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
}
You have validation working. Invalid requests get rejected with a 400 status, and valid requests still flow through to the database. The problem is the error response format. It is a generic blob that does not help API consumers fix their requests. In the next step, you will take full control of that response with a custom exception handler.
Fix Error Handling with @ControllerAdvice
In the previous step, you saw what happens when your API receives bad input. Spring returned a generic error blob with no field-level details. That default response is useless to any client trying to understand what went wrong.
A @ControllerAdvice class centralizes exception handling across all your controllers. Instead of each controller managing its own errors, one class intercepts exceptions and builds a clean, structured JSON response. This is the fix for the ugly defaults you saw in Step 4.
In this step, get ready to:
- Create a global exception handler that returns structured validation errors.
- Add custom not-found exception handling with a 404 response.
- Test the complete error handling across all failure scenarios.
Create the GlobalExceptionHandler
The @ControllerAdvice annotation marks a class as a cross-cutting concern that applies to every controller. Methods inside it annotated with @ExceptionHandler catch specific exception types and return your custom response.
- Create a new folder at src/main/java/com/example/tasktracker/exception.
- Inside that folder, create a new file called GlobalExceptionHandler.java and paste the following code:
package com.example.tasktracker.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, Object>> handleValidationExceptions(MethodArgumentNotValidException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 400);
body.put("message", "Validation failed");
Map<String, String> errors = new HashMap<>();
for (FieldError fieldError : ex.getBindingResult().getFieldErrors()) {
errors.put(fieldError.getField(), fieldError.getDefaultMessage());
}
body.put("errors", errors);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body);
}
}
What does this code do?
- @ControllerAdvice tells Spring to apply this class globally across all controllers.
- @ExceptionHandler(MethodArgumentNotValidException.class) catches the specific exception that Spring throws when @Valid validation fails.
- The method builds a response body with a timestamp, status code, message, and a map of field-level errors (field name to error message).
- It returns a ResponseEntity with a 400 status, giving the API consumer exactly what they need to fix their request.
- Save GlobalExceptionHandler.java.
- Stop the running application by pressing Ctrl+C in the terminal.
- Restart the application by running:
./mvnw spring-boot:run
Before you test, predict what you think will happen when you send the same invalid request from Step 4. Will the response look different now?
- Test the new error handling by running this invalid request:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","priority":"HIGH"}'
You should now see a clean, structured JSON response instead of the default error blob:
{
"timestamp": "2026-08-04T...",
"status": 400,
"message": "Validation failed",
"errors": {
"title": "Title is required"
}
}
Still seeing the old error format?
- Make sure you restarted the application after saving the file. Spring Boot does not hot-reload without DevTools.
- Verify the package declaration matches com.example.tasktracker.exception. Spring Boot component scanning only finds classes under the base package.
- Check that the @ControllerAdvice annotation is on the class (not the method).
Help me debug my @ControllerAdvice not intercepting validation exceptions.
Add not-found handling and complete the CRUD endpoints
Your validation errors are now clean. Next, you need a custom exception for when a client requests a task that does not exist. You will also add the remaining CRUD endpoints (get by ID, update status, delete) that throw this exception.
- In the exception folder, create a new file called TaskNotFoundException.java with the following code:
package com.example.tasktracker.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
- Now open GlobalExceptionHandler.java and add this second handler method below the existing handleValidationExceptions method (before the closing } of the class):
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleTaskNotFound(TaskNotFoundException ex) {
Map<String, Object> body = new HashMap<>();
body.put("timestamp", LocalDateTime.now().toString());
body.put("status", 404);
body.put("message", ex.getMessage());
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(body);
}
What does this handler do?
When any controller throws a TaskNotFoundException, this method catches it and returns a 404 response with a clear message. The exception message (e.g. "Task not found with id: 99") becomes the response message.
- Save both files.
- Restart the application (Ctrl+C then ./mvnw spring-boot:run) to verify it compiles.
You should see "Started TaskTrackerApplication" in the terminal with no errors.
✔️ Awesome, I've got everything!
Great. Your exception handling layer is wired up. Now you need the endpoints that use it.
ⓧ I'd like to double check the full code
Scroll up and confirm your GlobalExceptionHandler.java has both methods: handleValidationExceptions (handling MethodArgumentNotValidException) and handleTaskNotFound (handling TaskNotFoundException). Both should return a ResponseEntity<Map<String, Object>>.
- Create a new file called UpdateStatusRequest.java inside the dto folder with this code:
package com.example.tasktracker.dto;
import com.example.tasktracker.domain.Status;
import jakarta.validation.constraints.NotNull;
public class UpdateStatusRequest {
@NotNull(message = "Status is required")
private Status status;
public Status getStatus() { return status; }
public void setStatus(Status status) { this.status = status; }
}
Why a separate DTO for status updates?
The update endpoint only changes the task's status. Using a dedicated DTO means the client cannot accidentally overwrite the title or priority. Each endpoint accepts only the fields it needs.
- Open TaskService.java and add these imports below the existing import lines:
import com.example.tasktracker.dto.UpdateStatusRequest;
import com.example.tasktracker.exception.TaskNotFoundException;
- Below the existing getAllTasks() method, add these three new methods:
// Find a single task by ID, or throw 404
public Task getTaskById(Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
}
// Update only the status of an existing task
public Task updateTaskStatus(Long id, UpdateStatusRequest request) {
Task task = getTaskById(id);
task.setStatus(request.getStatus());
return taskRepository.save(task);
}
// Delete a task (throws 404 if not found)
public void deleteTask(Long id) {
Task task = getTaskById(id);
taskRepository.delete(task);
}
What do these methods do?
- getTaskById uses findById from JpaRepository, which returns an Optional. If the task does not exist, it throws TaskNotFoundException which your handler catches and returns as a 404.
- updateTaskStatus reuses getTaskById (getting the 404 check for free), then updates and saves.
- deleteTask also reuses getTaskById to ensure the task exists before deleting.
- Save TaskService.java.
- Restart the application (Ctrl+C then ./mvnw spring-boot:run) to verify it compiles with the new service methods.
You should see "Started TaskTrackerApplication" with no errors. The service layer is ready.
- Open TaskController.java and replace the entire file content with the following:
package com.example.tasktracker.controller;
import com.example.tasktracker.domain.Task;
import com.example.tasktracker.dto.CreateTaskRequest;
import com.example.tasktracker.dto.UpdateStatusRequest;
import com.example.tasktracker.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskService taskService;
public TaskController(TaskService taskService) {
this.taskService = taskService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Task createTask(@Valid @RequestBody CreateTaskRequest request) {
return taskService.createTask(request);
}
@GetMapping
public List<Task> getAllTasks() {
return taskService.getAllTasks();
}
// Get a single task by its ID
@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskService.getTaskById(id);
}
// Update only the status of a task
@PutMapping("/{id}/status")
public Task updateTaskStatus(@PathVariable Long id, @Valid @RequestBody UpdateStatusRequest request) {
return taskService.updateTaskStatus(id, request);
}
// Delete a task and return 204 No Content
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void deleteTask(@PathVariable Long id) {
taskService.deleteTask(id);
}
}
What are the new endpoints?
- GET /api/tasks/{id} retrieves a single task. If the ID does not exist, the service throws TaskNotFoundException and your handler returns a 404.
- PUT /api/tasks/{id}/status updates the task's status. The @Valid annotation validates the request body against UpdateStatusRequest constraints.
- DELETE /api/tasks/{id} removes a task and returns 204 No Content on success.
- Save TaskController.java.
✔️ Awesome, I've got everything!
Great. Save the file and move on to testing.
ⓧ I'd like to double check the full code
Your TaskController.java should have the package declaration, all imports (including DeleteMapping, PathVariable, PutMapping, and UpdateStatusRequest), then the class with five endpoint methods: createTask (POST), getAllTasks (GET), getTaskById (GET /{id}), updateTaskStatus (PUT /{id}/status), and deleteTask (DELETE /{id}).
Test the complete error handling
- Restart the application by pressing Ctrl+C then running:
./mvnw spring-boot:run
Now you will test all three error scenarios. Before each command, predict what status code and response body you expect.
- Test validation errors by sending an invalid POST (empty title):
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","priority":"HIGH"}'
You should see a 400 response with your structured error body:
{
"timestamp": "...",
"status": 400,
"message": "Validation failed",
"errors": {
"title": "Title is required"
}
}
- Test not-found handling by requesting a task ID that does not exist:
curl http://localhost:8080/api/tasks/999
You should see a 404 response with a clear message:
{
"timestamp": "...",
"status": 404,
"message": "Task not found with id: 999"
}
- Verify that valid requests still work by creating a task:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","priority":"HIGH"}'
You should see a 201 response with the saved task including its generated id, status set to TODO, and a createdAt timestamp.
Getting a 404 on all endpoints?
- Make sure you saved all files (TaskController.java, TaskService.java, UpdateStatusRequest.java, TaskNotFoundException.java) before restarting.
- Check the terminal output for compilation errors. A missing import or typo in a package name will prevent the app from starting.
- Verify that TaskController.java has @RequestMapping("/api/tasks") on the class.
Help me debug why my Spring Boot endpoints are returning 404.
Your API now handles errors like a production application. Invalid input gets field-level feedback, missing resources get clear 404 messages, and valid requests continue working as expected. You have a complete CRUD API with professional error handling.
Secret mission
Add Filtering by Status and Priority
Your API returns all tasks every time you call GET /api/tasks. Real-world APIs let consumers filter results by specific fields. Can you add optional query parameters so callers can retrieve tasks by status, priority, or both?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with an in-memory H2 database, so there are no ongoing costs. Decide whether to keep your project files, pause the running server, or delete everything entirely.
Resources you used:
- The tasktracker project folder on your Desktop (all Java source files, Maven Wrapper, and configuration).
- The running Spring Boot process on port 8080.
- The H2 in-memory database (automatically destroyed when the app stops).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep testing your API.
- Your Spring Boot app continues running on port 8080.
- Your project files stay in the tasktracker folder on your Desktop.
- The H2 database retains any tasks you created for as long as the app keeps running.
Pause - I'll come back to this later
Stop the running server to free up memory and port 8080. Your project files stay intact so you can start up again later.
- Switch to the terminal where Spring Boot is running.
- Press Ctrl+C to stop the server.
- Your project files remain in the tasktracker folder on your Desktop. To restart later, open the folder in VS Code and run ./mvnw spring-boot:run again.
- The H2 in-memory database resets when the app stops. Any tasks you created will be gone on next startup.
Delete - I don't want to use this again
Remove all project files and stop any running processes. This cannot be undone.
- Switch to the terminal where Spring Boot is running and press Ctrl+C to stop the server.
- Delete the project folder by running the following command.
On macOS/Linux:
cd ~/Desktop
rm -rf tasktracker
On Windows:
cd ~\Desktop
rmdir /s /q tasktracker
- The H2 database is already gone once the app stops. No separate cleanup needed.
Nice Work!
Nice Work!
Nice work! You just built a production-structured REST API from scratch using Spring Boot 4.1.0. You can create, read, update, and delete tasks through clean HTTP endpoints, with proper validation and structured error responses.
You've learned how to:
- Build a complete CRUD REST API with proper layered architecture (Controller, Service, Repository, Domain) that mirrors how production Spring Boot applications are structured.
- Persist data using Spring Data JPA with an embedded H2 database and implement Bean Validation with Jakarta Validation constraints for input safety.
- Create production-style global exception handling with @ControllerAdvice that returns structured JSON error responses instead of stack traces, and separate API contracts (DTOs) from domain entities.
- Secret Mission: Added query filtering using Spring Data JPA derived query methods so callers can retrieve tasks by status, priority, or both.
Ready to quiz yourself?