Build a Task Tracker REST API

Build a Spring Boot REST API with PostgreSQL, validation, and error handling.

Introduction

30 Second Summary

Every app you use tracks something. Your email tracks messages, your calendar tracks events, and your to-do list tracks tasks. Behind each of these is an API that stores, retrieves, and organizes that data.

In this project, you will build a Task Tracker REST API using Spring Boot and PostgreSQL. You will create endpoints that let you add tasks, list them, update their status, and delete them, all while handling errors gracefully.

What You'll Build

You will fire off curl commands in your terminal and watch your API respond with real JSON. You will create tasks, filter them by status, and intentionally send bad data to see your error handling return clean, structured messages instead of ugly stack traces.

By the end of this project, you'll have:

  • A fully working REST API where you create a task with a POST request and get back the saved task with a generated ID, list all tasks or filter by status, and update or delete tasks with proper HTTP status codes.
  • A layered architecture (Controller, Service, Repository) that separates your HTTP handling from your business logic and database access.
  • Production-style error handling that returns standards-compliant JSON error responses when you send invalid data or request a task that does not exist.
  • Secret Mission: Add a search endpoint that filters tasks by status and priority with pagination support.

Are there any prerequisites?

This project assumes you have experience writing Java code. You will also need Java 17+, IntelliJ IDEA, PostgreSQL, and Maven installed on your machine. Step 1 walks you through configuring these tools for the project.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. You're going to create a fully working REST API from scratch, connected to a real database, with the kind of error handling and monitoring that professional Spring Boot applications use in production.

Bootstrap the Project with Spring Initializr

You need a properly configured Spring Boot project with the right dependencies before you can write any API code. Getting the project structure, dependency management, and database connection right from the start means you won't fight configuration issues later.

Spring Initializr generates a ready-to-run Maven project with exactly the starters you need. In this step, you'll generate the project, connect it to PostgreSQL, and confirm everything starts cleanly.

In this step, get ready to:
  • Generate a Spring Boot 4.1.0 project on Spring Initializr with the required dependencies.
  • Create a PostgreSQL database and configure the application properties.
  • Run the application and verify the Actuator health endpoint responds.
Generate the project on Spring Initializr

Spring Initializr is a web tool that generates a complete Maven project structure with the dependencies you choose. You configure the project metadata once and download a ready-to-import zip file.

  • Open your browser and navigate to start.spring.io.
  • Set **Project** to Maven.
  • Set Language to Java.
  • Set Spring Boot to 4.1.0.
  • Under Project Metadata, set Group to com.example.
  • Set Artifact to tasktracker.
  • Leave Packaging as Jar and Java as 17.
  • Click ADD DEPENDENCIES and search for Spring Web. Select it.
  • Search for and add Spring Data JPA.
  • Search for and add PostgreSQL Driver.
  • Search for and add Validation.
  • Search for and add Spring Boot Actuator.
  • Click GENERATE to download the project as a zip file.

What is Spring Initializr?

Spring Initializr is the official project generator for Spring Boot applications. It creates a complete Maven (or Gradle) project with the correct directory structure, a pom.xml file with your chosen dependencies, and a main application class ready to run.

The five dependencies you added are Spring Boot starters. Each one pulls in a group of related libraries so you don't have to manage individual JAR versions yourself.

Open the project and create the database
  • Find the downloaded tasktracker.zip file and unzip it.
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type IntelliJ and press Enter to open it.
  • In IntelliJ IDEA, click Open on the welcome screen.
  • Navigate to the unzipped tasktracker folder and click OK.
  • Wait for IntelliJ to import the Maven project and download dependencies.

You should see the project structure in the Project panel on the left, including src/main/java and src/main/resources.

Your application needs a PostgreSQL database to connect to. You'll create one now using the PostgreSQL command-line tool.

  • Open a terminal window outside IntelliJ by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Terminal, and pressing Enter.
  • Create the database by running this command:
createdb tasktracker
  • Verify the database was created by running:
psql -l | grep tasktracker

You should see a line containing tasktracker in the list of databases.

Don't see tasktracker in the list?

  • Make sure PostgreSQL is running. On macOS, check that the Postgres app is active or run brew services start postgresql.
  • If you see a "command not found" error for createdb, your PostgreSQL bin directory may not be on your PATH.
  • help me troubleshoot why createdb is not working on my machine
Configure and run the application

The generated project includes a mostly-empty application.properties file. You need to tell Spring Boot how to connect to your database, how to manage the schema, and which Actuator endpoints to expose.

  • In IntelliJ's Project panel, expand src/main/resources and open application.properties.
  • Replace the file contents with the following configuration:
spring.application.name=tasktracker

# Database connection settings
spring.datasource.url=jdbc:postgresql://localhost:5432/tasktracker
spring.datasource.username=your-postgres-username-here
spring.datasource.password=your-postgres-password-here

# JPA settings - auto-create tables and log SQL
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.open-in-view=false

# Actuator - expose health, info, and metrics endpoints
management.endpoints.web.exposure.include=health,info,metrics
management.info.env.enabled=true

# Custom app info for the Actuator info endpoint
info.app.name=Task Tracker API
info.app.version=1.0.0
info.app.description=A Spring Boot REST API for tracking tasks

What does this configuration do?

  • spring.datasource.* tells Spring Boot where your PostgreSQL database lives and how to authenticate.
  • spring.jpa.hibernate.ddl-auto=update automatically creates or updates database tables to match your Java entity classes.
  • spring.jpa.show-sql=true prints every SQL query to the console so you can see what JPA is doing under the hood.
  • spring.jpa.open-in-view=false disables a legacy web pattern that can cause unexpected database queries in your controller layer.
  • management.endpoints.web.exposure.include explicitly exposes the health, info, and metrics Actuator endpoints over HTTP. Only the health endpoint is exposed by default.
  • info.app.* properties populate the Actuator /actuator/info endpoint with your custom application metadata.
  • Replace your-postgres-username-here with your actual PostgreSQL username (often your macOS username or postgres).
  • Replace your-postgres-password-here with your PostgreSQL password (leave it as-is if your local PostgreSQL uses no password).
  • Save the file with Cmd+S (macOS) or Ctrl+S (Windows).

Now run the application to confirm everything connects.

  • In IntelliJ's Project panel, navigate to src/main/java/com/example/tasktracker.
  • Right-click TasktrackerApplication.java and select Run 'TasktrackerApplication'.

What should I see?

The Run panel at the bottom of IntelliJ shows the application log. Look for a line that says Started TasktrackerApplication near the end of the output. This confirms the application started on port 8080 and connected to PostgreSQL successfully.

Before you hit the health endpoint, what do you think the response will look like for a freshly started application with no custom health indicators?

  • Switch back to your terminal and run this command:
curl http://localhost:8080/actuator/health

You should see this JSON response:

{"status":"UP"}

This confirms your Spring Boot application is running, connected to PostgreSQL, and has the Actuator health endpoint responding.

Seeing a connection error or the app won't start?

  • If the log shows a connection refused error, confirm PostgreSQL is running and your username/password in application.properties are correct.
  • If you see "database tasktracker does not exist", go back and run createdb tasktracker in your terminal.
  • If curl returns "connection refused", the app may not have started. Check the IntelliJ console for error messages.
  • help me fix my Spring Boot application that won't connect to PostgreSQL

✔️ Awesome, I've got everything!

Great. Double check you've saved application.properties and the app is still running in IntelliJ.

ⓧ I'd like to double check the full code

Here is the complete application.properties file. Compare it against yours (remember to substitute your own PostgreSQL credentials):

spring.application.name=tasktracker

spring.datasource.url=jdbc:postgresql://localhost:5432/tasktracker
spring.datasource.username=your-postgres-username-here
spring.datasource.password=your-postgres-password-here

spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.jpa.open-in-view=false

management.endpoints.web.exposure.include=health,info,metrics
management.info.env.enabled=true

info.app.name=Task Tracker API
info.app.version=1.0.0
info.app.description=A Spring Boot REST API for tracking tasks

Your project foundation is solid. The application starts, connects to PostgreSQL, and exposes a health endpoint. Next up, you'll create the Task entity and watch Spring Boot auto-generate the database table for you.

Create the Task Entity and Repository

Your Spring Boot application is running and connected to PostgreSQL. Now you need a data model that tells the framework how to store your tasks in the database.

In this step, you will define a Java class that maps directly to a PostgreSQL table. When the application restarts, Spring Data JPA reads your class and auto-creates the table for you.

In this step, get ready to:
  • Define a TaskStatus enum for task states.
  • Create a Task entity class mapped to a PostgreSQL table.
  • Set up a repository interface for database access.
Create the TaskStatus enum

Before building the entity, you need an enum that represents the three possible states a task can be in.

  • In IntelliJ's Project tool window on the left, right-click the src/main/java/com/example/tasktracker folder.
  • Select New then Package.
  • Type model and press Enter to create the package.
  • Right-click the new model package, select New then Java Class.
  • Choose Enum from the dropdown, type TaskStatus, and press Enter.
  • Replace the file contents with the following code:
package com.example.tasktracker.model;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}

What does this enum do?

This enum defines the three allowed states for a task. JPA stores the enum value as a string in PostgreSQL (e.g. TODO), which keeps your database readable.

  • Save TaskStatus.java with Cmd+S (macOS) or Ctrl+S (Windows).
  • Confirm TaskStatus appears under the model package in the Project tool window.
Create the Task entity class

The entity class maps each field to a column in PostgreSQL. Jakarta Bean Validation annotations enforce rules like "title is required" at the API layer.

  • Right-click the model package, select New then Java Class.
  • Name it Task and press Enter.
  • Replace the file contents with the following code to define the package, imports, class declaration, and first two fields:
package com.example.tasktracker.model;

import jakarta.persistence.Column;
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 jakarta.persistence.Table;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

import java.time.LocalDateTime;

@Entity
@Table(name = "task")
public class Task {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @NotBlank(message = "Title is required")
    @Size(max = 100, message = "Title must be 100 characters or less")
    @Column(nullable = false, length = 100)
    private String title;

What does this code do?

  • @Entity tells JPA this class maps to a database table.
  • @Table(name = "task") sets the exact table name in PostgreSQL.
  • @Id and @GeneratedValue(strategy = GenerationType.IDENTITY) let PostgreSQL auto-generate a unique ID for each row.
  • @NotBlank and @Size are validation annotations that reject empty or oversized titles before they reach the database.
  • Directly below the title field (still inside the class braces), add the remaining fields, constructor, and getters/setters:
    private String description;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskStatus status = TaskStatus.TODO;

    @Min(value = 1, message = "Priority must be at least 1")
    @Max(value = 5, message = "Priority must be at most 5")
    private int priority;

    @Column(name = "created_at", updatable = false)
    private LocalDateTime createdAt;

    public Task() {
        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 TaskStatus getStatus() { return status; }
    public void setStatus(TaskStatus status) { this.status = status; }
    public int getPriority() { return priority; }
    public void setPriority(int priority) { this.priority = priority; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
}

What does this code do?

  • @Enumerated(EnumType.STRING) stores the status as a readable string (TODO, IN_PROGRESS, DONE) in the database column.
  • @Min and @Max constrain priority to a 1-5 range.
  • The no-arg constructor sets createdAt to the current time whenever a new task is created.
  • Getters and setters let Spring (and JPA) read and write each field.
  • Save Task.java.
  • Restart your application by clicking the green Run button in IntelliJ (or pressing Shift+F10 on Windows / Ctrl+R on macOS).
  • Watch the console output. You should see Hibernate SQL statements including create table task because spring.jpa.show-sql=true is set in your properties file.

Application failing to start?

  • Check that TaskStatus.java is in the same model package as Task.java. The Task class references TaskStatus directly, so they must share a package.
  • Make sure Task.java has a closing } brace at the very end of the file.
  • Verify your PostgreSQL tasktracker database is still running. The application cannot start if the database connection fails.

Still stuck? Help me debug why my Spring Boot app won't start after adding the Task entity.

✔️ Awesome, I've got everything!

Great. Make sure you have saved Task.java and TaskStatus.java in the model package.

ⓧ I'd like to double check the full code

Your Task.java file is longer than 30 lines. Scroll up and confirm both code blocks are present in order:

  • Block 1: the package declaration, all imports, @Entity and @Table annotations, class declaration, the id field, and the title field.
  • Block 2: the description, status, priority, and createdAt fields, the constructor, all getters and setters, and the closing } brace.

Your TaskStatus.java should look exactly like this:

package com.example.tasktracker.model;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}
Create the TaskRepository interface

A repository gives you database operations (save, find, delete) without writing any SQL. Spring Data JPA generates the implementation at runtime based on the interface you define.

  • Right-click the src/main/java/com/example/tasktracker folder in the Project tool window.
  • Select New then Package, type repository, and press Enter.
  • Right-click the repository package, select New then Java Class.
  • Choose Interface, name it TaskRepository, and press Enter.
  • Replace the file contents with the following code:
package com.example.tasktracker.repository;

import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {
}

What does this interface do?

By extending JpaRepository<Task, Long>, Spring Data JPA auto-generates methods like findAll(), findById(), save(), and delete() at runtime.

You get full CRUD operations on your task table without writing a single line of SQL.

  • Save TaskRepository.java.
  • Restart the application again from IntelliJ.

Before you check the database, predict: do you think PostgreSQL has already created the task table, or does it need the repository to do so?

  • Verify the table exists by running this command in your system terminal (not IntelliJ's run console):
psql tasktracker -c "\dt"

You should see a table named task in the output list. JPA created it from your @Entity class because spring.jpa.hibernate.ddl-auto=update is set in application.properties.

Why did the table appear without writing SQL?

The ddl-auto=update setting tells Hibernate to compare your entity class against the database schema on startup. If a table is missing or a column was added, Hibernate generates the DDL and runs it automatically.

Don't see the task table?

  • Make sure the application started successfully. Check IntelliJ's console for Started TasktrackerApplication with no errors above it.
  • Confirm spring.jpa.hibernate.ddl-auto=update is in your application.properties file. Without this, Hibernate will not create tables.
  • Check that your PostgreSQL username and password in application.properties match the credentials you use with psql.

Need more help? Help me figure out why the task table was not created in PostgreSQL.

Your data model is mapped and the table is live in PostgreSQL. Next up, you will build the REST controller that lets you create, read, update, and delete tasks through HTTP requests.

Build the REST Controller with CRUD Endpoints

You have a data model and a repository ready to persist tasks. The missing piece is a way for the outside world to interact with them over HTTP.

In this step, you'll create a REST controller that maps HTTP requests directly to repository operations. This approach skips the service layer on purpose to get you a working, testable API as fast as possible.

In this step, get ready to:
  • Create a controller class that handles all task-related HTTP requests.
  • Implement GET, POST, PUT, and DELETE endpoints with proper HTTP status codes.
  • Test the full CRUD flow using curl.
Create the controller class

A @RestController tells Spring that every method in this class returns data (JSON by default) rather than an HTML view. The @RequestMapping annotation sets a base URL path for all endpoints in the class.

  • In the IntelliJ Project panel on the left, right-click the com.example.tasktracker package inside src/main/java.
  • Select New then Package and name it controller.
  • Right-click the new controller package, select New then Java Class, and name it TaskController.
  • Replace the generated content with the following code to set up the class skeleton:
package com.example.tasktracker.controller;

import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import com.example.tasktracker.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 org.springframework.web.server.ResponseStatusException;

import java.util.List;

@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskRepository taskRepository;

    public TaskController(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }
}

What does this code do?

This sets up the foundation of your controller with all the imports you'll need.

  • @RestController marks the class as an HTTP endpoint handler that returns JSON.
  • @RequestMapping("/api/tasks") makes every endpoint in this class start with /api/tasks.
  • Spring automatically injects the TaskRepository through the constructor. No new keyword needed.
Add the read endpoints

The GET endpoints let clients retrieve tasks. One returns all tasks, and the other finds a single task by its ID.

  • Inside TaskController.java, add the following two methods above the closing } brace of the class:
    @GetMapping
    public List<Task> getAllTasks() {
        return taskRepository.findAll();
    }

    @GetMapping("/{id}")
    public Task getTaskById(@PathVariable Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }

What does this code do?

These two methods handle read requests for your task data.

  • getAllTasks() responds to GET /api/tasks and returns every task in the database as a JSON array.
  • getTaskById() responds to GET /api/tasks/{id} where {id} is a number in the URL path.
  • If no task with that ID exists, ResponseStatusException(HttpStatus.NOT_FOUND) triggers a 404 response automatically.
  • Save TaskController.java.
  • Restart the application by clicking the green play button in IntelliJ (or stop and re-run the main class).
  • Open a terminal and test the GET endpoint by running:
curl http://localhost:8080/api/tasks

You should see an empty JSON array [] because no tasks exist yet.

Seeing a connection error?

  • Make sure the application is running. Check the IntelliJ console for Started TasktrackerApplication.
  • Confirm nothing else is using port 8080. Stop any other servers that might be running.
  • If you see a compilation error, check that the controller package is inside com.example.tasktracker. Spring Boot only scans subpackages of the main class's package.

My curl returns an error instead of an empty array.

Add the write endpoints

The write endpoints let clients create, update, and delete tasks. Each uses a different HTTP method to signal its intent.

  • In TaskController.java, add the following three methods below getTaskById and above the closing } brace:
    @PostMapping
    public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
        Task saved = taskRepository.save(task);
        return ResponseEntity.status(HttpStatus.CREATED).body(saved);
    }

    @PutMapping("/{id}")
    public Task updateTask(@PathVariable Long id, @Valid @RequestBody Task taskDetails) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        task.setTitle(taskDetails.getTitle());
        task.setDescription(taskDetails.getDescription());
        task.setStatus(taskDetails.getStatus());
        task.setPriority(taskDetails.getPriority());
        return taskRepository.save(task);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
        taskRepository.delete(task);
        return ResponseEntity.noContent().build();
    }

What does this code do?

Each method maps to a standard HTTP verb for CRUD operations.

  • createTask() handles POST /api/tasks. The @Valid annotation triggers your Jakarta validation rules (like @NotBlank on title). It returns HTTP 201 Created with the saved task.
  • updateTask() handles PUT /api/tasks/{id}. It finds the existing task, copies over the new field values, and saves.
  • deleteTask() handles DELETE /api/tasks/{id}. It returns HTTP 204 No Content, which means the deletion succeeded but there is no response body.
  • Save TaskController.java and restart the application.

Before you test, what HTTP status code do you think a successful POST will return?

  • Create your first task by running:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","description":"Build a REST API","status":"TODO","priority":3}'

You should see a JSON response with a generated id field and a 201 status. The response proves your task was saved to PostgreSQL and returned with all its fields.

Getting a 400 or 500 error?

  • A 400 error means validation failed. Check that your JSON includes a non-empty title and a priority between 1 and 5.
  • A 500 error often means the database connection failed. Confirm PostgreSQL is running and your application.properties credentials are correct.
  • Make sure the status value matches one of your enum values exactly: TODO, IN_PROGRESS, or DONE.

My POST request is returning an error.

Test the full CRUD flow

A real API needs every operation tested. You will run through the complete create, read, update, delete cycle to confirm everything works end to end.

  • List all tasks to confirm your earlier POST was saved:
curl http://localhost:8080/api/tasks

You should see a JSON array containing the task you just created.

  • Get a single task by its ID (replace 1 with the ID from your POST response if different):
curl http://localhost:8080/api/tasks/1
  • Update the task's status to IN_PROGRESS by running:
curl -X PUT http://localhost:8080/api/tasks/1 -H "Content-Type: application/json" -d '{"title":"Learn Spring Boot","description":"Build a REST API","status":"IN_PROGRESS","priority":3}'

The response shows the same task with status now set to IN_PROGRESS.

  • Delete the task by running:
curl -X DELETE http://localhost:8080/api/tasks/1

A successful delete returns no body (HTTP 204). Confirm it's gone by listing all tasks again:

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

You should see [] again. The task has been deleted from PostgreSQL.

Why inject the repository directly?

Putting database calls directly in the controller is quick but creates tight coupling between your HTTP layer and your data layer. In the next step, you'll extract a service layer that separates these concerns properly.

This shortcut gets you a testable API fast so you can see the full request-response cycle. Then you refactor.

✔️ Awesome, I've got everything!

Great. Confirm you have saved TaskController.java and the application restarts without errors.

ⓧ I'd like to double check the full code

Your TaskController.java should contain these sections in order. Scroll through your file and confirm each one is present:

  • The package declaration and all imports (including ResponseStatusException from org.springframework.web.server).
  • The class annotated with @RestController and @RequestMapping("/api/tasks"), plus the constructor injecting TaskRepository.
  • getAllTasks() with @GetMapping returning taskRepository.findAll().
  • getTaskById() with @GetMapping("/{id}") using orElseThrow to throw ResponseStatusException(HttpStatus.NOT_FOUND).
  • createTask() with @PostMapping returning ResponseEntity.status(HttpStatus.CREATED).body(saved).
  • updateTask() with @PutMapping("/{id}") copying fields and saving.
  • deleteTask() with @DeleteMapping("/{id}") returning ResponseEntity.noContent().build().

Your API handles every CRUD operation and returns proper HTTP status codes. Next up, you'll extract a service layer and add structured error handling so bad requests get clean, standards-compliant JSON error messages instead of raw stack traces.

Extract a Service Layer and Handle Errors Properly

Your API works, but there is a structural problem lurking beneath the surface. The controller handles HTTP requests, business logic, and database calls all in one place. This tight coupling makes the code harder to test, maintain, and extend.

There is also an error quality problem. When you request a task that does not exist, the response is a generic ProblemDetail with no useful detail message telling you what went wrong. In this step, you will introduce a proper service layer for clean separation and a global exception handler that returns structured, informative error responses.

In this step, get ready to:
  • Create a service layer that encapsulates all business logic separately from HTTP handling.
  • Build a global exception handler that returns RFC 9457 ProblemDetail JSON responses.
  • Refactor the controller to be thin and delegate all logic to the service.
Create the custom exception and service layer

Before you build the fix, take a look at what the current error looks like. Make sure your application is still running in IntelliJ.

  • Request a task ID that does not exist by running this command:
curl http://localhost:8080/api/tasks/999

What do you think the response will look like? Before you read on, predict what fields you expect in the JSON.

You will see a response with a generic title like "Not Found" and status 404, but no detail field telling you which task or what went wrong. That is the gap you are about to close.

The fix starts with a custom exception class. When a task is not found, the service will throw this exception with a message that includes the ID.

  • In IntelliJ, right-click on src/main/java/com/example/tasktracker in the project tree.
  • Select New then Package and name it exception.
  • Inside the exception package, create a new Java class called TaskNotFoundException and paste the following code:
package com.example.tasktracker.exception;

public class TaskNotFoundException extends RuntimeException {

    // Accepts the missing task ID and builds a descriptive message
    public TaskNotFoundException(Long id) {
        super("Task not found with id: " + id);
    }
}

What does this code do?

This class extends RuntimeException so Spring can catch it without requiring checked-exception boilerplate. The constructor takes the missing ID and builds a human-readable message like "Task not found with id: 42".

Next, the service needs a findByStatus query method on the repository so it can filter tasks. This is a single line addition.

  • Open TaskRepository.java in the repository package.
  • Find this line:
public interface TaskRepository extends JpaRepository<Task, Long> {
}
  • Add the following method declaration inside the interface, so it looks like this:
public interface TaskRepository extends JpaRepository<Task, Long> {

    List<Task> findByStatus(TaskStatus status);
}

What does this code do?

Spring Data JPA automatically implements query methods based on the method name. By declaring findByStatus, Spring generates the SQL WHERE status = ? for you. No implementation code needed.

Now create the service class that will hold all the business logic currently living in the controller.

  • Right-click on src/main/java/com/example/tasktracker and create a new package called service.
  • Inside the service package, create a new Java class called TaskService and paste the following code:
package com.example.tasktracker.service;

import com.example.tasktracker.exception.TaskNotFoundException;
import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import com.example.tasktracker.repository.TaskRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class TaskService {

    private final TaskRepository taskRepository;

    // Spring injects the repository via constructor injection
    public TaskService(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    // Returns all tasks, or filters by status if provided
    public List<Task> findAll(TaskStatus status) {
        if (status != null) {
            return taskRepository.findByStatus(status);
        }
        return taskRepository.findAll();
    }

    // Throws TaskNotFoundException if the ID does not exist
    public Task findById(Long id) {
        return taskRepository.findById(id)
                .orElseThrow(() -> new TaskNotFoundException(id));
    }

What does this code do?

The @Service annotation tells Spring to manage this class as a bean. The findAll method conditionally filters by status. The findById method throws your custom TaskNotFoundException instead of the generic ResponseStatusException the controller used before.

  • Continue adding the remaining methods below the findById method in the same file:
    // Saves a new task to the database
    public Task create(Task task) {
        return taskRepository.save(task);
    }

    // Updates an existing task's fields
    public Task update(Long id, Task taskDetails) {
        Task task = findById(id);
        task.setTitle(taskDetails.getTitle());
        task.setDescription(taskDetails.getDescription());
        task.setStatus(taskDetails.getStatus());
        task.setPriority(taskDetails.getPriority());
        return taskRepository.save(task);
    }

    // Deletes a task, throwing not-found if the ID is invalid
    public void delete(Long id) {
        Task task = findById(id);
        taskRepository.delete(task);
    }
}

What does this code do?

The create method delegates directly to the repository. The update method reuses findById to throw an exception if the task does not exist, then updates the fields and saves. The delete method follows the same pattern.

  • Save all three files: TaskNotFoundException.java, TaskRepository.java, and TaskService.java.
  • Restart the application from IntelliJ (click the green run button or press Ctrl+Shift+F10).

The console should show Started TasktrackerApplication with no errors. The new service bean is registered, but nothing uses it yet.

Application fails to start?

Check that TaskNotFoundException is in the com.example.tasktracker.exception package and that the TaskService import matches com.example.tasktracker.exception.TaskNotFoundException. Mismatched package paths cause compilation failures.

Also verify that you added List<Task> findByStatus(TaskStatus status); inside the TaskRepository interface braces, not below the closing brace.

help me fix a Spring Boot compile error in my TaskService or TaskNotFoundException

Build the global exception handler

The service now throws TaskNotFoundException, but Spring does not yet know how to convert that into a clean JSON response. A @RestControllerAdvice class acts as a centralized error translator for your entire API.

  • Inside the exception package, create a new Java class called GlobalExceptionHandler and paste this first handler method:
package com.example.tasktracker.exception;

import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

import java.util.List;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Catches TaskNotFoundException and returns a 404 ProblemDetail
    @ExceptionHandler(TaskNotFoundException.class)
    public ProblemDetail handleTaskNotFound(TaskNotFoundException ex) {
        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
                HttpStatus.NOT_FOUND, ex.getMessage());
        problemDetail.setTitle("Not Found");
        return problemDetail;
    }

What does this code do?

The @RestControllerAdvice annotation makes this class intercept exceptions thrown by any controller. The @ExceptionHandler method catches your custom TaskNotFoundException and returns a ProblemDetail object. Spring automatically serializes it to RFC 9457 JSON with type, title, status, and detail fields.

  • Add the second handler method below the first one in the same file:
    // Catches validation failures and returns a 400 ProblemDetail with error list
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) {
        List<String> errors = ex.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .collect(Collectors.toList());

        ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(
                HttpStatus.BAD_REQUEST, "Validation failed");
        problemDetail.setTitle("Bad Request");
        problemDetail.setProperty("errors", errors);
        return problemDetail;
    }
}

What does this code do?

This handler catches MethodArgumentNotValidException, which Spring throws when @Valid annotations on a request body fail. It collects every field error into a list of readable strings like title: Title is required. The setProperty call adds a custom errors array to the ProblemDetail JSON.

  • Save GlobalExceptionHandler.java.
Refactor the controller to use the service

The controller currently does everything itself. Now that the service layer handles business logic and the exception handler manages errors, the controller becomes a thin HTTP adapter.

  • Open TaskController.java in the controller package.
  • Select all the file contents and replace them with this class setup:
package com.example.tasktracker.controller;

import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import com.example.tasktracker.service.TaskService;
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.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

// Thin HTTP adapter that delegates to TaskService
@RestController
@RequestMapping("/api/tasks")
public class TaskController {

    private final TaskService taskService;

    public TaskController(TaskService taskService) {
        this.taskService = taskService;
    }

What changed?

The controller now imports TaskService instead of TaskRepository. The ResponseStatusException import is gone entirely. A new @RequestParam import appears because the GET endpoint will now accept an optional status filter.

  • Continue adding the endpoint methods below the constructor in the same file:
    // Accepts an optional status filter as a query parameter
    @GetMapping
    public List<Task> getAllTasks(@RequestParam(required = false) TaskStatus status) {
        return taskService.findAll(status);
    }

    @GetMapping("/{id}")
    public Task getTaskById(@PathVariable Long id) {
        return taskService.findById(id);
    }

    @PostMapping
    public ResponseEntity<Task> createTask(@Valid @RequestBody Task task) {
        Task created = taskService.create(task);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }

    @PutMapping("/{id}")
    public Task updateTask(@PathVariable Long id, @Valid @RequestBody Task task) {
        return taskService.update(id, task);
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable Long id) {
        taskService.delete(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

Every method is now a one-liner that delegates to taskService. The controller no longer knows about the repository, orElseThrow, or field-by-field updates. If the service throws TaskNotFoundException, the global exception handler catches it automatically.

  • Save TaskController.java.
  • Restart the application from IntelliJ.

The console should show Started TasktrackerApplication with no errors. Now let's see if the error handling actually improved.

Before you run the next command, predict what the JSON response will look like. Will it still be the same generic error you saw earlier, or will it include a detail message?

  • Test the not-found error handling by running:
curl http://localhost:8080/api/tasks/999

You should see a clean, structured JSON response:

{
  "type": "about:blank",
  "title": "Not Found",
  "status": 404,
  "detail": "Task not found with id: 999"
}

That detail field is the improvement. It tells the API consumer exactly which task was missing.

  • Test the validation error handling by sending invalid data:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"","priority":0}'

You should see a 400 response with a list of validation errors:

{
  "type": "about:blank",
  "title": "Bad Request",
  "status": 400,
  "detail": "Validation failed",
  "errors": [
    "title: Title is required",
    "priority: Priority must be at least 1"
  ]
}

Still seeing a generic error without details?

Make sure you saved and restarted the application after making all changes. IntelliJ does not hot-reload new classes by default.

Verify that GlobalExceptionHandler is in the com.example.tasktracker.exception package and annotated with @RestControllerAdvice. Spring must be able to component-scan it.

help me debug my Spring Boot global exception handler not catching TaskNotFoundException

Why a service layer instead of putting logic in the controller?

Separating concerns means the controller only handles HTTP concerns (parsing requests, setting status codes), while the service holds the rules (what happens when a task is not found, how to update fields). This makes unit testing the business logic possible without booting up the full web stack.

It also means multiple controllers or scheduled jobs can reuse the same service methods without duplicating logic.

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm each of these code chunks is present in your project, in order:

  • TaskNotFoundException.java in the exception package with the constructor accepting a Long id.
  • TaskRepository.java with findByStatus(TaskStatus status) declared inside the interface.
  • TaskService.java with five methods: findAll, findById, create, update, delete.
  • GlobalExceptionHandler.java with handlers for TaskNotFoundException and MethodArgumentNotValidException.
  • TaskController.java injecting TaskService with one-liner delegation methods and an @RequestParam(required = false) TaskStatus status on the GET endpoint.

Your API now has clean separation of concerns and professional error responses. Next up, you will add filtering by status and configure Actuator to expose monitoring endpoints.

Add Filtering and Actuator Configuration

Your API now has clean architecture and structured error handling. But right now, the only way to retrieve tasks is to fetch all of them at once.

A real API needs filtering. In this step, you will add query methods so users can retrieve only tasks matching a specific status. You will also verify the Spring Boot Actuator endpoints that give you production-style health monitoring.

In this step, get ready to:
  • Add derived query methods to the repository for status filtering.
  • Test filtering tasks by status with curl.
  • Verify Actuator health and info endpoints.
Add derived query methods to TaskRepository

In Step 4, your TaskService was written to call findByStatus() on the repository when a status filter is provided. But that method does not exist in TaskRepository yet. The app will not compile until you declare it.

What are derived queries?

Spring Data JPA can automatically implement query methods based on their name. When you declare findByStatus(TaskStatus status) in a repository interface, Spring parses the method name and generates the SQL query for you.

No implementation code needed. Just the method signature in the interface.

  • In IntelliJ IDEA, open TaskRepository.java in the repository package.
  • Replace the entire file contents with this code:
package com.example.tasktracker.repository;

import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {

    // Find all tasks matching a specific status
    List<Task> findByStatus(TaskStatus status);

    // Find tasks matching both status and priority level
    List<Task> findByStatusAndPriority(TaskStatus status, int priority);
}

What does this code do?

  • findByStatus(TaskStatus status) tells Spring Data to generate a query equivalent to SELECT * FROM task WHERE status = ?.
  • findByStatusAndPriority(TaskStatus status, int priority) combines two conditions with AND. Spring parses the method name at startup and builds the query automatically.
  • Both return List<Task> so your service can pass the results straight through to the controller.
  • Save TaskRepository.java.
  • Restart the application by clicking the green Run button in IntelliJ IDEA.
  • Watch the console. You should see Started TasktrackerApplication with no errors.

Seeing a compilation error?

  • Check that the method signatures match exactly. The parameter types must be TaskStatus and int (not Integer).
  • Make sure you have the import java.util.List; statement at the top of the file.
  • Confirm the import for TaskStatus points to your model package: com.example.tasktracker.model.TaskStatus.

Still stuck? Help me debug my TaskRepository compile error.

Test filtering tasks by status

The filtering chain is now complete. Your controller accepts an optional status query parameter, passes it to the service, and the service calls your new repository method.

  • First, create a few tasks with different statuses. Run each of these curl commands in your terminal:
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Write unit tests","description":"Add JUnit tests","status":"TODO","priority":2}'
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Deploy to staging","description":"Push to staging server","status":"IN_PROGRESS","priority":4}'
curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title":"Set up CI pipeline","description":"Configure GitHub Actions","status":"TODO","priority":3}'

You should see each task returned with a generated id and status 201.

  • Before you run the next command, predict: if you filter by TODO status, how many tasks do you expect to see?
  • Test the filtering by running:
curl "http://localhost:8080/api/tasks?status=TODO"

You should see only the tasks with "status":"TODO" in the response. The IN_PROGRESS task is excluded.

  • Now filter for in-progress tasks by running:
curl "http://localhost:8080/api/tasks?status=IN_PROGRESS"

Only the "Deploy to staging" task appears. The query parameter maps directly to the enum value in your code.

Getting an empty array or all tasks?

  • The status value is case-sensitive. Use TODO or IN_PROGRESS (all caps with underscores), not todo or in_progress.
  • If you get all tasks regardless of the parameter, check that your TaskService.findAll() method has the if (status != null) check calling findByStatus.

Need more help? Why isn't my status filter returning the right tasks?

Verify Actuator endpoints

Your application.properties already has Spring Boot Actuator configured to expose health, info, and metrics endpoints. Actuator gives you built-in production monitoring without writing any extra code.

What is Spring Boot Actuator?

Actuator adds operational endpoints to your application. The /actuator/health endpoint reports whether your app and its database connection are alive. The /actuator/info endpoint exposes metadata about your application.

By default, only the health endpoint is exposed over HTTP. Your configuration explicitly includes health,info,metrics to make all three accessible.

  • Check the health endpoint by running:
curl http://localhost:8080/actuator/health

You should see {"status":"UP"} confirming your application and database are healthy.

  • Now check the info endpoint to see your custom application metadata by running:
curl http://localhost:8080/actuator/info

You should see a JSON response containing your custom info properties: the app name, version, and description you defined in application.properties.

Where does the info response come from?

Any property starting with info. in your application.properties is automatically included in the /actuator/info response. The management.info.env.enabled=true setting enables this environment-based info contributor.

Getting an empty response from /actuator/info?

  • Make sure management.info.env.enabled=true is present in your application.properties. Without this, Spring Boot does not expose environment-based info.
  • Confirm your info properties start with info. (e.g. info.app.name). Properties without this prefix are not included.
  • Restart the application after any properties change. Spring Boot reads these at startup.

Still not working? Why is my Actuator info endpoint returning an empty response?

✔️ Awesome, I've got everything!

Great. Confirm you have saved TaskRepository.java and the app is running without errors.

ⓧ I'd like to double check the full code

Here is the complete TaskRepository.java file. This is the only file you changed in this step.

package com.example.tasktracker.repository;

import com.example.tasktracker.model.Task;
import com.example.tasktracker.model.TaskStatus;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface TaskRepository extends JpaRepository<Task, Long> {

    List<Task> findByStatus(TaskStatus status);

    List<Task> findByStatusAndPriority(TaskStatus status, int priority);
}

Your API now supports smart filtering by status. You have also confirmed that Actuator is monitoring your application health and exposing custom metadata. Next up, you will tackle the Secret Mission and add pagination support for handling large datasets.

Secret mission

Add Pagination and Multi-Field Search

Real-world APIs serve thousands of records. Returning all of them in one response is a performance disaster. In this secret mission, you'll upgrade your list endpoint with Spring Data's built-in pagination support so clients can request specific pages, control page size, and sort results.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally with a PostgreSQL database on your machine, so there are no ongoing costs.

Resources you used:

  • A PostgreSQL database named tasktracker on your local machine.
  • The Spring Boot project folder containing your Java source files and Maven configuration.

Keep everything running

No action needed. Choose this if you're still actively building on your Task Tracker API or plan to extend it soon.

  • Your tasktracker database stays on your local PostgreSQL server.
  • Your project files remain in the folder where you unzipped them.
  • PostgreSQL runs as a background service on your machine but consumes minimal resources when idle.

Pause - I'll come back to this later

Stop the Spring Boot application to free up the port. Your database and files stay intact for next time.

  • Stop the application in IntelliJ IDEA by clicking the red square (Stop) button in the Run panel.
  • Your tasktracker database and all task data remain in PostgreSQL, ready for when you come back.
  • To resume later, open the project in IntelliJ IDEA and run the main class again.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your database and project files.

  • Stop the application in IntelliJ IDEA if it is still running.
  • Drop the PostgreSQL database by running this command in your terminal:
dropdb tasktracker
  • Delete the project folder from your machine. On macOS or Linux, run:
rm -rf ~/Desktop/tasktracker

On Windows, run:

rmdir /s /q %USERPROFILE%\Desktop\tasktracker
  • Confirm the database is gone by running:
psql -l | grep tasktracker

If nothing is returned, the database has been successfully removed.

Nice Work!

Nice Work!

Nice work! You just built a production-style REST API from scratch using Spring Boot 4.1.0 and PostgreSQL.

You've learned how to:

  • Build a full CRUD REST API with proper layered architecture (Controller, Service, Repository) connected to a PostgreSQL database using Spring Data JPA.
  • Validate incoming data with Jakarta Bean Validation and return standards-compliant error responses using @RestControllerAdvice and RFC 9457 ProblemDetail.
  • Configure Spring Boot Actuator to expose health, info, and metrics endpoints for monitoring your application in production.
  • Secret Mission: Added pagination and multi-field filtering using Spring Data's Pageable support, so your API can handle thousands of tasks without melting.

Ready to quiz yourself?