Build a Task Management REST API

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

Introduction

30 Second Summary

Every app you use talks to an API behind the scenes. When you mark a task as done in your to-do app, that action hits a REST API that decides whether to accept or reject the change.

In this project, you will build a Task Management REST API from scratch using Spring Boot. You will implement CRUD endpoints, input validation, and structured error handling following the patterns that come up in Java developer interviews.

What You'll Build

You will send curl commands from your terminal and watch your API create tasks, reject invalid input, and return clean JSON responses with the correct HTTP status codes.

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

  • A running REST API with full CRUD operations where you create, read, update, and delete tasks using curl and see proper HTTP status codes in every response.
  • Input validation that rejects bad data at the boundary and returns structured JSON error messages instead of raw stack traces.
  • A layered architecture separating Controller, Service, and Model code into the pattern interviewers expect to see in a production codebase.
  • Secret Mission: add filtering and search so you can query your task list by status or title keyword.

Are there any prerequisites?

This project assumes you have programming experience in another language, OpenJDK 25 installed, and Neovim configured with Java LSP. No prior Java or Spring Boot experience is needed.

Before We Start

Before We Start...

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up Your Spring Boot Project

Before you can write a single API endpoint, you need a working project structure with the correct dependencies wired in.

Spring Initializr generates a complete Spring Boot project scaffold from a single command. You will download it, unzip it, and confirm the embedded server starts on port 8080.

In this step, get ready to:
  • Generate a Spring Boot project with webmvc and validation dependencies.
  • Compile and run the application using the Maven wrapper.
  • Confirm the server responds by hitting it with curl.
Generate the project from Spring Initializr

Spring Initializr is a web service that generates ready-to-run Spring Boot projects. Instead of clicking through a web UI, you can hit it with a single curl command to download a project zip.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Download the project scaffold from Spring Initializr by running this command:
curl https://start.spring.io/starter.zip \
  -d type=maven-project \
  -d language=java \
  -d bootVersion=4.1.0 \
  -d groupId=com.example \
  -d artifactId=taskapi \
  -d name=taskapi \
  -d packageName=com.example.taskapi \
  -d javaVersion=25 \
  -d dependencies=webmvc,validation \
  -o taskapi.zip

What does this command do?

  • bootVersion=4.1.0 pins the project to Spring Boot 4.1.0.
  • javaVersion=25 tells the build to target Java 25 (your installed JDK).
  • dependencies=webmvc,validation adds spring-boot-starter-webmvc (the REST controller framework) and spring-boot-starter-validation (Jakarta Bean Validation for input checking).
  • -o taskapi.zip saves the downloaded zip to your current directory.
  • Confirm the download succeeded by running:
ls taskapi.zip
  • You should see taskapi.zip listed in the output.
  • Unzip the project into a new directory and move into it by running this command:
unzip taskapi.zip -d taskapi && cd taskapi
  • Verify the project structure by running:
ls
  • You should see mvnw, mvnw.cmd, pom.xml, and a src/ directory in the output.

Don't see taskapi.zip?

Make sure you are connected to the internet. The curl command downloads from https://start.spring.io which requires network access.

If you see a "Could not resolve host" error, check your DNS or try again in a moment.

help me troubleshoot why my Spring Initializr curl command failed

Run the application for the first time

The Maven wrapper (./mvnw) ships with the generated project. It downloads the correct Maven version automatically, so you do not need Maven installed globally.

  • Start the application by running this command:
./mvnw spring-boot:run

What happens on first run?

The Maven wrapper downloads Maven itself, then downloads all project dependencies (Spring Boot, the embedded Tomcat server, validation libraries). This can take 1-2 minutes the first time.

Once compilation finishes, the embedded server starts and takes over this terminal window. You will not be able to type further commands here until you stop the server.

After the download and compilation finishes, you should see the Spring Boot banner (ASCII art) followed by a log line containing "Started TaskapiApplication". The server is now running on port 8080.

Seeing a compilation error?

Confirm your Java version by opening a new terminal and running java --version. You need JDK 25. Spring Boot 4.1.0 requires Java 17 or higher.

If you see a permissions error on ./mvnw, make it executable first:

chmod +x mvnw

help me fix a compilation or startup error when running ./mvnw spring-boot:run

Test that the server responds

The server is occupying your first terminal, so you need a second terminal to send requests to it.

  • Open a new terminal window or tab by pressing Cmd+T (macOS) or Ctrl+Shift+T (Linux/Windows Terminal).
  • Navigate to the project directory in the new terminal by running:
cd ~/Desktop/taskapi

Before you run the next command, what do you think the server will return when you request a URL with no endpoints defined?

  • Send a request to the running server by running this command:
curl -i http://localhost:8080

You should see a 404 response with a JSON body containing a Whitelabel Error. This proves Spring Boot is running and serving HTTP responses, but it has no endpoints defined yet.

  • Switch back to the first terminal (where the server is running).
  • Press Ctrl+C to stop the server.

Getting "Connection refused"?

Make sure the server is still running in your first terminal. Look for the "Started TaskapiApplication" log line. If the server crashed, run ./mvnw spring-boot:run again in the first terminal.

Also confirm you are using port 8080 in the curl URL. Spring Boot starts on this port by default.

help me troubleshoot why curl to localhost:8080 is giving Connection refused

✔️ Awesome, I've got everything!

Great. Double check you are back in the taskapi directory and the server is stopped (you pressed Ctrl+C).

ⓧ I'd like to double check the full code

The only application code Spring Initializr generated is the entry point file. Open it in Neovim and confirm it matches:

package com.example.taskapi;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class TaskapiApplication {

    public static void main(String[] args) {
        SpringApplication.run(TaskapiApplication.class, args);
    }
}

Your Spring Boot project is running and responding to HTTP requests. Next up, you will create the Task model and build your first working API endpoint.

Create the Task Model and a Naive Controller

Your Spring Boot project compiles and starts on port 8080. The next goal is to get real data flowing through the API.

You need to see a working endpoint returning JSON before adding any complexity. This step gives you that early win of seeing your own task data come back from an API call. The controller will be naive though: no validation, no service layer, and it will fall over on bad input. That sets up the gap you will fix in the next steps.

In this step, get ready to:
  • Define a Task model class and a TaskStatus enum to represent your data.
  • Build a controller with in-memory storage and full CRUD endpoints.
  • Test every endpoint with curl and confirm JSON responses come back.
Create the TaskStatus enum

Every task needs a status. In Java, an enum is the cleanest way to represent a fixed set of values.

  • Create the directory structure for your model package by running:
mkdir -p src/main/java/com/example/taskapi/model
  • Create the file src/main/java/com/example/taskapi/model/TaskStatus.java and add the following code:
package com.example.taskapi.model;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}

What does this code do?

This enum defines the three possible states a task can be in. Spring Boot will automatically convert these values to and from JSON strings when sending responses.

  • Save TaskStatus.java.

✔️ Awesome, I've got everything!

Great. Make sure you saved the file before moving on.

ⓧ I'd like to double check the full code

package com.example.taskapi.model;

public enum TaskStatus {
    TODO,
    IN_PROGRESS,
    DONE
}
Create the Task model class

The Task class represents the data shape your API sends and receives. It needs fields for an ID, title, description, status, priority, and a timestamp.

  • Create the file src/main/java/com/example/taskapi/model/Task.java and add the class declaration with its fields and constructors:
package com.example.taskapi.model;

import java.time.LocalDateTime;

public class Task {

    private String id;
    private String title;
    private String description;
    private TaskStatus status;
    private int priority;
    private LocalDateTime createdAt;

    // No-arg constructor needed for JSON deserialization
    public Task() {
    }

    // Full constructor for creating tasks in code
    public Task(String id, String title, String description, TaskStatus status, int priority, LocalDateTime createdAt) {
        this.id = id;
        this.title = title;
        this.description = description;
        this.status = status;
        this.priority = priority;
        this.createdAt = createdAt;
    }

What does this code do?

  • The six private fields define what data a Task holds.
  • The no-argument constructor lets Jackson (Spring Boot's JSON library) create a Task object from incoming JSON.
  • The full constructor lets you build a Task with all fields set in one call.
  • Below the constructors in the same file, add getters and setters for every field:
    // Getters and setters for JSON serialization
    public String getId() { return id; }
    public void setId(String 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; }
}

Why do I need getters and setters?

Jackson uses these methods to read field values when converting a Task to JSON (getters) and to populate fields when converting JSON into a Task (setters). Without them, your API responses would be empty objects.

  • Save Task.java.
  • Confirm both model files exist by running:
ls src/main/java/com/example/taskapi/model/

You should see Task.java and TaskStatus.java listed.

Don't see both files?

  • Check that you created the model directory inside src/main/java/com/example/taskapi/. The package path must match exactly.
  • Make sure you saved both files after writing the code.

help me check my model directory structure

✔️ Awesome, I've got everything!

Great. Double check you saved both files before moving on.

ⓧ I'd like to double check the full code

Your Task.java should have: the package and import at the top, six private fields, a no-arg constructor, a full constructor, then twelve getter/setter methods, and a closing brace. Scroll up and confirm each section is present in order.

Create the TaskController with in-memory storage

A REST controller is the class that maps HTTP requests to Java methods. This controller will store tasks in a simple HashMap directly in memory.

  • Create the controller directory and file by running:
mkdir -p src/main/java/com/example/taskapi/controller
  • Create src/main/java/com/example/taskapi/controller/TaskController.java and add the imports, class declaration, and GET endpoints:
package com.example.taskapi.controller;

import com.example.taskapi.model.Task;
import com.example.taskapi.model.TaskStatus;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.time.LocalDateTime;
import java.util.*;

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

    // In-memory storage for tasks
    private final Map<String, Task> tasks = new HashMap<>();

    // Return all tasks as a JSON array
    @GetMapping
    public ResponseEntity<List<Task>> getAllTasks() {
        return ResponseEntity.ok(List.copyOf(tasks.values()));
    }

    // Return a single task by its ID
    @GetMapping("/{id}")
    public ResponseEntity<Task> getTaskById(@PathVariable String id) {
        return ResponseEntity.ok(tasks.get(id));
    }

What does this code do?

  • @RestController tells Spring this class handles HTTP requests and returns JSON directly.
  • @RequestMapping("/api/tasks") sets the base URL path for all endpoints in this controller.
  • The HashMap acts as a simple in-memory database. Tasks disappear when the server restarts.
  • @GetMapping maps HTTP GET requests to the method below it.
  • Below the GET endpoints in the same file, add the POST, PUT, and DELETE endpoints:
    // Create a new task with default status and priority
    @PostMapping
    public ResponseEntity<Task> createTask(@RequestBody Task task) {
        String id = UUID.randomUUID().toString();
        task.setId(id);
        task.setStatus(TaskStatus.TODO);
        task.setPriority(1);
        task.setCreatedAt(LocalDateTime.now());
        tasks.put(id, task);
        return ResponseEntity.status(HttpStatus.CREATED).body(task);
    }

    // Replace an existing task's data
    @PutMapping("/{id}")
    public ResponseEntity<Task> updateTask(@PathVariable String id, @RequestBody Task updated) {
        Task existing = tasks.get(id);
        if (existing != null) {
            updated.setId(id);
            tasks.put(id, updated);
        }
        return ResponseEntity.ok(updated);
    }

    // Remove a task by ID
    @DeleteMapping("/{id}")
    public ResponseEntity<Void> deleteTask(@PathVariable String id) {
        tasks.remove(id);
        return ResponseEntity.noContent().build();
    }
}

What does this code do?

  • @PostMapping handles HTTP POST. It generates a unique ID, sets defaults (status TODO, priority 1), and returns 201 Created.
  • @PutMapping replaces an existing task. Notice it does not check whether the ID exists. That gap becomes obvious in the next step.
  • @DeleteMapping removes the task and returns 204 No Content to confirm deletion.
  • Save TaskController.java.
  • Verify the project compiles with no errors by running:
./mvnw compile -q

If the build succeeds, you will see BUILD SUCCESS in the output. No errors means all your files are in the right packages and import correctly.

Seeing a compilation error?

  • Check that the package declaration at the top of each file matches its directory path exactly.
  • Make sure Task.java imports java.time.LocalDateTime and references TaskStatus (same package, no import needed).
  • Confirm the closing brace } is present at the end of every class and enum file.

help me fix my compilation error

✔️ Awesome, I've got everything!

Great. Double check you saved the controller file before moving on.

ⓧ I'd like to double check the full code

Your TaskController.java should have: the package and imports at the top, the @RestController and @RequestMapping annotations, a HashMap field, then five endpoint methods (GET all, GET by id, POST, PUT, DELETE), and a closing brace. Scroll up and confirm each section is present in order.

Run and test the CRUD endpoints with curl

The moment of truth. You have a model and a controller. Time to start the server and hit every endpoint to confirm JSON flows in and out.

  • Start the server by running:
./mvnw spring-boot:run

Wait for the Spring Boot banner and a line ending with Started TaskapiApplication. The server is now listening on port 8080.

  • Open a second terminal window. You need to leave the server running in the first terminal.
  • Before you test, predict: what do you think GET /api/tasks will return right now, with no tasks created yet?
  • List all tasks by running:
curl http://localhost:8080/api/tasks

You should see an empty JSON array: []. The HashMap is empty, so there is nothing to return yet.

  • Create a task by running:
curl -X POST -H "Content-Type: application/json" \
  -d '{"title":"My first task","description":"Test"}' \
  http://localhost:8080/api/tasks

You should see a JSON response with your task, including a generated id, status set to TODO, and priority set to 1.

What should I see?

The response looks something like this (your id and createdAt will differ):

{"id":"a1b2c3d4-...","title":"My first task","description":"Test","status":"TODO","priority":1,"createdAt":"2026-07-15T..."}
  • List all tasks again to confirm the task was stored:
curl http://localhost:8080/api/tasks

You should now see a JSON array containing your created task.

Seeing "Connection refused" or an empty response?

  • Make sure the server is still running in your first terminal. Look for the Spring Boot banner and no error stack traces.
  • Confirm you are using port 8080 in your curl URL.
  • If you see a port conflict, stop any other process on 8080 and restart with ./mvnw spring-boot:run.

help me debug my curl request to the task API

Your API is alive. You can create tasks, list them, and see real JSON flowing through your endpoints. But this controller has no guard rails. In the next step, you will discover exactly how it breaks when you send it bad data.

Break It with Bad Input

Your API handles valid input just fine. You can create tasks, list them, fetch by ID, update, and delete.

But what happens when someone sends garbage data? In a real interview, this is exactly what a reviewer asks next: "What happens if I POST a blank title?" or "What does your API return for an ID that doesn't exist?" Let's find out how your naive controller handles these cases.

In this step, get ready to:
  • Send invalid data to your API and observe what happens.
  • Request a task that does not exist and observe the response.
  • Identify the gaps that need fixing.
Send a POST with blank fields

A well-built API should reject a task that has no title. Before you run this next command, think about what you expect to happen. Do you think the API will reject the request or accept it?

  • Test what happens when you POST a task with an empty title by running this command:
curl -X POST -H "Content-Type: application/json" \
  -d '{"title":"","description":""}' \
  http://localhost:8080/api/tasks

The API happily creates the task with an empty title. You get back a 201 Created response with a full JSON body, including an id and timestamp. No rejection, no error, no guard rails.

Why this matters in interviews

An API that silently accepts garbage data is a liability. Interviewers want to see that you validate at the boundary. If bad data gets in, every downstream system has to deal with it.

Seeing Connection refused?

Your server is not running. Switch back to your server terminal and start it again with ./mvnw spring-boot:run. Wait for the "Started" message before retrying the curl command.

Help me debug my Spring Boot server not starting.

Request a non-existent task

The second problem is how the API handles lookups for IDs that do not exist. Before you run this, what HTTP status code should a proper API return for a missing resource?

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

You get back a 200 OK with a null or empty body. A real API should return a 404 Not Found with a clear error message explaining that no task exists with that ID.

What is wrong with returning 200 for missing data?

HTTP status codes are a contract between your API and its consumers. A 200 means "everything went fine, here is your data." Returning 200 with a null body forces the client to guess whether the request failed or the resource simply has no data.

A proper 404 tells the client unambiguously: this resource does not exist. The client can handle that case cleanly without parsing the body.

Identify the gaps

You have now seen both failure modes of the naive controller. These are the exact problems interviewers expect you to fix.

  • No input validation: blank titles, empty strings, and missing fields are all accepted without complaint.
  • No custom exceptions: the controller returns null or an unhelpful default instead of throwing a meaningful error.
  • No structured error responses: when things go wrong, the client gets null or a generic stack trace instead of a clean JSON error message.
  • Stop the server by pressing Ctrl+C in the terminal where it is running.

These gaps are intentional. You have now experienced firsthand why validation and error handling matter. In the next step, you will add Jakarta Bean Validation annotations to reject bad input at the boundary, and build a global exception handler that returns consistent JSON error responses.

Add Validation and Structured Error Handling

In the previous step, you proved that the API has no guard rails. It accepts blank titles and returns nothing useful when a task does not exist.

Now you will fix both problems with a pattern interviewers actively look for: Jakarta Bean Validation to reject bad input at the boundary, and a global exception handler to return structured JSON errors.

In this step, get ready to:
  • Create a request DTO with validation annotations that reject blank titles.
  • Build a custom exception and global handler that returns consistent JSON errors.
  • Update the controller to use validation and throw proper 404s.
Create the request DTO with validation annotations

The first fix is to stop accepting raw Task objects directly from the client. Instead, you will define a CreateTaskRequest DTO that declares exactly which fields are required and what constraints they must meet.

  • Create the directory for your DTOs by running:
mkdir -p src/main/java/com/example/taskapi/dto
  • Create src/main/java/com/example/taskapi/dto/CreateTaskRequest.java and add the following code:
package com.example.taskapi.dto;

import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

public class CreateTaskRequest {

    // Reject blank or null titles at the boundary
    @NotBlank(message = "Title is required")
    private String title;

    // Cap description length to prevent abuse
    @Size(max = 500, message = "Description must not exceed 500 characters")
    private String description;

    private int priority;

    public CreateTaskRequest() {
    }

    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 int getPriority() { return priority; }
    public void setPriority(int priority) { this.priority = priority; }
}

What does this code do?

This class defines the shape of incoming POST/PUT request bodies. The annotations declare constraints that Spring enforces automatically before your controller code ever runs.

  • @NotBlank rejects null, empty strings, and whitespace-only strings.
  • @Size(max = 500) caps the description length so clients cannot send unbounded text.
  • The message attribute on each annotation controls the error text returned to the caller.

Now update TaskController.java so the POST endpoint uses this DTO instead of a raw Task.

  • In src/main/java/com/example/taskapi/controller/TaskController.java, add these two imports below the existing import lines:
import com.example.taskapi.dto.CreateTaskRequest;
import jakarta.validation.Valid;
  • Find the existing createTask method:
@PostMapping
public ResponseEntity<Task> createTask(@RequestBody Task task) {
    String id = UUID.randomUUID().toString();
    task.setId(id);
    task.setStatus(TaskStatus.TODO);
    task.setPriority(1);
    task.setCreatedAt(LocalDateTime.now());
    tasks.put(id, task);
    return ResponseEntity.status(HttpStatus.CREATED).body(task);
}
  • Replace the entire method with this version that uses @Valid and the new DTO:
@PostMapping
public ResponseEntity<Task> createTask(@Valid @RequestBody CreateTaskRequest request) {
    String id = UUID.randomUUID().toString();
    // Build a Task from validated request fields
    Task task = new Task(id, request.getTitle(), request.getDescription(), TaskStatus.TODO,
            request.getPriority() > 0 ? request.getPriority() : 1, LocalDateTime.now());
    tasks.put(id, task);
    return ResponseEntity.status(HttpStatus.CREATED).body(task);
}

What changed here?

The @Valid annotation tells Spring to run all Jakarta validation annotations on the request body before entering your method. If validation fails, Spring throws a MethodArgumentNotValidException and your method never executes.

Instead of mutating a raw Task object, you now construct a new Task from the validated request fields. This keeps your model clean.

  • Save TaskController.java and CreateTaskRequest.java.
  • Start the server by running:
./mvnw spring-boot:run

Before you test, predict: what do you think will happen if you POST a blank title now?

  • In a second terminal, test validation by sending a blank title:
curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"","description":""}' \
  http://localhost:8080/api/tasks

You should see an HTTP/1.1 400 response. Spring's default error handler catches the validation failure and returns a 400 status with error details in the body.

Why is this already a 400 without a custom handler?

Spring Boot includes a default error mechanism that converts common exceptions to appropriate HTTP status codes. MethodArgumentNotValidException maps to 400 automatically. However, the default response format is verbose and inconsistent with what you want to return.

In the next substep, you will build a custom handler that returns a clean, predictable JSON structure.

  • Stop the server with Ctrl+C.

Still seeing a 200 or 500?

  • Make sure you added @Valid before @RequestBody in the method signature. Without @Valid, Spring skips validation entirely.
  • Check that the parameter type is CreateTaskRequest, not Task. The annotations live on the DTO class.
  • Confirm the jakarta.validation.Valid import is present, not javax.validation.Valid.

Help me debug my Spring Boot validation

Create the custom exception and error response classes

The default Spring error response is verbose and includes fields you do not control. You will now create a custom exception for missing tasks and a structured error response that your API always returns.

  • Create the exception directory by running:
mkdir -p src/main/java/com/example/taskapi/exception
  • Create src/main/java/com/example/taskapi/exception/TaskNotFoundException.java with this code:
package com.example.taskapi.exception;

public class TaskNotFoundException extends RuntimeException {

    public TaskNotFoundException(String id) {
        // Message follows a consistent pattern for the error handler to use
        super("Task not found with id: " + id);
    }
}
  • Create src/main/java/com/example/taskapi/dto/ErrorResponse.java with this code:
package com.example.taskapi.dto;

import java.time.LocalDateTime;

public class ErrorResponse {

    private String error;
    private String message;
    private LocalDateTime timestamp;

    public ErrorResponse(String error, String message) {
        this.error = error;
        this.message = message;
        // Auto-set timestamp so every error response is traceable
        this.timestamp = LocalDateTime.now();
    }

    public String getError() { return error; }
    public void setError(String error) { this.error = error; }
    public String getMessage() { return message; }
    public void setMessage(String message) { this.message = message; }
    public LocalDateTime getTimestamp() { return timestamp; }
    public void setTimestamp(LocalDateTime timestamp) { this.timestamp = timestamp; }
}

What do these two classes do?

  • TaskNotFoundException is a custom runtime exception. When thrown, it signals that the caller requested a task that does not exist. The error handler will catch it and return a 404.
  • ErrorResponse is the JSON shape every error response will follow. Three fields: error (category), message (detail), and timestamp (when it happened).
  • Confirm both files compile by running:
./mvnw compile -q

You should see BUILD SUCCESS with no errors.

Now create the handler that ties everything together. The @ControllerAdvice annotation tells Spring Boot to intercept exceptions thrown from any controller and route them to your handler methods.

  • Create src/main/java/com/example/taskapi/exception/GlobalExceptionHandler.java with this code:
package com.example.taskapi.exception;

import com.example.taskapi.dto.ErrorResponse;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;

import java.util.stream.Collectors;

@ControllerAdvice
public class GlobalExceptionHandler {

    // Catch TaskNotFoundException and return 404
    @ExceptionHandler(TaskNotFoundException.class)
    public ResponseEntity<ErrorResponse> handleTaskNotFound(TaskNotFoundException ex) {
        ErrorResponse error = new ErrorResponse("Not Found", ex.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
    }

    // Catch validation failures and return 400 with field details
    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> handleValidationErrors(MethodArgumentNotValidException ex) {
        String message = ex.getBindingResult().getFieldErrors().stream()
                .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage())
                .collect(Collectors.joining(", "));
        ErrorResponse error = new ErrorResponse("Validation Failed", message);
        return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
    }
}

What does this handler do?

The @ControllerAdvice class acts as a global interceptor. Each @ExceptionHandler method catches a specific exception type thrown from any controller.

  • handleTaskNotFound catches TaskNotFoundException and returns a 404 with your ErrorResponse JSON.
  • handleValidationErrors catches MethodArgumentNotValidException, collects all field errors into a single message string, and returns a 400.
  • Save GlobalExceptionHandler.java and confirm everything still compiles:
./mvnw compile -q

You should see BUILD SUCCESS again.

Compile error mentioning TaskNotFoundException?

  • Check that TaskNotFoundException.java is in the exception package directory, not in dto or model.
  • Verify the package declaration at the top of the file matches com.example.taskapi.exception.

Help me fix my compile error

Update the controller to throw proper 404s

The handler is ready to catch TaskNotFoundException, but no code throws it yet. You need to update the GET by ID, PUT, and DELETE endpoints to throw when a task is not found.

  • In TaskController.java, add this import below the existing imports:
import com.example.taskapi.exception.TaskNotFoundException;
  • Find the existing getTaskById method:
@GetMapping("/{id}")
public ResponseEntity<Task> getTaskById(@PathVariable String id) {
    return ResponseEntity.ok(tasks.get(id));
}
  • Replace it with this version that throws when the task is null:
@GetMapping("/{id}")
public ResponseEntity<Task> getTaskById(@PathVariable String id) {
    Task task = tasks.get(id);
    if (task == null) throw new TaskNotFoundException(id);
    return ResponseEntity.ok(task);
}
  • Find the existing updateTask method:
@PutMapping("/{id}")
public ResponseEntity<Task> updateTask(@PathVariable String id, @RequestBody Task updated) {
    Task existing = tasks.get(id);
    if (existing != null) {
        updated.setId(id);
        tasks.put(id, updated);
    }
    return ResponseEntity.ok(updated);
}
  • Replace it with this version that validates the request and throws if the ID does not exist:
@PutMapping("/{id}")
public ResponseEntity<Task> updateTask(@PathVariable String id, @Valid @RequestBody CreateTaskRequest request) {
    Task existing = tasks.get(id);
    if (existing == null) throw new TaskNotFoundException(id);
    existing.setTitle(request.getTitle());
    existing.setDescription(request.getDescription());
    if (request.getPriority() > 0) existing.setPriority(request.getPriority());
    return ResponseEntity.ok(existing);
}
  • Save the file, then start the server and test the 404 handling by running:
./mvnw spring-boot:run
  • In a second terminal, request a task that does not exist:
curl -i http://localhost:8080/api/tasks/nonexistent-id

You should see HTTP/1.1 404 with a JSON body containing "error":"Not Found" and "message":"Task not found with id: nonexistent-id".

  • Stop the server with Ctrl+C.

Still seeing a 200 with null?

  • Make sure you replaced the entire getTaskById method, not just added lines inside the old one.
  • Confirm the import com.example.taskapi.exception.TaskNotFoundException line is at the top of your controller file.

Help me debug my 404 handling

One more endpoint to update. The DELETE handler currently does not check whether the task exists before removing.

  • Find the existing deleteTask method:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable String id) {
    tasks.remove(id);
    return ResponseEntity.noContent().build();
}
  • Replace it with this version that throws if the task does not exist:
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteTask(@PathVariable String id) {
    if (!tasks.containsKey(id)) throw new TaskNotFoundException(id);
    tasks.remove(id);
    return ResponseEntity.noContent().build();
}
  • Save TaskController.java, then start the server and run a full set of tests.
./mvnw spring-boot:run

Before running the next test, predict: what HTTP status and message do you expect if you POST a blank title now that the custom handler is active?

  • Test validation with a blank title:
curl -i -X POST -H "Content-Type: application/json" \
  -d '{"title":"","description":""}' \
  http://localhost:8080/api/tasks

You should see HTTP/1.1 400 with a JSON body: "error":"Validation Failed" and "message":"title: Title is required".

  • Test the 404 on a non-existent ID:
curl -i http://localhost:8080/api/tasks/nonexistent-id

You should see HTTP/1.1 404 with "error":"Not Found" and "message":"Task not found with id: nonexistent-id". Both failure modes from Step 3 are now fixed.

Why does this pattern matter in interviews?

Interviewers look for three things in error handling: consistent response shape (every error follows the same JSON structure), correct HTTP semantics (400 for bad input, 404 for missing resources), and separation of concerns (the controller does not format error responses directly).

The @ControllerAdvice pattern gives you all three. You can add new exception types without changing any controller code.

✔️ Awesome, I've got everything!

Great. Double check you have saved all files: CreateTaskRequest.java, ErrorResponse.java, TaskNotFoundException.java, GlobalExceptionHandler.java, and TaskController.java.

ⓧ I'd like to double check the full code

Scroll up and confirm the following chunks are in your project, in order:

  • CreateTaskRequest.java with @NotBlank on title and @Size(max = 500) on description.
  • TaskNotFoundException.java extending RuntimeException with the message "Task not found with id: " + id.
  • ErrorResponse.java with error, message, and timestamp fields.
  • GlobalExceptionHandler.java with @ControllerAdvice handling TaskNotFoundException (404) and MethodArgumentNotValidException (400).
  • TaskController.java with @Valid @RequestBody CreateTaskRequest on POST and PUT, and TaskNotFoundException thrown in GET by ID, PUT, and DELETE.

Your API now rejects bad input and returns clean, structured error responses. Next up, you will extract the business logic into a service layer to separate HTTP concerns from task manipulation logic.

Extract a Service Layer

Your API now validates input and returns structured error responses. The controller is doing its job well. But look at TaskController.java closely. It handles HTTP concerns (status codes, request mapping) AND business logic (creating tasks, managing storage) in the same class.

In a real codebase, this makes the code harder to test and harder to maintain. Extracting a service layer separates these concerns. The controller handles HTTP, the service handles logic. This is the architecture interviewers expect.

In this step, get ready to:
  • Create a TaskService class that owns all CRUD logic and data storage.
  • Refactor TaskController to delegate to TaskService via constructor injection.
  • Verify that all existing curl commands still work identically.
Create the TaskService class

The service class takes ownership of the task storage and all the logic that manipulates it. Spring's @Service annotation tells the framework to manage this class as a bean that can be injected into other components.

  • Create the service package directory by running:
mkdir -p src/main/java/com/example/taskapi/service
  • Confirm the directory was created by running:
ls src/main/java/com/example/taskapi/

You should see a new service directory alongside controller, dto, exception, and model.

  • Create TaskService.java inside the service directory with the class skeleton and read methods:
package com.example.taskapi.service;

import com.example.taskapi.dto.CreateTaskRequest;
import com.example.taskapi.exception.TaskNotFoundException;
import com.example.taskapi.model.Task;
import com.example.taskapi.model.TaskStatus;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;

@Service
public class TaskService {

    private final Map<String, Task> tasks = new HashMap<>();

    public List<Task> getAllTasks() {
        return List.copyOf(tasks.values());
    }

    public Task getTaskById(String id) {
        Task task = tasks.get(id);
        if (task == null) {
            throw new TaskNotFoundException(id);
        }
        return task;
    }
}

What does this code do?

  • @Service marks this class as a Spring-managed bean. Spring automatically creates one instance and makes it available for injection.
  • The Map<String, Task> storage has moved here from the controller. The service now owns data.
  • getAllTasks() and getTaskById() are the same logic you had in the controller, now living in the right layer.
  • Now add the write methods. In TaskService.java, find the closing } of the class and add these methods above it:
    public Task createTask(CreateTaskRequest request) {
        String id = UUID.randomUUID().toString();
        Task task = new Task(
                id,
                request.getTitle(),
                request.getDescription(),
                TaskStatus.TODO,
                request.getPriority() > 0 ? request.getPriority() : 1,
                LocalDateTime.now()
        );
        tasks.put(id, task);
        return task;
    }

    public Task updateTask(String id, CreateTaskRequest request) {
        Task existing = getTaskById(id);
        existing.setTitle(request.getTitle());
        existing.setDescription(request.getDescription());
        if (request.getPriority() > 0) {
            existing.setPriority(request.getPriority());
        }
        return existing;
    }

    public void deleteTask(String id) {
        if (!tasks.containsKey(id)) {
            throw new TaskNotFoundException(id);
        }
        tasks.remove(id);
    }

What does this code do?

  • createTask() generates the UUID, sets defaults, stores the task, and returns it. All creation logic lives here now.
  • updateTask() calls getTaskById() internally. If the task does not exist, the exception is thrown from the service layer, not the controller.
  • deleteTask() checks existence before removing. The controller will never need to know about the Map.
  • Save TaskService.java.
  • Verify the project compiles with the new class by running:
./mvnw compile -q

You should see BUILD SUCCESS. The service compiles cleanly, but nothing uses it yet.

Seeing a compilation error?

  • Check that the package declaration says package com.example.taskapi.service; and the file lives in the matching directory path.
  • Confirm the closing brace of the class is present after all five methods.
  • Verify you have the @Service annotation above the class declaration.

Still stuck?

✔️ Awesome, I've got everything!

Great. Make sure you have saved TaskService.java before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm your TaskService.java contains both chunks in order:

  • The class skeleton chunk: package, imports, @Service annotation, class declaration, the HashMap field, getAllTasks(), and getTaskById().
  • The write methods chunk: createTask(), updateTask(), and deleteTask(), all inside the class before the closing brace.
Refactor TaskController to inject TaskService

The controller currently does everything itself. You will replace its contents so that each endpoint becomes a one-liner that delegates to taskService.

  • Open TaskController.java and replace the entire file contents with this code (the imports and class header with constructor injection):
package com.example.taskapi.controller;

import com.example.taskapi.dto.CreateTaskRequest;
import com.example.taskapi.model.Task;
import com.example.taskapi.service.TaskService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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<Task>> getAllTasks() {
        return ResponseEntity.ok(taskService.getAllTasks());
    }

    @GetMapping("/{id}")
    public ResponseEntity<Task> getTaskById(@PathVariable String id) {
        return ResponseEntity.ok(taskService.getTaskById(id));
    }

What changed here?

  • The HashMap, UUID, LocalDateTime, and TaskStatus imports are gone. The controller no longer touches data directly.
  • The field is now private final TaskService taskService instead of a Map. Spring injects the TaskService instance through the constructor automatically.
  • Each GET endpoint is a single line that delegates to the service. No if-checks, no map lookups.
  • Directly below the getTaskById method, add the remaining three endpoints to complete the file:

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

    @PutMapping("/{id}")
    public ResponseEntity<Task> updateTask(@PathVariable String id, @Valid @RequestBody CreateTaskRequest request) {
        return ResponseEntity.ok(taskService.updateTask(id, request));
    }

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

What does this code do?

  • createTask() delegates to the service and wraps the result in a 201 CREATED response. The controller only handles the HTTP status.
  • updateTask() is a single delegation. The service throws TaskNotFoundException if the ID does not exist.
  • deleteTask() calls the service and returns 204 No Content. The controller never touches the storage Map.
  • Save TaskController.java.
  • Verify the refactored project compiles by running:
./mvnw compile -q

You should see BUILD SUCCESS again. The controller now compiles against the service.

Compilation failed?

  • Make sure the import com.example.taskapi.service.TaskService is present at the top of your controller.
  • Check that the old imports (UUID, LocalDateTime, TaskStatus, HashMap) are removed. They are no longer needed.
  • Confirm the constructor parameter type matches the class name exactly. Spring resolves the injection by type.

Still stuck?

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before running the server.

ⓧ I'd like to double check the full code

Scroll up and confirm your TaskController.java contains both chunks in order:

  • The imports and class header chunk: package, imports (no UUID/LocalDateTime/HashMap), class declaration, the taskService field, the constructor, getAllTasks(), and getTaskById().
  • The write endpoints chunk: createTask(), updateTask(), deleteTask(), and the closing class brace.

Why constructor injection?

Spring can inject dependencies through fields, setters, or constructors. Constructor injection is the recommended pattern because it makes dependencies explicit and immutable. The final keyword guarantees the service reference can never change after the controller is created.

Since Spring 4.3, a single-constructor class does not even need an @Autowired annotation. Spring infers the injection automatically.

Run the full test suite via curl

The refactoring is complete. The real proof is that every operation you tested before still works identically. The behavior should not change, only the internal structure.

  • Start the server by running:
./mvnw spring-boot:run

Before you test, do you think the curl commands will behave any differently now that the code is split across two classes? Why or why not?

  • Open a second terminal. Create a task by running:
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"title":"Deploy to production","description":"Ship the API","priority":2}' \
  http://localhost:8080/api/tasks | python3 -m json.tool

You should see the created task with a generated id, status TODO, priority 2, and a createdAt timestamp. Identical to before.

  • List all tasks to confirm it was stored:
curl -s http://localhost:8080/api/tasks | python3 -m json.tool

You should see an array containing your newly created task.

  • Test the validation and error handling still work. Send a blank title:
curl -s -X POST -H "Content-Type: application/json" \
  -d '{"title":"","description":""}' \
  http://localhost:8080/api/tasks | python3 -m json.tool

You should see a 400 response with the validation error message about title being required.

  • Test the not-found handling:
curl -s http://localhost:8080/api/tasks/does-not-exist | python3 -m json.tool

You should see a 404 response with Task not found with id: does-not-exist. Every response is identical to what you saw in Steps 3 and 4. The refactoring changed the structure without changing the behavior.

Getting a 500 error or application startup failure?

  • If the server fails to start with a bean creation error, confirm TaskService.java has the @Service annotation. Without it, Spring cannot find a bean to inject.
  • If endpoints return 404 for all paths, check that @RestController and @RequestMapping("/api/tasks") are still on the controller class.
  • Make sure the TaskService class is in the service package under the same base package as your application class. Spring scans sub-packages automatically.

Still stuck?

  • Stop the server with Ctrl+C when you are done testing.

Your API now follows the architecture that professional Java codebases use. The controller is a thin HTTP adapter. The service holds all the business rules. You can point to this separation in an interview and explain exactly why it matters for testing and maintainability. Up next, a Secret Mission challenge to push your API further.

Secret mission

Add Filtering and Search to Your Task List

Real APIs rarely return all records unfiltered. In this mission, you'll add query parameter support to filter tasks by status and search by title keyword, demonstrating how production APIs handle optional parameters cleanly.

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, so there are no ongoing costs.

Resources you used:

  • The taskapi project folder on your Desktop (contains all Java source files, the Maven wrapper, and compiled output).
  • The Spring Boot server process running on localhost:8080 (if still running).

Keep everything running

No action needed. Choose this if you are still actively building on the API or want to revisit the code later.

  • Your taskapi folder stays on your Desktop.
  • If the server is still running, it continues to respond on localhost:8080.
  • OpenJDK 25, Neovim, and curl remain installed on your machine.

Pause - I'll come back to this later

Shut down the running server process to free up memory and the port. Your project files remain on disk for next time.

  • If the Spring Boot server is still running in your terminal, press Ctrl+C to stop it.
  • Your taskapi folder stays on your Desktop. You can restart the server any time with ./mvnw spring-boot:run from inside that folder.

Delete - I don't want to use this again

Remove the project folder entirely. This deletes all source code, compiled output, and the Maven wrapper.

  • If the Spring Boot server is still running, press Ctrl+C in the terminal to stop it.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/taskapi

On Windows, run this instead:

rmdir /s /q %USERPROFILE%\Desktop\taskapi
  • Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see taskapi listed.

Nice Work!

Nice Work!

Nice work! You just built a production-style REST API from scratch using Spring Boot 4.1.0 and Java 25. You can create, read, update, and delete tasks through clean endpoints that validate input, return proper HTTP status codes, and handle errors gracefully.

You've learned how to:

  • Build a complete CRUD REST API with proper HTTP semantics (201 on create, 204 on delete, 404 on not found) using Spring Boot annotations like @RestController, @GetMapping, and ResponseEntity.
  • Add input validation with Jakarta Bean Validation annotations and a global exception handler using @ControllerAdvice to return structured JSON error responses instead of stack traces.
  • Separate concerns into Controller, Service, and Model layers with constructor-based dependency injection, demonstrating the layered architecture pattern interviewers expect in Java codebases.
  • Secret Mission: Added query parameter filtering and search to the task list using Java Streams with composable .filter() predicates.

Ready to quiz yourself?