Build a Task Management REST API

Build a Spring Boot REST API with layered architecture and CRUD operations.

Introduction

30 Second Summary

Every app you use daily relies on a backend API to store and retrieve data. Building one yourself is the fastest way to prove you understand how professional software actually works.

In this project, you will build a Task Management REST API using Java and Spring Boot that handles creating, reading, updating, and deleting tasks with professional enterprise architecture patterns.

What You'll Build

A fully functional REST API with five endpoints, layered architecture, input validation, and centralized error handling that runs locally using an in-memory database.

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

  • A running Spring Boot REST API with endpoints for creating, listing, updating, and deleting tasks.
  • Professional layered architecture separating your Controller, Service, and Repository layers.
  • Input validation and a global exception handler that returns standardized error responses.
  • Secret Mission: Add filtering, sorting, and pagination to your task retrieval endpoint.

Are there any prerequisites?

No prior Java or Spring Boot experience is required. This guide walks you through installing all the tools you need from scratch, including JDK 21 and VS Code extensions.

Before We Start

Before you can build a REST API, you need three things on your machine: a Java Development Kit to compile and run Java code, a code editor with Java intelligence, and the right extensions to connect the two.

In this step, you will install JDK 21, configure the environment variables that tools like Maven rely on, and add Java and Spring Boot extensions to VS Code.

In this step, get ready to:
  • Install JDK 21 and verify it runs.
  • Set the JAVA_HOME environment variable and update PATH.
  • Install Java and Spring Boot extensions in VS Code.
Install JDK 21

The JDK is the toolkit that compiles your Java source code into bytecode and runs it. Spring Boot 4.1.0 requires at least Java 17, but JDK 21 is the current Long-Term Support release recommended for production work.

  • Check if Java is already installed by opening a Command Prompt. Press the Windows key to open your search bar.
  • Type cmd and press Enter to open Command Prompt.
  • Check your Java version by running this command:
java -version

✔️ I see version 21 or higher

Your JDK is ready. Skip ahead to the next substep where you set the JAVA_HOME environment variable.

ⓧ I see an older version

You have an older Java version installed. You need JDK 21 for this project. Follow the steps below to install it alongside your current version.

  • Open your browser and navigate to the Oracle JDK downloads page:
  • https://www.oracle.com/java/technologies/downloads/
  • Scroll to the Java 21 section and click the Windows tab.
  • Download the x64 Installer (the .exe file).
  • Double-click the downloaded file to launch the installer.
  • Click Next through the wizard, keeping the default installation directory at C:\Program Files\Java\jdk-21.
  • Once the installer finishes, close your current Command Prompt and open a new one.
  • Run java -version again. You should now see version 21 in the output.

Why a new Command Prompt?

Environment variable changes only take effect in new terminal sessions. Any Command Prompt you had open before the install will still use the old PATH values.

ⓧ Command not found

Java is not installed on your machine yet. Follow the steps below to install JDK 21.

  • Open your browser and navigate to the Oracle JDK downloads page:
  • https://www.oracle.com/java/technologies/downloads/
  • Scroll to the Java 21 section and click the Windows tab.
  • Download the x64 Installer (the .exe file).
  • Double-click the downloaded file to launch the installer.
  • Click Next through the wizard, keeping the default installation directory at C:\Program Files\Java\jdk-21.
  • Once the installer finishes, close your current Command Prompt and open a new one.
  • Run java -version to confirm. You should see version 21 in the output.

Still seeing 'not recognized' after installing?

Make sure you opened a brand new Command Prompt after the installer finished. The Oracle installer updates PATH automatically, but existing terminals will not pick up the change.

If a new prompt still fails, the installer may not have updated PATH. You will fix this in the next substep when you set JAVA_HOME and add its bin folder to PATH manually.

help me troubleshoot why java -version is not recognized after installing JDK 21 on Windows

Set the JAVA_HOME environment variable

Many Java tools (including Maven, which Spring Boot uses to build your project) look for a variable called JAVA_HOME to locate the JDK. The Oracle installer does not set this automatically, so you need to add it yourself.

  • Press the Windows key to open your search bar.
  • Type Environment Variables and select Edit the system environment variables.
  • In the System Properties window, click Environment Variables.
  • Under System variables, click New.
  • Enter JAVA_HOME as the Variable name.
  • Enter C:\Program Files\Java\jdk-21 as the Variable value.
  • Click OK to save the new variable.

Now add the JDK bin folder to your PATH so you can run Java commands from any terminal.

  • In the same System variables list, find the variable named Path and select it.
  • Click Edit.
  • Click New and type %JAVA_HOME%\bin.
  • Click OK on all open dialogs to close them.

Why not just rely on the installer's PATH entry?

The Oracle installer adds a javapath shim to PATH that forwards to the JDK. Setting JAVA_HOME explicitly ensures that Maven and other build tools can locate the JDK root directory directly. It also protects you if another Java installation later changes the shim order.

  • Open a new Command Prompt (existing ones will not see the change).
  • Verify JAVA_HOME by running this command:
echo %JAVA_HOME%
  • You should see C:\Program Files\Java\jdk-21 printed in the output.
  • Confirm Java still works by running:
java -version
  • The output should show 21 in the version number.

JAVA_HOME shows nothing or java -version fails?

Double-check that you created JAVA_HOME under System variables, not User variables. Also confirm the path does not include the bin folder. JAVA_HOME should point to the JDK root (C:\Program Files\Java\jdk-21), not C:\Program Files\Java\jdk-21\bin.

Make sure you opened a brand new Command Prompt after saving the changes. Environment variable edits do not apply to already-open terminals.

help me fix JAVA_HOME not being recognized on Windows after I set it in Environment Variables

Install Java and Spring Boot extensions in VS Code

VS Code does not understand Java out of the box. You need two extension packs: one for core Java language support and one for Spring Boot tooling like auto-complete and run shortcuts.

  • Press the Windows key to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the Extensions panel by pressing Ctrl+Shift+X.
  • In the search bar at the top, type Extension Pack for Java.
  • Find the result published by Microsoft and click Install.
  • Once installed, clear the search bar and type Spring Boot Extension Pack.
  • Find the result published by VMware and click Install.
  • After both extensions are installed, restart VS Code by closing it and reopening it.

What do these extensions provide?

The Extension Pack for Java bundles language support, a debugger, a test runner, and Maven integration. The Spring Boot Extension Pack adds Spring-aware auto-complete, a boot dashboard for running your app, and Spring Initializr support for generating new projects.

Together, they turn VS Code into a lightweight Java IDE.

  • Confirm the extensions are active by opening the Extensions panel (Ctrl+Shift+X) again.
  • Type @installed in the search bar. You should see both Extension Pack for Java and Spring Boot Extension Pack listed.

Extensions not showing as installed?

Make sure you restarted VS Code after installing. Some extension packs require a restart before they appear in the installed list.

If the install button is greyed out, check that your VS Code is up to date. Go to Help then Check for Updates.

help me troubleshoot VS Code extensions not installing for Java development

Your development environment is ready. Next up, you will generate a full Spring Boot project and see it run for the first time.

Create the Spring Boot Project

Your development environment is ready with Java and VS Code configured. Now you need a project to work in.

Spring Boot projects have a specific folder structure and configuration that would be tedious to set up manually. Spring Initializr is an official web tool that generates a ready-to-go project with all the right structure and dependencies already wired together.

In this step, get ready to:
  • Generate a Spring Boot project with the correct dependencies.
  • Configure the H2 in-memory database for local development.
  • Run the application and verify it starts successfully.
Generate the project on Spring Initializr

Spring Initializr lets you pick your build tool, language, framework version, and dependencies from a web form. It then generates a zip file containing a fully structured project.

  • Open your browser and navigate to https://start.spring.io.
  • Under Project, select Maven.
  • Under Language, select Java.
  • Under Spring Boot, select 4.1.0.
  • In the Project Metadata section, set Group to com.example.
  • Set Artifact to taskmanager.
  • Set Packaging to Jar.
  • Set Java to 21.
  • Click ADD DEPENDENCIES (or press Ctrl+B).
  • Search for Spring Web and select it.
  • Search for Spring Data JPA and select it.
  • Search for Validation and select it.
  • Search for H2 Database and select it.
  • Click GENERATE (or press Ctrl+Enter) to download the zip file.

What are these dependencies?

Spring Web adds spring-boot-starter-webmvc, which gives your app a web server (Tomcat) and the ability to handle HTTP requests.

Spring Data JPA provides a framework for talking to databases without writing raw SQL. Validation lets you enforce rules on incoming data (like requiring a title field). H2 is a lightweight in-memory database that runs inside your app, so anyone can clone and run your project without installing a separate database.

Open the project in VS Code
  • Find the downloaded taskmanager.zip file in your Downloads folder.
  • Extract the zip to your Desktop.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the extracted taskmanager folder on your Desktop, then click Select Folder.

In the Explorer sidebar on the left, you should see the project structure. The key files are src/main/java/com/example/taskmanager/TaskmanagerApplication.java (your app's entry point) and src/main/resources/application.properties (your app's configuration).

What is the Maven wrapper?

You'll also see mvnw and mvnw.cmd files in the project root. These are the Maven wrapper scripts that let you build and run the project without installing Maven globally. On Windows, you use mvnw.cmd.

Configure the H2 database

The generated application.properties file only has the app name. You need to add configuration so Spring Data JPA knows how to connect to the H2 database and so you can inspect the database through a web console.

  • In the Explorer sidebar, expand src > main > resources.
  • Click application.properties to open it.
  • Replace the entire contents of the file with this configuration:
spring.application.name=taskmanager
spring.datasource.url=jdbc:h2:mem:taskdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console

What does this configuration do?

  • spring.datasource.url creates an in-memory database called taskdb. The DB_CLOSE_ON_EXIT=FALSE flag keeps the database alive while the app runs.
  • spring.jpa.hibernate.ddl-auto=update tells JPA to automatically create or update database tables based on your entity classes.
  • spring.jpa.show-sql=true prints database queries to the terminal so you can see what JPA does behind the scenes.
  • spring.h2.console.enabled=true turns on a web-based database viewer at /h2-console.
  • Save application.properties by pressing Ctrl+S.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here is the complete application.properties file:

spring.application.name=taskmanager
spring.datasource.url=jdbc:h2:mem:taskdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
Run the application

With the project configured, you can start the application using the Maven wrapper that came bundled with the generated project.

  • Open a terminal in VS Code by pressing Ctrl+` (the backtick key, above Tab).
  • Start the application by running this command:
.\mvnw.cmd spring-boot:run

What should I see?

The first run downloads all required dependencies, so it may take a minute or two. When it finishes, you should see a line containing Started TaskmanagerApplication in the terminal output.

  • Open your browser and navigate to http://localhost:8080.

You should see a "Whitelabel Error Page" in your browser. This is expected because you haven't created any endpoints yet. The error page confirms that Spring Boot is running and listening for requests.

Not seeing the app start?

  • If you see a "not recognized" error, make sure your terminal is open inside the taskmanager folder (the one containing mvnw.cmd).
  • If the build fails with a Java version error, confirm java -version shows version 21 in a new terminal.
  • If port 8080 is already in use, close any other application using that port and try again.

Why won't my Spring Boot application start?

Your Spring Boot project is alive and running. Next up, you'll define the data model that represents a task in your API.

Build the Entity and Repository Layer

Your Spring Boot project is running and the H2 database is configured. Now you need to define what a "task" actually looks like in your database.

In enterprise Java applications, the data model lives in an entity class that maps directly to a database table. You will also create a repository interface that gives you built-in methods to create, read, update, and delete records without writing any SQL.

In this step, get ready to:
  • Create enums for task status and priority levels.
  • Build a Task entity class with JPA annotations.
  • Create a TaskRepository interface for database access.
Create the status and priority enums

Each task has a status (like TODO or DONE) and a priority level (like LOW or HIGH). Java enums define a fixed set of allowed values, which prevents invalid data from entering your database.

  • In the VS Code Explorer sidebar, right-click on src/main/java/com/example/taskmanager and select New Folder.
  • Name the folder entity.
  • Right-click on the new entity folder and select New File.
  • Name the file TaskStatus.java and paste the following code:
package com.example.taskmanager.entity;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}

What does this code do?

This enum defines the three states a task can be in: TODO (not started), IN_PROGRESS (being worked on), and DONE (completed).

Using an enum instead of a plain String means Java will reject any value that is not in this list at compile time.

  • Save TaskStatus.java.
  • Create another file in the entity folder named TaskPriority.java and paste the following code:
package com.example.taskmanager.entity;

public enum TaskPriority {
    LOW,
    MEDIUM,
    HIGH
}

What does this code do?

This enum defines three priority levels for tasks. When your API receives a request, it can only accept one of these three values for the priority field.

  • Save TaskPriority.java.
Create the Task entity class

The Task entity is the core of your data model. JPA annotations on the class tell Spring Boot how to map each field to a column in your database table.

  • Create a new file in the entity folder named Task.java and paste the following code:
package com.example.taskmanager.entity;

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.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;

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

    // Primary key, auto-incremented by the database
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    // Title is required for every task
    @Column(nullable = false)
    private String title;

    // Optional longer description
    private String description;

    // Store enum values as readable strings in the database
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskStatus status;

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskPriority priority;

    // Optional due date for the task
    private LocalDate dueDate;

    // Automatically set when a task is first saved
    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    // Automatically updated every time the task is modified
    @Column(nullable = false)
    private LocalDateTime updatedAt;

    // JPA lifecycle hook: runs before a new entity is persisted
    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    // JPA lifecycle hook: runs before an existing entity is updated
    @PreUpdate
    protected void onUpdate() {
        updatedAt = 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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

What does this code do?

The @Entity annotation tells JPA this class represents a database table. @Table(name = "tasks") sets the table name explicitly.

Each field maps to a column. @Id marks the primary key, and @GeneratedValue tells the database to auto-increment it. @Enumerated(EnumType.STRING) stores enum values as human-readable text (like "TODO") instead of numbers.

The @PrePersist and @PreUpdate methods are lifecycle hooks. They automatically set timestamps whenever a task is created or modified, so you never forget to track these dates.

  • Save Task.java.

Seeing red underlines in VS Code?

If you see errors under the import statements, give the Java extension a moment to index the new files. The imports reference packages from spring-boot-starter-data-jpa which is already in your pom.xml.

If errors persist after 10 seconds, try pressing Ctrl+Shift+P and running Java: Clean Java Language Server Workspace.

Help me fix red underlines in my Task.java entity file

Create the TaskRepository interface

Spring Data JPA provides a powerful pattern: you define an interface, and Spring automatically generates the implementation at runtime. By extending JpaRepository, your repository gets methods like findAll(), findById(), save(), and delete() without writing a single line of SQL.

  • In the Explorer sidebar, right-click on src/main/java/com/example/taskmanager and select New Folder.
  • Name the folder repository.
  • Create a new file in the repository folder named TaskRepository.java and paste the following code:
package com.example.taskmanager.repository;

import com.example.taskmanager.entity.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

// Spring Data JPA generates the implementation at runtime
@Repository
public interface TaskRepository extends JpaRepository<Task, Long> {
}

What does this code do?

The @Repository annotation marks this as a data access component that Spring manages. JpaRepository<Task, Long> tells Spring this repository works with Task entities that have a primary key of type Long.

Spring automatically provides implementations for common database operations. You get full CRUD capability with zero boilerplate code.

  • Save TaskRepository.java.
  • Stop your running application by pressing Ctrl+C in the terminal.
  • Restart the application by running:
.\mvnw.cmd spring-boot:run
  • You should see "Started TaskmanagerApplication" in the terminal output along with SQL statements showing the table creation.

Application won't start?

Check the terminal output for a specific error message. The most common issue is a typo in the package name or a missing import. Make sure all three new files (TaskStatus.java, TaskPriority.java, Task.java) are inside src/main/java/com/example/taskmanager/entity/ and that TaskRepository.java is inside src/main/java/com/example/taskmanager/repository/.

Help me fix my Spring Boot application startup error

Now verify that JPA created the table from your entity class.

  • Open your browser and navigate to http://localhost:8080/h2-console.
  • In the JDBC URL field, enter jdbc:h2:mem:taskdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE.
  • Leave User Name as sa and Password empty.
  • Click Connect.
  • In the left panel, you should see the TASKS table listed. Click on it to see all the columns that JPA created from your entity fields.

Why does the table name show as TASKS?

H2 displays table names in uppercase by default. Your @Table(name = "tasks") annotation set the name, and H2 normalizes it to uppercase for display. The columns match your entity fields: ID, TITLE, DESCRIPTION, STATUS, PRIORITY, DUE_DATE, CREATED_AT, and UPDATED_AT.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all four new files before moving on.

ⓧ I'd like to double check the full code

Here are the four files you created in this step.

package com.example.taskmanager.entity;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}
package com.example.taskmanager.entity;

public enum TaskPriority {
    LOW,
    MEDIUM,
    HIGH
}
package com.example.taskmanager.entity;

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.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;

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

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

    @Column(nullable = false)
    private String title;

    private String description;

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

    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    private TaskPriority priority;

    private LocalDate dueDate;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @Column(nullable = false)
    private LocalDateTime updatedAt;

    @PrePersist
    protected void onCreate() {
        createdAt = LocalDateTime.now();
        updatedAt = LocalDateTime.now();
    }

    @PreUpdate
    protected void onUpdate() {
        updatedAt = 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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
package com.example.taskmanager.repository;

import com.example.taskmanager.entity.Task;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

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

Your data model is in place and the database table is ready. Next up, you will build the service and DTO layer that sits between the controller and the repository, keeping your API clean and professional.

Build the Service and DTO Layer

Your database layer stores tasks, but you need to control what data flows in and out of your API.

Right now, your Task entity is tightly coupled to the database schema. Exposing it directly to clients would leak internal details and make your API fragile. In this step, you'll create DTOs (Data Transfer Objects) to define your API's contract separately from the database, then build a service layer to house all the business logic.

In this step, get ready to:
  • Create request and response DTOs with validation annotations.
  • Define a TaskService interface as a contract for business logic.
  • Implement the service with entity-to-DTO mapping and error handling.
Create the DTO classes

DTOs are plain Java classes that define the shape of data your API accepts and returns. By keeping them separate from your entity, you can change your database schema without breaking client applications.

Why not return the Task entity directly?

Returning JPA entities from your API exposes your database schema to clients. If you add a column later, every client breaks.

DTOs give you a stable contract. You decide exactly which fields clients see and send, independent of how data is stored.

  • In the VS Code Explorer sidebar, right-click the com.example.taskmanager package inside src/main/java.
  • Select New Folder and name it dto.
  • Inside the dto folder, create a new file called TaskRequest.java and paste the following code:
package com.example.taskmanager.dto;

import com.example.taskmanager.entity.TaskPriority;
import com.example.taskmanager.entity.TaskStatus;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;

public class TaskRequest {

    // Title must not be blank when creating or updating a task
    @NotBlank(message = "Title is required")
    private String title;

    private String description;

    // Status is mandatory so the API always knows a task's state
    @NotNull(message = "Status is required")
    private TaskStatus status;

    // Priority is mandatory for filtering and sorting later
    @NotNull(message = "Priority is required")
    private TaskPriority priority;

    // Due date cannot be in the past
    @FutureOrPresent(message = "Due date must be today or in the future")
    private LocalDate dueDate;

    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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
}

What does this code do?

  • @NotBlank ensures the title is not empty or whitespace-only. Spring rejects the request before it reaches your service.
  • @NotNull on status and priority means clients must always provide these values.
  • @FutureOrPresent on dueDate prevents creating tasks with deadlines that have already passed.
  • Save TaskRequest.java.
  • Create another file in the dto folder called TaskResponse.java and paste the following code:
package com.example.taskmanager.dto;

import com.example.taskmanager.entity.TaskPriority;
import com.example.taskmanager.entity.TaskStatus;
import java.time.LocalDate;
import java.time.LocalDateTime;

public class TaskResponse {

    // All fields the client receives when requesting a task
    private Long id;
    private String title;
    private String description;
    private TaskStatus status;
    private TaskPriority priority;
    private LocalDate dueDate;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}

What does this code do?

TaskResponse includes every field a client might want to see, including id, createdAt, and updatedAt which are generated by the server.

Notice there are no validation annotations here. This DTO flows outward, so Spring does not need to validate it.

  • Save TaskResponse.java.
  • Create one more file in the dto folder called ErrorResponse.java and paste the following code:
package com.example.taskmanager.dto;

import java.time.LocalDateTime;
import java.util.List;

public class ErrorResponse {

    // Standardized error shape for all API error responses
    private LocalDateTime timestamp;
    private int status;
    private String message;
    private List<String> details;

    public ErrorResponse(int status, String message, List<String> details) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.message = message;
        this.details = details;
    }

    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
    public int getStatus() { return status; }
    public void setStatus(int status) { this.status = status; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public List<String> getDetails() { return details; }
    public void setDetails(List<String> details) { this.details = details; }
}

What does this code do?

ErrorResponse gives every error a consistent shape: a timestamp, HTTP status code, human-readable message, and a list of details.

The constructor sets timestamp automatically so callers never forget to include it. Clients consuming your API can rely on this predictable format for all errors.

  • Save ErrorResponse.java.
  • You won't see a change yet. The service layer you build next will use these DTOs.
Create the TaskService interface

The service layer sits between your controller and repository. Defining it as an interface first establishes a contract that any implementation must follow.

Why use an interface?

An interface separates the "what" from the "how". Your controller depends on the interface, not a specific implementation.

This makes unit testing easier (you can swap in a mock) and demonstrates the Dependency Inversion principle that enterprise teams value.

  • In the VS Code Explorer sidebar, right-click the com.example.taskmanager package and create a new folder called service.
  • Inside the service folder, create a new file called TaskService.java and paste the following code:
package com.example.taskmanager.service;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import java.util.List;

public interface TaskService {

    // Retrieve all tasks as response DTOs
    List<TaskResponse> getAllTasks();

    // Retrieve a single task by its ID
    TaskResponse getTaskById(Long id);

    // Create a new task from a validated request
    TaskResponse createTask(TaskRequest taskRequest);

    // Update an existing task by ID
    TaskResponse updateTask(Long id, TaskRequest taskRequest);

    // Delete a task by ID
    void deleteTask(Long id);
}

What does this code do?

  • Each method accepts and returns DTOs, never the raw Task entity. This keeps your controller completely unaware of the database layer.
  • The deleteTask method returns void because a successful delete has nothing to return.
  • Save TaskService.java.
  • You won't see a change yet. The implementation class you create next brings this interface to life.
Implement the service layer

Now you'll create the actual business logic. You also need a custom exception for when a requested task does not exist in the database.

  • Right-click the com.example.taskmanager package and create a new folder called exception.
  • Inside the exception folder, create a new file called ResourceNotFoundException.java and paste the following code:
package com.example.taskmanager.exception;

// Custom exception thrown when a task is not found by ID
public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}

What does this code do?

This is a custom unchecked exception. By extending RuntimeException, Spring can catch it with a global exception handler (which you'll build in the next step) and return a clean 404 response instead of a stack trace.

  • Save ResourceNotFoundException.java.
  • Now create the service implementation. Inside the service folder, create a new file called TaskServiceImpl.java and paste the following code:
package com.example.taskmanager.service;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.entity.Task;
import com.example.taskmanager.exception.ResourceNotFoundException;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class TaskServiceImpl implements TaskService {

    private final TaskRepository taskRepository;

    // Constructor injection lets Spring provide the repository automatically
    public TaskServiceImpl(TaskRepository taskRepository) {
        this.taskRepository = taskRepository;
    }

    @Override
    public List<TaskResponse> getAllTasks() {
        // Fetch all tasks and convert each entity to a response DTO
        return taskRepository.findAll()
                .stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    @Override
    public TaskResponse getTaskById(Long id) {
        // Throw custom exception if the task does not exist
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
        return mapToResponse(task);
    }

    @Override
    public TaskResponse createTask(TaskRequest taskRequest) {
        // Convert the incoming DTO to an entity, save it, then return the response DTO
        Task task = mapToEntity(taskRequest);
        Task savedTask = taskRepository.save(task);
        return mapToResponse(savedTask);
    }

    @Override
    public TaskResponse updateTask(Long id, TaskRequest taskRequest) {
        // Find the existing task or throw not-found
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));

        // Update entity fields from the request DTO
        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        task.setStatus(taskRequest.getStatus());
        task.setPriority(taskRequest.getPriority());
        task.setDueDate(taskRequest.getDueDate());

        Task updatedTask = taskRepository.save(task);
        return mapToResponse(updatedTask);
    }

    @Override
    public void deleteTask(Long id) {
        // Verify the task exists before deleting
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
        taskRepository.delete(task);
    }

    // Convert a Task entity to a TaskResponse DTO
    private TaskResponse mapToResponse(Task task) {
        TaskResponse response = new TaskResponse();
        response.setId(task.getId());
        response.setTitle(task.getTitle());
        response.setDescription(task.getDescription());
        response.setStatus(task.getStatus());
        response.setPriority(task.getPriority());
        response.setDueDate(task.getDueDate());
        response.setCreatedAt(task.getCreatedAt());
        response.setUpdatedAt(task.getUpdatedAt());
        return response;
    }

    // Convert a TaskRequest DTO to a Task entity
    private Task mapToEntity(TaskRequest taskRequest) {
        Task task = new Task();
        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        task.setStatus(taskRequest.getStatus());
        task.setPriority(taskRequest.getPriority());
        task.setDueDate(taskRequest.getDueDate());
        return task;
    }
}

What does this code do?

  • @Service registers this class as a Spring bean so it can be injected into your controller later.
  • Constructor injection gives TaskServiceImpl access to TaskRepository without hardcoding a dependency. Spring handles the wiring automatically.
  • mapToResponse() and mapToEntity() keep conversion logic in one place. If your entity gains a new field, you update the mapper once instead of in every method.
  • Every "find by ID" call throws ResourceNotFoundException if the task is missing, giving you a consistent error signal across all operations.
  • Save TaskServiceImpl.java.
  • Verify the project compiles by running this command in the VS Code terminal:
.\mvnw.cmd spring-boot:run

You should see "Started TaskmanagerApplication" in the terminal output with no compilation errors.

Seeing a compilation error?

  • Check that your dto, service, and exception folders are inside src/main/java/com/example/taskmanager/. If they are at the wrong level, Spring cannot find the classes.
  • Verify each file has the correct package declaration at the top. For example, TaskRequest.java must start with package com.example.taskmanager.dto;.
  • Confirm that your import statements match the class names exactly. A typo in TaskPriority or TaskStatus will prevent compilation.

Still stuck? Help me debug a compilation error in my Spring Boot service layer.

✔️ Awesome, I've got everything!

Great. Double check you've saved all six new files.

ⓧ I'd like to double check the full code

package com.example.taskmanager.dto;

import com.example.taskmanager.entity.TaskPriority;
import com.example.taskmanager.entity.TaskStatus;
import jakarta.validation.constraints.FutureOrPresent;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.time.LocalDate;

public class TaskRequest {

    @NotBlank(message = "Title is required")
    private String title;

    private String description;

    @NotNull(message = "Status is required")
    private TaskStatus status;

    @NotNull(message = "Priority is required")
    private TaskPriority priority;

    @FutureOrPresent(message = "Due date must be today or in the future")
    private LocalDate dueDate;

    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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
}
package com.example.taskmanager.dto;

import com.example.taskmanager.entity.TaskPriority;
import com.example.taskmanager.entity.TaskStatus;
import java.time.LocalDate;
import java.time.LocalDateTime;

public class TaskResponse {

    private Long id;
    private String title;
    private String description;
    private TaskStatus status;
    private TaskPriority priority;
    private LocalDate dueDate;
    private LocalDateTime createdAt;
    private LocalDateTime updatedAt;

    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 TaskPriority getPriority() { return priority; }
    public void setPriority(TaskPriority priority) { this.priority = priority; }
    public LocalDate getDueDate() { return dueDate; }
    public void setDueDate(LocalDate dueDate) { this.dueDate = dueDate; }
    public LocalDateTime getCreatedAt() { return createdAt; }
    public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
    public LocalDateTime getUpdatedAt() { return updatedAt; }
    public void setUpdatedAt(LocalDateTime updatedAt) { this.updatedAt = updatedAt; }
}
package com.example.taskmanager.dto;

import java.time.LocalDateTime;
import java.util.List;

public class ErrorResponse {

    private LocalDateTime timestamp;
    private int status;
    private String message;
    private List<String> details;

    public ErrorResponse(int status, String message, List<String> details) {
        this.timestamp = LocalDateTime.now();
        this.status = status;
        this.message = message;
        this.details = details;
    }

    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
    public int getStatus() { return status; }
    public void setStatus(int status) { this.status = status; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public List<String> getDetails() { return details; }
    public void setDetails(List<String> details) { this.details = details; }
}
package com.example.taskmanager.exception;

public class ResourceNotFoundException extends RuntimeException {

    public ResourceNotFoundException(String message) {
        super(message);
    }
}
package com.example.taskmanager.service;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import java.util.List;

public interface TaskService {

    List<TaskResponse> getAllTasks();

    TaskResponse getTaskById(Long id);

    TaskResponse createTask(TaskRequest taskRequest);

    TaskResponse updateTask(Long id, TaskRequest taskRequest);

    void deleteTask(Long id);
}
package com.example.taskmanager.service;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.entity.Task;
import com.example.taskmanager.exception.ResourceNotFoundException;
import com.example.taskmanager.repository.TaskRepository;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;

@Service
public class TaskServiceImpl implements TaskService {

    private final TaskRepository taskRepository;

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

    @Override
    public List<TaskResponse> getAllTasks() {
        return taskRepository.findAll()
                .stream()
                .map(this::mapToResponse)
                .collect(Collectors.toList());
    }

    @Override
    public TaskResponse getTaskById(Long id) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
        return mapToResponse(task);
    }

    @Override
    public TaskResponse createTask(TaskRequest taskRequest) {
        Task task = mapToEntity(taskRequest);
        Task savedTask = taskRepository.save(task);
        return mapToResponse(savedTask);
    }

    @Override
    public TaskResponse updateTask(Long id, TaskRequest taskRequest) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));

        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        task.setStatus(taskRequest.getStatus());
        task.setPriority(taskRequest.getPriority());
        task.setDueDate(taskRequest.getDueDate());

        Task updatedTask = taskRepository.save(task);
        return mapToResponse(updatedTask);
    }

    @Override
    public void deleteTask(Long id) {
        Task task = taskRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Task not found with id: " + id));
        taskRepository.delete(task);
    }

    private TaskResponse mapToResponse(Task task) {
        TaskResponse response = new TaskResponse();
        response.setId(task.getId());
        response.setTitle(task.getTitle());
        response.setDescription(task.getDescription());
        response.setStatus(task.getStatus());
        response.setPriority(task.getPriority());
        response.setDueDate(task.getDueDate());
        response.setCreatedAt(task.getCreatedAt());
        response.setUpdatedAt(task.getUpdatedAt());
        return response;
    }

    private Task mapToEntity(TaskRequest taskRequest) {
        Task task = new Task();
        task.setTitle(taskRequest.getTitle());
        task.setDescription(taskRequest.getDescription());
        task.setStatus(taskRequest.getStatus());
        task.setPriority(taskRequest.getPriority());
        task.setDueDate(taskRequest.getDueDate());
        return task;
    }
}

Your service layer is wired up and compiling cleanly. Next up, you'll build the controller that exposes your service methods as HTTP endpoints and add a global exception handler that turns your ResourceNotFoundException into a proper 404 response.

Build the Controller and Exception Handler

Your service layer is ready to handle all the business logic. The next piece of the puzzle is connecting your service to the outside world so that clients can actually send requests and receive responses.

In this step, you will build the REST controller that maps HTTP requests to your service methods. You will also create a global exception handler that catches errors and returns clean, standardized error messages instead of raw stack traces.

In this step, get ready to:
  • Create a REST controller with 5 endpoints for task CRUD operations.
  • Build a global exception handler that returns standardized error responses.
  • Test all endpoints to confirm your API works end to end.
Create the REST controller

The controller is the entry point for all HTTP traffic. It receives incoming requests, delegates work to the service layer, and sends back responses with the appropriate HTTP status codes.

  • In VS Code's Explorer sidebar, right-click the src/main/java/com/example/taskmanager folder.
  • Select New Folder and name it controller.
  • Right-click the new controller folder and select New File.
  • Name the file TaskController.java and paste the following code:
package com.example.taskmanager.controller;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.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.RestController;
import java.util.List;

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

    private final TaskService taskService;

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

    // GET /api/tasks - retrieve all tasks
    @GetMapping
    public ResponseEntity<List<TaskResponse>> getAllTasks() {
        List<TaskResponse> tasks = taskService.getAllTasks();
        return ResponseEntity.ok(tasks);
    }

    // GET /api/tasks/{id} - retrieve a single task by ID
    @GetMapping("/{id}")
    public ResponseEntity<TaskResponse> getTaskById(@PathVariable Long id) {
        TaskResponse task = taskService.getTaskById(id);
        return ResponseEntity.ok(task);
    }

    // POST /api/tasks - create a new task
    @PostMapping
    public ResponseEntity<TaskResponse> createTask(@Valid @RequestBody TaskRequest taskRequest) {
        TaskResponse createdTask = taskService.createTask(taskRequest);
        return new ResponseEntity<>(createdTask, HttpStatus.CREATED);
    }

    // PUT /api/tasks/{id} - update an existing task
    @PutMapping("/{id}")
    public ResponseEntity<TaskResponse> updateTask(@PathVariable Long id, @Valid @RequestBody TaskRequest taskRequest) {
        TaskResponse updatedTask = taskService.updateTask(id, taskRequest);
        return ResponseEntity.ok(updatedTask);
    }

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

What does this code do?

This controller maps HTTP methods to your service layer using Spring annotations.

  • @RestController tells Spring this class handles HTTP requests and automatically serializes responses to JSON.
  • @RequestMapping("/api/tasks") sets the base URL path for all endpoints in this controller.
  • @Valid on the @RequestBody triggers the Jakarta Validation annotations you defined on TaskRequest.
  • Each method returns a ResponseEntity with the appropriate HTTP status code (200 OK, 201 Created, 204 No Content).
  • Save TaskController.java.

Seeing import errors?

If VS Code shows red underlines on the import statements, give the Java language server a moment to resolve them. The extension needs to re-index after creating a new file.

If errors persist after 10 seconds, check that the file is inside the controller folder and the package declaration matches com.example.taskmanager.controller.

help me fix import errors in my TaskController

Create the global exception handler

Without an exception handler, Spring Boot returns generic error pages when something goes wrong. A global exception handler intercepts specific exceptions and returns structured JSON error responses that API clients can parse.

  • In the Explorer sidebar, find the exception folder at src/main/java/com/example/taskmanager/exception.
  • Right-click the exception folder and select New File.
  • Name the file GlobalExceptionHandler.java and paste the following code:
package com.example.taskmanager.exception;

import com.example.taskmanager.dto.ErrorResponse;
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.List;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    // Handles 404 - resource not found
    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                List.of("The requested resource was not found")
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    // Handles 400 - validation failures from @Valid
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        List<String> details = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.toList());

        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Validation failed",
                details
        );
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    // Handles 500 - catch-all for unexpected errors
    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred",
                List.of(ex.getMessage())
        );
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

What does this code do?

@RestControllerAdvice makes this class a centralized error handler for all controllers in the application. Each @ExceptionHandler method catches a specific exception type.

  • ResourceNotFoundException returns a 404 response when a task ID does not exist.
  • MethodArgumentNotValidException returns a 400 response with a list of which fields failed validation and why.
  • The generic Exception handler is a safety net that catches anything unexpected and returns a 500 response.
  • Save GlobalExceptionHandler.java.
  • Confirm both files compile by running this in the VS Code terminal:
.\mvnw.cmd compile

You should see BUILD SUCCESS in the terminal output.

Compilation errors?

Check that GlobalExceptionHandler.java is in the exception package alongside ResourceNotFoundException.java. The package declaration must be com.example.taskmanager.exception.

Also verify that TaskController.java is inside the controller folder with the package com.example.taskmanager.controller.

help me fix compilation errors in my Spring Boot project

Test your API endpoints

Your controller and exception handler are in place. Time to start the application and verify that each endpoint responds correctly.

  • Start the application by running this in the VS Code terminal:
.\mvnw.cmd spring-boot:run

Wait until you see a log line containing Started TaskmanagerApplication in the terminal output.

Why keep this terminal running?

The Spring Boot application runs as a long-lived process. You need to open a second terminal to send test requests while the app stays running. Do not close or stop this terminal.

  • Open a second terminal in VS Code by clicking the + icon in the terminal panel (or press Ctrl+Shift+`).
  • 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", "status": "TODO", "priority": "HIGH", "dueDate": "2026-12-31"}'

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\", \"status\": \"TODO\", \"priority\": \"HIGH\", \"dueDate\": \"2026-12-31\"}"

You should see a JSON response with your task data and a 201 Created status. The response includes an auto-generated id, createdAt, and updatedAt timestamp.

Use curl.exe on Windows

In PowerShell, curl is an alias for Invoke-WebRequest, which has completely different syntax. Always type curl.exe to call the actual curl program.

  • Test validation by sending a request with an empty title:

macOS

curl -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d '{"title": "", "status": "TODO", "priority": "HIGH"}'

Windows

curl.exe -X POST http://localhost:8080/api/tasks -H "Content-Type: application/json" -d "{\"title\": \"\", \"status\": \"TODO\", \"priority\": \"HIGH\"}"

You should see a 400 response with a validation error message indicating that the title is required.

  • Test the not-found handler by requesting a task ID that does not exist:
curl http://localhost:8080/api/tasks/999

You should see your custom 404 error response with the message Task not found with id: 999.

What are the expected responses?

  • POST with valid data returns 201 with the created task in JSON format.
  • POST with empty title returns 400 with a details array showing which fields failed (e.g. title: Title is required).
  • GET with a non-existent ID returns 404 with your ErrorResponse format including timestamp, status, message, and details.

Getting connection refused?

Make sure the application is still running in your first terminal. Look for the Started TaskmanagerApplication log message.

If the app crashed on startup, scroll up in the first terminal to see the error. Common causes include a typo in the package declaration or a missing import statement.

help me debug why my Spring Boot app won't start

✔️ Awesome, I've got everything!

Great. Double check you have saved both TaskController.java and GlobalExceptionHandler.java.

ⓧ I'd like to double check the full code

Here is the complete TaskController.java:

package com.example.taskmanager.controller;

import com.example.taskmanager.dto.TaskRequest;
import com.example.taskmanager.dto.TaskResponse;
import com.example.taskmanager.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.RestController;
import java.util.List;

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

    private final TaskService taskService;

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

    @GetMapping
    public ResponseEntity<List<TaskResponse>> getAllTasks() {
        List<TaskResponse> tasks = taskService.getAllTasks();
        return ResponseEntity.ok(tasks);
    }

    @GetMapping("/{id}")
    public ResponseEntity<TaskResponse> getTaskById(@PathVariable Long id) {
        TaskResponse task = taskService.getTaskById(id);
        return ResponseEntity.ok(task);
    }

    @PostMapping
    public ResponseEntity<TaskResponse> createTask(@Valid @RequestBody TaskRequest taskRequest) {
        TaskResponse createdTask = taskService.createTask(taskRequest);
        return new ResponseEntity<>(createdTask, HttpStatus.CREATED);
    }

    @PutMapping("/{id}")
    public ResponseEntity<TaskResponse> updateTask(@PathVariable Long id, @Valid @RequestBody TaskRequest taskRequest) {
        TaskResponse updatedTask = taskService.updateTask(id, taskRequest);
        return ResponseEntity.ok(updatedTask);
    }

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

Here is the complete GlobalExceptionHandler.java:

package com.example.taskmanager.exception;

import com.example.taskmanager.dto.ErrorResponse;
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.List;
import java.util.stream.Collectors;

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ResourceNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleResourceNotFound(ResourceNotFoundException ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.NOT_FOUND.value(),
                ex.getMessage(),
                List.of("The requested resource was not found")
        );
        return new ResponseEntity<>(error, HttpStatus.NOT_FOUND);
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        List<String> details = ex.getBindingResult()
                .getFieldErrors()
                .stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.toList());

        ErrorResponse error = new ErrorResponse(
                HttpStatus.BAD_REQUEST.value(),
                "Validation failed",
                details
        );
        return new ResponseEntity<>(error, HttpStatus.BAD_REQUEST);
    }

    @ExceptionHandler(Exception.class)
    public ResponseEntity<ErrorResponse> handleGenericException(Exception ex) {
        ErrorResponse error = new ErrorResponse(
                HttpStatus.INTERNAL_SERVER_ERROR.value(),
                "An unexpected error occurred",
                List.of(ex.getMessage())
        );
        return new ResponseEntity<>(error, HttpStatus.INTERNAL_SERVER_ERROR);
    }
}

Secret mission

Add Filtering, Sorting, and Pagination

Your API works, but real-world APIs need to handle thousands of tasks efficiently. Can you add query parameters that let clients filter by status and priority, sort by any field, and paginate through results? This requires writing custom JPA queries and understanding Spring Data's Pageable pattern.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally. The H2 database is in-memory and shuts down when you stop the application, so there are no ongoing costs or cloud resources to worry about.

Resources you used:

  • The taskmanager project folder on your computer.
  • JDK 21 installed on your system.
  • Extension Pack for Java and Spring Boot Extension Pack in VS Code.
  • The Spring Boot application process running on http://localhost:8080.

Keep everything running

No action needed. Choose this if you're still actively building or plan to push your project to GitHub as a portfolio piece.

  • Your application keeps running on http://localhost:8080 until you close the terminal.
  • JDK 21 and the VS Code extensions remain installed for future Java projects.
  • Your project folder stays on your computer, ready for you to push to GitHub or continue developing.

Pause - I'll come back to this later

Stop the running application to free up your terminal and port 8080. Your project files and installed tools stay in place.

  • Switch to the terminal in VS Code where the application is running.
  • Press Ctrl+C to stop the Spring Boot process.
  • Your project files, JDK 21, and VS Code extensions all remain in place for when you return.

Delete - I don't want to use this again

Remove all project files and optionally uninstall the tools you set up. This frees disk space and cleans your environment.

  • Stop the application by pressing Ctrl+C in the terminal where it is running.
  • Delete the project folder by running this command from the parent directory (macOS/Linux):
rm -rf taskmanager

On Windows, run this in Command Prompt:

rmdir /s /q taskmanager
  • To uninstall the VS Code extensions, open VS Code and press Ctrl+Shift+X to open Extensions. Find Extension Pack for Java and Spring Boot Extension Pack, then click Uninstall on each.
  • To uninstall JDK 21 on Windows, press the Windows key, search for Add or remove programs, find Java(TM) SE Development Kit 21 in the list, and click Uninstall.
  • To uninstall JDK 21 on macOS, delete the JDK directory by running:
sudo rm -rf /Library/Java/JavaVirtualMachines/jdk-21.jdk
  • Remove the JAVA_HOME environment variable and the %JAVA_HOME%\bin PATH entry you added during setup.

Nice Work!

Nice Work!

You just built a production-grade Task Management REST API using Spring Boot 4.1.0 and Java 21. This project proves to recruiters that you understand how real enterprise backends are structured.

You've learned how to:

  • Build a complete REST API with 5 CRUD endpoints, Jakarta Bean Validation for input safety, and proper HTTP status codes for every response.
  • Implement professional layered architecture (Controller, Service, Repository) with DTOs to decouple your API contracts from database entities.
  • Create a global exception handler that produces standardized error responses, using Spring Data JPA with H2 for zero-setup database operations.
  • Secret Mission: Added filtering, sorting, and pagination to demonstrate query-building skills that backend developers use daily.

Ready to quiz yourself?