Build a Task Manager REST API
Build a CRUD REST API with Java Spring Boot, JPA, and H2 database.
Introduction
30 Second Summary
Every app you use daily talks to a backend API. When you check off a to-do, save a note, or update your profile, a REST API receives that request, processes it, and sends back a response. Building one yourself is the fastest way to prove you understand how backends work.
In this project, you will build a Task Manager REST API using Spring Boot that lets users create, read, update, and delete tasks. Your API will store data in a database, validate user input, and handle errors with proper HTTP status codes.
What You'll Build
A Task Manager REST API with five endpoints that persists tasks in an H2 in-memory database and returns structured JSON responses.
By the end of this project, you'll have:
- A working REST API with full CRUD endpoints for managing tasks, running locally on your machine.
- A clean, layered project structure (Controller, Repository, Model, Exception) that demonstrates professional Java patterns.
- Your project pushed to GitHub with a professional README, ready to show in interviews.
- Secret Mission: Add filtering and search to your task list so users can find tasks by completion status or title keyword.
Are there any prerequisites?
You should have basic Java knowledge (variables, classes, methods) and JDK 21 installed on your machine. You also need VS Code and a GitHub account.
Set Up Your Spring Boot Project
Every REST API needs a foundation. Before you write any business logic, you need a properly configured project with the right dependencies.
In this step, you will generate a ready-to-run Spring Boot project using Spring Initializr. Spring Initializr is a web tool that scaffolds a project with exactly the dependencies you need.
In this step, get ready to:
- Generate a Spring Boot project with the correct dependencies.
- Verify the application starts successfully on your machine.
Generate a new Spring Boot project
Spring Initializr lets you pick your build tool, language version, and dependencies from a web interface. It packages everything into a downloadable ZIP file.
- Open your browser and go to https://start.spring.io.
- Set Project to Maven.
- Set Language to Java.
- Set Spring Boot to 3.5.16.
- Under Project Metadata, set Group to com.example.
- Set Artifact to task-manager.
- Set Packaging to Jar.
- Set Java to 21.
- Click ADD DEPENDENCIES and add Spring Web.
- Add Spring Data JPA.
- Add H2 Database.
- Add Validation.
- Click GENERATE to download the ZIP file.
What are these dependencies?
Spring Web provides the tools to build REST endpoints. Spring Data JPA handles database operations without writing SQL.
H2 Database is an in-memory database that requires zero installation. Validation lets you enforce rules on incoming data (like requiring a title).
- Find the downloaded task-manager.zip file and extract it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the extracted task-manager folder, then click Open.
What should I see?
The VS Code Explorer sidebar should show folders like src and .mvn, plus files like pom.xml, mvnw, and mvnw.cmd. These were all generated by Spring Initializr.
Why Maven over Gradle?
Maven is the default on Spring Initializr and more common in enterprise Java shops. The Maven wrapper (mvnw) is included in the generated project, so you do not need to install Maven separately.
Verify the project runs
Spring Boot includes an embedded web server. You can start the entire application with a single command to confirm everything is wired up correctly.
- Open the integrated terminal in VS Code by pressing Ctrl+` (same shortcut on macOS and Windows).
- Start the application by running this command:
macOS
./mvnw spring-boot:run
Windows
.\mvnw.cmd spring-boot:run
What does this command do?
The mvnw file is the Maven wrapper. It downloads the correct Maven version automatically and runs the spring-boot:run goal, which compiles your code and starts the embedded Tomcat server.
The first run may take a minute or two as Maven downloads all the project dependencies. You will see log output scrolling in your terminal.
- Wait for the log output to stop scrolling. Look for a line containing Started TaskManagerApplication.
- Open your browser and visit http://localhost:8080.
You should see a page titled "Whitelabel Error Page". This confirms that Spring Boot is running and listening for requests.
Why an error page?
You have not defined any endpoints yet, so Spring Boot shows its default error page. This is expected. You will create your first endpoint in the next step.
- Return to the VS Code terminal and press Ctrl+C to stop the application.
Application not starting?
If you see a "permission denied" error on macOS, run chmod +x mvnw in the terminal first, then try the command again.
If you see "port 8080 already in use", another process is using that port. Close any other servers running on port 8080 and try again.
Help me troubleshoot my Spring Boot startup error
Your Spring Boot project is scaffolded and running. Next up, you will create the Task entity and repository that define how your data is stored.
Create the Task Entity and Repository
Your Spring Boot project is running and ready for action. Now it's time to define what a "task" actually looks like in your application.
Every REST API needs a data model. In this step, you'll create a JPA entity that maps directly to a database table, a repository that handles all database operations, and the configuration that connects everything to an H2 in-memory database.
In this step, get ready to:
- Create a Task entity with validation rules.
- Create a repository interface for database access.
- Configure the H2 database and verify it's accessible.
Create the Task entity class
A JPA entity is a Java class that maps to a database table. Each instance of the class becomes a row in that table.
- In the VS Code Explorer sidebar, right-click the src/main/java/com/example/taskmanager folder.
- Select New Folder and name it model.
- Right-click the new model folder, select New File, and name it Task.java.
- Paste the following code into Task.java:
package com.example.taskmanager.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(min = 1, max = 100, message = "Title must be between 1 and 100 characters")
private String title;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
private boolean completed;
public Task() {
}
public Task(String title, String description, boolean completed) {
this.title = title;
this.description = description;
this.completed = completed;
}
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 boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
What does this code do?
The @Entity and @Table(name = "tasks") annotations tell JPA to create a database table called tasks from this class.
- @Id and @GeneratedValue make the id field an auto-incrementing primary key.
- @NotBlank ensures the title cannot be empty or null when creating a task.
- @Size enforces character limits so users cannot submit unreasonably long text.
- The getters and setters let Spring access and modify the fields when converting between JSON and Java objects.
- Save Task.java.
Seeing red underlines in VS Code?
If you see errors under the jakarta imports, VS Code may still be loading the project dependencies. Wait a few seconds for the Java extension to finish indexing. If they persist, open the command palette with Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows) and run Java: Clean Java Language Server Workspace.
Help me fix the red underlines in my Task.java file
✔️ Awesome, I've got everything!
Great. Make sure you've saved Task.java before moving on.
ⓧ I'd like to double check the full code
package com.example.taskmanager.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
@Entity
@Table(name = "tasks")
public class Task {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@NotBlank(message = "Title is required")
@Size(min = 1, max = 100, message = "Title must be between 1 and 100 characters")
private String title;
@Size(max = 500, message = "Description must not exceed 500 characters")
private String description;
private boolean completed;
public Task() {
}
public Task(String title, String description, boolean completed) {
this.title = title;
this.description = description;
this.completed = completed;
}
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 boolean isCompleted() {
return completed;
}
public void setCompleted(boolean completed) {
this.completed = completed;
}
}
Create the TaskRepository interface
Spring Data JPA can generate all the database code you need from a single interface. By extending JpaRepository, you get methods for saving, finding, updating, and deleting records without writing any implementation.
- In the Explorer sidebar, right-click the src/main/java/com/example/taskmanager folder.
- Select New Folder and name it repository.
- Right-click the new repository folder, select New File, and name it TaskRepository.java.
- Paste the following code into TaskRepository.java:
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
What does this code do?
JpaRepository is a Spring Data interface that provides full CRUD operations. The <Task, Long> tells Spring that this repository manages Task entities and uses Long as the ID type.
You get methods like findAll(), findById(), save(), and delete() for free. Spring generates the implementation at runtime.
- Save TaskRepository.java.
✔️ Awesome, I've got everything!
Great. Make sure you've saved TaskRepository.java before moving on.
ⓧ I'd like to double check the full code
package com.example.taskmanager.repository;
import com.example.taskmanager.model.Task;
import org.springframework.data.jpa.repository.JpaRepository;
public interface TaskRepository extends JpaRepository<Task, Long> {
}
Configure the H2 database
Your entity and repository are ready, but Spring Boot needs to know which database to connect to. H2 is an in-memory database that runs inside your application. It requires zero installation.
- In the Explorer sidebar, open src/main/resources/application.properties.
- Replace the entire file contents with the following configuration:
spring.application.name=task-manager
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
What do these settings do?
- spring.datasource.url=jdbc:h2:mem:taskdb creates an in-memory database called taskdb. Data resets every time you restart the app.
- spring.h2.console.enabled=true turns on a web-based database viewer you can use to inspect your tables.
- spring.jpa.hibernate.ddl-auto=update automatically creates database tables from your entity classes.
- spring.jpa.show-sql=true prints the generated SQL statements to your terminal so you can see what's happening behind the scenes.
- Save application.properties.
- Start the application by running this command in your VS Code terminal:
./mvnw spring-boot:run
- Open your browser and visit http://localhost:8080/h2-console.
- In the JDBC URL field, enter jdbc:h2:mem:taskdb.
- Click Connect.
You should see the H2 console dashboard with a TASKS table listed in the left panel. This confirms that Spring Boot automatically created the table from your Task entity.
Don't see the TASKS table?
Make sure the JDBC URL field says jdbc:h2:mem:taskdb before clicking Connect. The H2 console defaults to a different URL that won't find your database.
If the H2 console page doesn't load at all, check your terminal for errors. Make sure application.properties is saved and the app has restarted successfully.
Help me troubleshoot the missing TASKS table
- Return to your terminal and press Ctrl+C to stop the application.
✔️ Awesome, I've got everything!
Great. Make sure you've saved application.properties before moving on.
ⓧ I'd like to double check the full code
spring.application.name=task-manager
spring.datasource.url=jdbc:h2:mem:taskdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
Your data layer is locked in. You have an entity, a repository, and a running database. Next up, you'll build the REST controller that exposes these to the outside world as API endpoints.
Build the REST Controller
You have a Task entity and a repository that handles database operations. Now you need a way for the outside world to interact with your tasks.
In this step, you will create a REST controller that receives HTTP requests and maps them to your repository methods. This is the layer that turns your Java application into a web API.
In this step, get ready to:
- Create the TaskController class with Spring annotations and constructor injection.
- Implement five CRUD endpoints for managing tasks.
- Test your API with curl to confirm everything works.
Create the controller class
A REST controller is the entry point for all HTTP requests to your API. Spring Boot uses annotations to map incoming requests to specific Java methods.
Before you create the controller, you need a small helper class that the controller will use when a task is not found in the database.
- In VS Code's Explorer sidebar, right-click the com.example.taskmanager package folder inside src/main/java.
- Create a new folder called exception.
- Inside the exception folder, create a new file called TaskNotFoundException.java and paste the following code:
package com.example.taskmanager.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
What does this class do?
TaskNotFoundException extends RuntimeException, making it an unchecked exception. When your controller cannot find a task with a given ID, it throws this exception to signal the problem clearly.
- Save TaskNotFoundException.java.
Now you are ready to create the controller itself. The controller class needs a controller package alongside the exception package you just made.
- Right-click the com.example.taskmanager package folder and create a new folder called controller.
- Inside the controller folder, create a new file called TaskController.java and paste the following code:
package com.example.taskmanager.controller;
import com.example.taskmanager.exception.TaskNotFoundException;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskRepository taskRepository;
// Spring injects the TaskRepository automatically
public TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
}
What does this code do?
- @RestController tells Spring this class handles HTTP requests and returns JSON responses directly (no HTML templates).
- @RequestMapping("/api/tasks") sets the base URL path for every endpoint in this controller.
- The constructor accepts a TaskRepository parameter. Spring automatically provides the repository instance at startup through dependency injection.
Why constructor injection instead of @Autowired?
Constructor injection makes dependencies explicit and easy to test. The Spring team recommends it over field injection with @Autowired because it guarantees the class cannot be instantiated without its required dependencies.
- Save TaskController.java.
You will not see a change yet. The next substep adds the endpoint methods that handle actual requests.
Implement the five CRUD endpoints
Each CRUD operation maps to an HTTP method. GET retrieves data, POST creates new data, PUT updates existing data, and DELETE removes data.
- In TaskController.java, place your cursor after the constructor's closing brace and before the class's closing brace.
- Add the following two read endpoints:
// Return all tasks in the database
@GetMapping
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
// Return a single task by its ID, or throw 404 if not found
@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
}
What do these methods do?
- @GetMapping with no path matches GET /api/tasks (the base path). It returns every task as a JSON array.
- @GetMapping("/{id}") matches GET /api/tasks/1 (or any ID). The @PathVariable annotation extracts the number from the URL.
- orElseThrow() unwraps the Optional returned by findById. If the task does not exist, it throws your TaskNotFoundException.
- Below the getTaskById method, add the following three write endpoints:
// Create a new task and return it with HTTP 201 status
@PostMapping
public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
Task savedTask = taskRepository.save(task);
return new ResponseEntity<>(savedTask, HttpStatus.CREATED);
}
// Update an existing task's fields
@PutMapping("/{id}")
public Task updateTask(@PathVariable Long id, @Valid @RequestBody Task taskDetails) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
}
// Delete a task and return HTTP 204 (no content)
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
taskRepository.delete(task);
return ResponseEntity.noContent().build();
}
What do these methods do?
- @PostMapping handles POST /api/tasks. The @Valid annotation triggers the validation rules you defined on the Task entity. It returns HTTP 201 (Created) on success.
- @PutMapping("/{id}") handles updates. It finds the existing task, copies the new field values over, and saves the updated task back to the database.
- @DeleteMapping("/{id}") removes the task and returns HTTP 204 (No Content) to indicate success with no response body.
- Save TaskController.java.
- Confirm that VS Code shows no red underlines in the file. If you see errors, check that your imports match the code above and that TaskNotFoundException.java is saved in the exception package.
Seeing red underlines in VS Code?
- Make sure TaskNotFoundException.java is saved inside src/main/java/com/example/taskmanager/exception/. The controller imports from that exact package path.
- Verify the package declaration at the top of each file matches its folder location.
- Try closing and reopening VS Code if the Java language server has not picked up the new files.
Still stuck? Why is my TaskController showing import errors?
✔️ Awesome, I've got everything!
Great. Double check you have saved both TaskNotFoundException.java and TaskController.java.
ⓧ I'd like to double check the full code
Here is the complete TaskController.java file:
package com.example.taskmanager.controller;
import com.example.taskmanager.exception.TaskNotFoundException;
import com.example.taskmanager.model.Task;
import com.example.taskmanager.repository.TaskRepository;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
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.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/tasks")
public class TaskController {
private final TaskRepository taskRepository;
public TaskController(TaskRepository taskRepository) {
this.taskRepository = taskRepository;
}
@GetMapping
public List<Task> getAllTasks() {
return taskRepository.findAll();
}
@GetMapping("/{id}")
public Task getTaskById(@PathVariable Long id) {
return taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
}
@PostMapping
public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
Task savedTask = taskRepository.save(task);
return new ResponseEntity<>(savedTask, HttpStatus.CREATED);
}
@PutMapping("/{id}")
public Task updateTask(@PathVariable Long id, @Valid @RequestBody Task taskDetails) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
task.setTitle(taskDetails.getTitle());
task.setDescription(taskDetails.getDescription());
task.setCompleted(taskDetails.isCompleted());
return taskRepository.save(task);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
Task task = taskRepository.findById(id)
.orElseThrow(() -> new TaskNotFoundException("Task not found with id: " + id));
taskRepository.delete(task);
return ResponseEntity.noContent().build();
}
}
And here is the complete TaskNotFoundException.java file:
package com.example.taskmanager.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
Run and test your endpoints
Your controller is complete. Time to start the application and send real HTTP requests to confirm everything is wired correctly.
- Start your application by opening a terminal in VS Code (Ctrl+`) and running:
macOS
./mvnw spring-boot:run
Windows
mvnw spring-boot:run
Wait until you see a line in the terminal that says the application started on port 8080.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- Test the POST endpoint by creating a new task with this command:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\":\"Learn Spring Boot\",\"description\":\"Build a REST API\",\"completed\":false}"
What does this command do?
- -X POST specifies the HTTP method.
- -H "Content-Type: application/json" tells the server you are sending JSON data.
- -d provides the request body containing your task data.
You should see a JSON response with your task, including a generated id field (likely 1).
- Test the GET endpoint to retrieve all tasks by running:
curl http://localhost:8080/api/tasks
You should see a JSON array containing the task you just created, with its id, title, description, and completed fields.
Windows PowerShell users
PowerShell aliases curl to a different command. If you get unexpected output, use curl.exe instead of curl in both commands above.
Not getting a JSON response?
- Make sure your first terminal is still running the application. Look for the "Started TaskManagerApplication" message.
- Check that you opened a separate second terminal for curl. Running curl in the same terminal that runs the app will not work.
- If you see a connection refused error, the app may have failed to start. Switch to the first terminal and look for red error text.
Need more help? My curl command is not returning JSON from my Spring Boot app.
Your REST API is live and responding to requests. Next up, you will add validation and error handling so the API returns helpful messages when something goes wrong.
Add Validation and Error Handling
Your five CRUD endpoints from the last step handle the happy path perfectly. But what happens when a user sends an empty title, or requests a task that doesn't exist?
Right now, Spring Boot returns its default error page with a generic status code. In this step, you'll create the exception-handling infrastructure that catches those errors and returns clean, structured JSON responses instead.
In this step, get ready to:
- Create a custom exception class for missing tasks.
- Build a global exception handler that returns structured JSON error responses.
- Verify your API returns proper 404 and 400 status codes.
Understand how validation is already wired up
Your controller already has @Valid annotations on the createTask and updateTask methods from Step 3.
- Open TaskController.java and find the createTask method signature:
public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
What does @Valid do?
The @Valid annotation tells Spring to check the request body against the validation constraints you defined on the Task entity (like @NotBlank and @Size).
If validation fails, Spring throws a MethodArgumentNotValidException. Without a custom handler, that exception produces an ugly default error page. You'll fix that in this step.
Your controller also already uses .orElseThrow() to throw a TaskNotFoundException when a task ID doesn't exist. The exception class itself doesn't exist yet though. Let's create it now.
Create the TaskNotFoundException class
Custom exceptions give you control over what error message and HTTP status code your API returns. A dedicated exception for "task not found" lets you return a clean 404 response.
- Create a new folder called exception inside src/main/java/com/example/taskmanager/.
- Create a new file called TaskNotFoundException.java inside the exception folder.
- Add the following code to TaskNotFoundException.java:
package com.example.taskmanager.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
What does this code do?
This class extends RuntimeException, which means Spring won't force you to add try-catch blocks everywhere you throw it.
The constructor accepts a message string (like "Task not found with id: 5") and passes it up to the parent class. Your controller already throws this exception in getTaskById, updateTask, and deleteTask.
- Save TaskNotFoundException.java.
Seeing a compilation error?
Make sure the package declaration at the top of the file is exactly package com.example.taskmanager.exception; and that the file is saved inside the exception folder.
Help me fix a compilation error in my TaskNotFoundException class.
Build the global exception handler
The exception class alone doesn't change what your API returns. You need a handler that intercepts exceptions before Spring's default error page kicks in.
A class annotated with @RestControllerAdvice acts as a centralized error catcher for all your controllers. Each method inside it handles one type of exception.
- Create a new file called GlobalExceptionHandler.java inside the exception folder.
- Add the following code to GlobalExceptionHandler.java:
package com.example.taskmanager.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<Map<String, String>> handleTaskNotFound(TaskNotFoundException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fieldError ->
errors.put(fieldError.getField(), fieldError.getDefaultMessage()));
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
}
What does this code do?
- @RestControllerAdvice tells Spring this class handles exceptions across all controllers. It automatically catches exceptions before they reach the default error page.
- handleTaskNotFound catches TaskNotFoundException and returns a 404 response with a JSON body like {"error": "Task not found with id: 5"}.
- handleValidationErrors catches MethodArgumentNotValidException (thrown when @Valid fails) and returns a 400 response with field-level error messages like {"title": "Title is required"}.
- Save GlobalExceptionHandler.java.
- Return to your terminal and restart the application by pressing Ctrl+C to stop it, then running:
./mvnw spring-boot:run
- In a new terminal, test the 404 response by requesting a non-existent task:
curl http://localhost:8080/api/tasks/999
You should see a JSON response like {"error":"Task not found with id: 999"} instead of the default error page.
Still seeing a Whitelabel Error Page?
Make sure GlobalExceptionHandler.java is inside the com.example.taskmanager.exception package. Spring Boot only scans classes under the main application package (com.example.taskmanager) and its sub-packages.
Also confirm you restarted the application after saving both files.
Help me debug why my GlobalExceptionHandler isn't catching exceptions.
\u2714\ufe0f Awesome, I've got everything!
Great. Double check you've saved both files in the exception folder.
\u24e7 I'd like to double check the full code
Here is the complete code for both exception files:
package com.example.taskmanager.exception;
public class TaskNotFoundException extends RuntimeException {
public TaskNotFoundException(String message) {
super(message);
}
}
package com.example.taskmanager.exception;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.HashMap;
import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(TaskNotFoundException.class)
public ResponseEntity<Map<String, String>> handleTaskNotFound(TaskNotFoundException ex) {
Map<String, String> error = new HashMap<>();
error.put("error", ex.getMessage());
return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<Map<String, String>> handleValidationErrors(MethodArgumentNotValidException ex) {
Map<String, String> errors = new HashMap<>();
ex.getBindingResult().getFieldErrors().forEach(fieldError ->
errors.put(fieldError.getField(), fieldError.getDefaultMessage()));
return new ResponseEntity<>(errors, HttpStatus.BAD_REQUEST);
}
}
Your API now handles errors like a professional service. Instead of leaking stack traces or showing generic error pages, it returns structured JSON with meaningful messages. Next up, you'll put the entire API through its paces and push your project to GitHub.
Test Your API and Push to GitHub
Your API has all five endpoints, input validation, and error handling wired up. Now it is time to prove it works end-to-end.
In this step, you will run through the full create-read-update-delete workflow using curl commands. Then you will push the finished project to GitHub with a professional README.
In this step, get ready to:
- Test all CRUD endpoints plus validation and error responses.
- Initialize a Git repository and push your code to GitHub.
- Add a README documenting your API endpoints.
Test the full CRUD workflow
Since H2 is an in-memory database, restarting the application gives you a clean, empty database. This is perfect for a fresh test run.
- Switch back to the terminal in VS Code where your app is running.
- Press Ctrl+C to stop the application.
- Restart the application by running:
macOS
./mvnw spring-boot:run
Windows
.\mvnw spring-boot:run
- Open a second terminal in VS Code by clicking Terminal in the top menu bar, then New Terminal.
- Create a new task by running this command:
macOS
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","description":"Build a REST API","completed":false}'
Windows
curl.exe -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\":\"Learn Spring Boot\",\"description\":\"Build a REST API\",\"completed\":false}"
You should see a JSON response with an id field, your title, description, and completed: false.
- Retrieve the task you just created by running:
curl http://localhost:8080/api/tasks/1
You should see the same task returned as JSON.
- Update the task to mark it as completed by running:
macOS
curl -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","description":"Build a REST API","completed":true}'
Windows
curl.exe -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d "{\"title\":\"Learn Spring Boot\",\"description\":\"Build a REST API\",\"completed\":true}"
You should see the task returned with completed: true.
- Delete the task by running:
curl -X DELETE http://localhost:8080/api/tasks/1
You should see no response body returned, which confirms the 204 No Content status.
What just happened?
You walked through the full lifecycle of a task: created it (POST), read it back (GET), updated a field (PUT), and removed it (DELETE). These four operations map to the standard CRUD pattern that nearly every backend API uses.
Now test that your validation and error handling work correctly.
- Send a POST request with an empty title to trigger a validation error:
macOS
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","description":"Missing title","completed":false}'
Windows
curl.exe -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\":\"\",\"description\":\"Missing title\",\"completed\":false}"
You should see a 400 Bad Request response with a JSON body containing the field name and the error message you defined in your Task entity.
- Request a task ID that does not exist to trigger the 404 handler:
curl http://localhost:8080/api/tasks/999
You should see a 404 response with your custom error message: {"error":"Task not found with id: 999"}.
Not seeing the expected responses?
Make sure your application is still running in the first terminal. If it crashed, check the terminal output for stack traces pointing to a typo in your code.
On Windows, make sure you are using curl.exe (not curl) because PowerShell aliases curl to a different command.
help me debug my curl commands
Push your code to GitHub
Your API is working locally. Pushing it to GitHub gives you a public link to share with recruiters and hiring managers.
- The Spring Initializr project already includes a .gitignore file that excludes build artifacts and IDE files.
- Initialize a Git repository and make your first commit by running these commands in the second terminal:
git init
git add .
git commit -m "Initial commit: Task Manager REST API"
What does this do?
- git init creates a new Git repository in your project folder.
- git add . stages all files (respecting the .gitignore).
- git commit saves a snapshot of your project.
- Go to github.com in your browser.
- Click the + icon in the top right corner, then select New repository.
- Enter task-manager as the repository name.
- Leave it set to Public.
- Do not check any of the initialization options (README, .gitignore, or license).
- Click Create repository.
- GitHub shows setup instructions on the next page. Connect your local repo and push by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/task-manager.git"]]
git branch -M main
git push -u origin main
You should see output confirming your code was pushed to the main branch.
Push failed or permission denied?
Make sure you are signed in to GitHub in your terminal. If prompted for credentials, use a Personal Access Token (not your GitHub password).
help me push my code to GitHub
Add a README to document your API
A good README turns a code dump into a professional project. It tells anyone who visits your repository what the project does and how to use it.
- Create a new file called README.md in the root of your task-manager folder.
- In VS Code, right-click in the Explorer sidebar and select New File.
- Name it README.md.
- Paste the following content into the file:
# Task Manager REST API
A Spring Boot REST API for managing tasks with full CRUD operations.
## Tech Stack
- Java 21, Spring Boot 3.5, Spring Data JPA, H2 Database, Maven
## How to Run
```
./mvnw spring-boot:run
```
## API Endpoints
| Method | URL | Description |
|--------|-----|-------------|
| GET | /api/tasks | List all tasks |
| GET | /api/tasks/{id} | Get task by ID |
| POST | /api/tasks | Create a task |
| PUT | /api/tasks/{id} | Update a task |
| DELETE | /api/tasks/{id} | Delete a task |
What makes this README effective?
It answers the three questions every visitor has: what is this project, how do I run it, and what can I do with it. The endpoint table gives recruiters a quick overview of the API surface without reading any code.
- Save README.md.
- Commit and push the README by running:
git add README.md
git commit -m "Add README with API documentation"
git push
You should see the push succeed. Refresh your GitHub repository page in the browser and you will see the README rendered below the file list.
README not showing on GitHub?
Make sure the file is named exactly README.md (case-sensitive) and is in the root folder, not inside src/.
help me get my README to show on GitHub
✔️ Awesome, I've got everything!
Great. Double check you have saved README.md and pushed your final commit.
ⓧ I'd like to double check the full code
# Task Manager REST API
A Spring Boot REST API for managing tasks with full CRUD operations.
## Tech Stack
- Java 21, Spring Boot 3.5, Spring Data JPA, H2 Database, Maven
## How to Run
```
./mvnw spring-boot:run
```
## API Endpoints
| Method | URL | Description |
|--------|-----|-------------|
| GET | /api/tasks | List all tasks |
| GET | /api/tasks/{id} | Get task by ID |
| POST | /api/tasks | Create a task |
| PUT | /api/tasks/{id} | Update a task |
| DELETE | /api/tasks/{id} | Delete a task |
Your Task Manager API is complete, tested, and published. Next, take on the Secret Mission and add search and filtering to make your API even more impressive.
Secret mission
Add Filtering and Search
Your API can create, read, update, and delete tasks. But what if a user has hundreds of tasks and only wants to see completed ones? Or search for a task by keyword? Add filtering and search capabilities using custom Spring Data JPA query methods.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally with an in-memory database, so there are no ongoing costs. Decide whether to keep your resources as they are, pause them to come back later, or delete them entirely.
Resources you used:
- The task-manager project folder on your machine.
- The GitHub repository you created for this project.
- The Spring Boot Extension Pack installed in VS Code.
Keep everything running
No action needed. Choose this if you're still actively building or plan to extend the project further.
- Your project folder stays where it is, ready to open in VS Code anytime.
- Your GitHub repository remains public for hiring managers to review.
- The Spring Boot Extension Pack stays installed in VS Code for future Java projects.
- The H2 database is in-memory, so all task data disappears each time you stop the application. No lingering data to worry about.
Pause - I'll come back to this later
Shut down the Spring Boot process to free up your terminal. Your files and repository stay intact for when you return.
- If the Spring Boot application is still running in your terminal, press Ctrl+C to stop it.
- Your project folder and GitHub repository remain untouched.
- To resume later, open the task-manager folder in VS Code and run mvnw spring-boot:run again.
Delete - I don't want to use this again
Remove all project resources from your machine and GitHub.
- If the Spring Boot application is still running, press Ctrl+C in the terminal to stop it.
Delete the project folder by running this command from the folder's parent directory:
macOS
rm -rf task-manager
Windows
rmdir /s /q task-manager
- Delete the GitHub repository by going to your repository page, clicking Settings, scrolling to the bottom, and clicking Delete this repository in the Danger Zone section.
- Uninstall the Spring Boot Extension Pack by opening VS Code, clicking the Extensions icon in the sidebar, finding Spring Boot Extension Pack, and clicking Uninstall.
Nice Work!
Nice Work!
You just built a production-ready REST API from scratch using Spring Boot. This is exactly the kind of backend project that demonstrates real engineering skills in a job interview.
Here's what you accomplished:
- Built a fully functional REST API with five endpoints that handle Create, Read, Update, and Delete operations using Spring Data JPA and an H2 in-memory database.
- Implemented input validation with Jakarta Bean Validation and global exception handling that returns structured JSON error responses with proper HTTP status codes.
- Designed a clean layered architecture with separate Model, Repository, Controller, and Exception packages, then pushed everything to GitHub with a professional README documenting your API.
- Secret Mission: Added search and filtering capabilities using custom Spring Data JPA query methods.
Ready to quiz yourself?