Build a Task Manager REST API

Build a REST API with Jakarta Servlets, Gson, and Tomcat 10.1.

Introduction

30 Second Summary

Every time you open a to-do app, check your email, or scroll social media, your device is sending requests to a server and getting responses back. That conversation between client and server is the backbone of every modern application.

In this project, you will build a Task Manager REST API from scratch using Jakarta Servlets and run it on a local Tomcat server. You will create endpoints that handle creating, reading, updating, and deleting tasks.

What You'll Build

A complete REST API that runs on your local machine, handles HTTP requests for task management, and persists data to a JSON file on disk.

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

  • A Maven WAR project configured to download and run Tomcat with a single command.
  • A layered architecture with separate servlet, service, and repository layers that mirrors professional Java backends.
  • A fully functional CRUD API that persists tasks to a local JSON file and survives server restarts.
  • Secret Mission: Add filtering by completion status and a search-by-title feature to your GET endpoint.

Are there any prerequisites?

You should be comfortable with basic Java OOP concepts like classes, interfaces, and inheritance. Familiarity with IntelliJ IDEA and Maven is also helpful.

You do not need any prior experience with web servers or HTTP programming. This project teaches those concepts from the ground up.

Create the Maven Project

Every REST API needs a foundation. Before you can handle HTTP requests, you need a project structure that tells Java where your code lives, what libraries to use, and how to run a web server.

In this step, you will create a Maven project in IntelliJ IDEA, configure it with the dependencies your API needs, and verify that Tomcat starts successfully.

In this step, get ready to:
  • Create a Maven WAR project with the correct coordinates and dependencies.
  • Set up the webapp directory structure with a web.xml file.
  • Verify that Tomcat starts and responds on port 8080.
Create the Maven project and configure dependencies

Maven uses a file called pom.xml to define your project's identity, dependencies, and build instructions. You will create a new project in IntelliJ and then replace the generated pom.xml with one that includes everything your REST API needs.

  • In IntelliJ IDEA, click File in the top menu bar.
  • Click New, then Project.
  • In the Name field, type task-api.
  • Select Maven as the Build system.
  • In the JDK dropdown, select your JDK 21 installation.
  • Expand Advanced Settings at the bottom of the dialog.
  • Set GroupId to com.taskmanager.
  • Set ArtifactId to task-api.
  • Click Create.

IntelliJ generates a basic project with a minimal pom.xml. You need to replace it with a version that includes your servlet and JSON dependencies, plus the Cargo plugin that manages Tomcat.

  • In the Project tool window on the left, double-click pom.xml to open it in the editor.
  • Select all the existing content with Cmd+A (macOS) or Ctrl+A (Windows) and delete it.
  • Paste the following configuration into the empty file:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <!-- Project coordinates identify your artifact uniquely -->
    <groupId>com.taskmanager</groupId>
    <artifactId>task-api</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <!-- Compile with Java 21 -->
    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <!-- Jakarta Servlet API - provided by Tomcat at runtime -->
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
            <scope>provided</scope>
        </dependency>
        <!-- Gson for JSON serialization/deserialization -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.14.0</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>task-api</finalName>
        <plugins>
            <!-- Packages the project as a WAR file -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
            </plugin>
            <!-- Cargo plugin downloads and runs Tomcat automatically -->
            <plugin>
                <groupId>org.codehaus.cargo</groupId>
                <artifactId>cargo-maven3-plugin</artifactId>
                <version>1.10.19</version>
                <configuration>
                    <container>
                        <containerId>tomcat10x</containerId>
                        <artifactInstaller>
                            <groupId>org.apache.tomcat</groupId>
                            <artifactId>tomcat</artifactId>
                            <version>10.1.56</version>
                        </artifactInstaller>
                    </container>
                    <configuration>
                        <properties>
                            <cargo.servlet.port>8080</cargo.servlet.port>
                        </properties>
                    </configuration>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

What does this configuration do?

The <packaging>war</packaging> line tells Maven to build a Web Application Archive. This is the format Tomcat expects for deployment.

The jakarta.servlet-api dependency has scope provided because Tomcat already includes it at runtime. Gson is the JSON library that converts Java objects to JSON and back.

The Cargo plugin is the key piece. It automatically downloads Tomcat 10.1.56 from Maven Central and starts it with your WAR deployed. No manual Tomcat installation needed.

  • Save pom.xml with Cmd+S (macOS) or Ctrl+S (Windows).
  • IntelliJ may show a notification asking to load Maven changes. Click Load Maven Changes (or the refresh icon that appears in the editor gutter) to import the new dependencies.

IntelliJ not recognizing the dependencies?

  • Make sure you clicked Load Maven Changes after saving. Without this, IntelliJ does not download the libraries.
  • Check that the XML is valid. A missing closing tag or extra character will prevent Maven from parsing the file.
  • If you see red underlines on the dependency lines, wait a moment. IntelliJ may still be downloading from Maven Central.

Why won't IntelliJ load my Maven dependencies?

Create the webapp directory structure

A WAR project requires a specific directory layout. The src/main/webapp/WEB-INF/ folder holds deployment configuration that Tomcat reads when it loads your application.

  • In the Project tool window, right-click the src/main folder.
  • Select New, then Directory.
  • Type webapp/WEB-INF and press Enter. IntelliJ creates both nested folders at once.

Why do I need WEB-INF?

The WEB-INF directory is a special folder in Java web applications. Files inside it are not directly accessible by web browsers. Tomcat looks here for your web.xml deployment descriptor and compiled classes.

Now create the Java package directory where your source code will live.

  • Right-click the src/main/java folder in the Project tool window.
  • Select New, then Package.
  • Type com.taskmanager and press Enter.

Next, add the web.xml file that declares your web application.

  • Right-click the WEB-INF folder.
  • Select New, then File.
  • Name it web.xml and press Enter.
  • Paste the following content into the new file:
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">
</web-app>

What does this file do?

This is a minimal deployment descriptor. The web-app_6_0.xsd schema tells Tomcat this application uses Jakarta Servlet 6.0. The file is empty because you will use annotations (@WebServlet) to register servlets instead of XML configuration.

  • Save web.xml.
  • Confirm your project structure in the Project tool window. You should see src/main/java/com/taskmanager and src/main/webapp/WEB-INF/web.xml.

Missing directories in the project tree?

  • If src/main/java does not exist, right-click src/main and create it manually via New then Directory.
  • Make sure the directories are marked correctly. Right-click src/main/java, select Mark Directory as, then Sources Root.

Why is IntelliJ not recognizing my source directories?

Verify Tomcat starts successfully

The Cargo plugin handles downloading and starting Tomcat for you. Running a single Maven command will compile your project, package it as a WAR file, download Tomcat 10.1.56, and deploy your application.

  • Open the terminal in IntelliJ by clicking View in the menu bar, then Tool Windows, then Terminal.
  • Start the server by running this command:
mvn clean package cargo:run

What does this command do?

  • mvn clean removes any previous build output.
  • package compiles your code and creates the task-api.war file.
  • cargo:run downloads Tomcat (first time only), deploys your WAR, and starts the server on port 8080.

The first run takes a few minutes because Maven downloads Tomcat and all dependencies. Subsequent runs are much faster.

  • Wait until you see a log line containing Tomcat 10. and a message indicating the server has started.
  • Open your browser and navigate to http://localhost:8080.

You will see a blank page or a 404 error. This is expected. The server is running, but you have not created any servlets yet.

Command not found or build failure?

  • If you see a message about mvn not being recognized, make sure Maven is installed and on your system PATH. Type mvn -version to check.
  • If the build fails with XML parsing errors, double-check that your pom.xml is well-formed. A stray character or missing closing tag will cause the failure.
  • If port 8080 is already in use, stop the other process or change cargo.servlet.port in your pom.xml to another port like 9090.

Help me troubleshoot my Maven Cargo build

  • Stop the server by pressing Ctrl+C in the terminal.

✔️ Awesome, I've got everything!

Great. Double check you have saved both pom.xml and web.xml.

ⓧ I'd like to double check the full code

Here is the complete pom.xml:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.taskmanager</groupId>
    <artifactId>task-api</artifactId>
    <version>1.0-SNAPSHOT</version>
    <packaging>war</packaging>

    <properties>
        <maven.compiler.source>21</maven.compiler.source>
        <maven.compiler.target>21</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>jakarta.servlet</groupId>
            <artifactId>jakarta.servlet-api</artifactId>
            <version>6.0.0</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.14.0</version>
        </dependency>
    </dependencies>

    <build>
        <finalName>task-api</finalName>
        <plugins>
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-war-plugin</artifactId>
                <version>3.4.0</version>
            </plugin>
            <plugin>
                <groupId>org.codehaus.cargo</groupId>
                <artifactId>cargo-maven3-plugin</artifactId>
                <version>1.10.19</version>
                <configuration>
                    <container>
                        <containerId>tomcat10x</containerId>
                        <artifactInstaller>
                            <groupId>org.apache.tomcat</groupId>
                            <artifactId>tomcat</artifactId>
                            <version>10.1.56</version>
                        </artifactInstaller>
                    </container>
                    <configuration>
                        <properties>
                            <cargo.servlet.port>8080</cargo.servlet.port>
                        </properties>
                    </configuration>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

Here is the complete web.xml:

<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="https://jakarta.ee/xml/ns/jakartaee https://jakarta.ee/xml/ns/jakartaee/web-app_6_0.xsd"
         version="6.0">
</web-app>

Your project skeleton is ready and Tomcat runs without errors. Next up, you will build the data model and repository layer that stores your tasks.

Build the Data Model and Repository Layer

Your project skeleton is ready and Tomcat is running. Now you need to define what a "task" looks like in your system and build the code that stores tasks on disk.

Every REST API needs a data model (the shape of the data) and a persistence layer (how data gets saved and loaded). In this step, you will create both using a Java record for the model and a repository class that reads and writes JSON to a file.

In this step, get ready to:
  • Create an immutable Task data model using a Java record.
  • Build a TaskRepository class that persists tasks to a JSON file.
  • Verify persistence works by saving a test task.
Create the Task record

A task in your API needs four pieces of information: an ID, a title, a description, and whether it is completed. Java 21 gives you records for exactly this purpose. A record is a compact class that holds data without requiring boilerplate getters, constructors, or equals/hashCode methods.

  • In IntelliJ IDEA, open the Project tool window by pressing Cmd+1 (macOS) or Alt+1 (Windows/Linux).
  • Right-click the com.taskmanager folder inside src/main/java.
  • Select New then Java Class.
  • In the Name field, type model.Task and select Record from the type dropdown.
  • Press Enter. IntelliJ creates the model package and Task.java file automatically.

IntelliJ generates a basic record stub. Replace the entire file contents with the following code:

package com.taskmanager.model;

public record Task(String id, String title, String description, boolean completed) {
}

What is a Java record?

A record is a special kind of class introduced in Java 14 (stable since Java 16). It automatically generates a constructor, accessor methods, and equals/hashCode/toString based on the fields you declare in the parentheses.

Records are immutable by design. You cannot change a record's fields after creation. To "update" a task, you create a new Task instance with the modified values.

  • Save Task.java with Cmd+S (macOS) or Ctrl+S (Windows/Linux).
  • Check that Task.java appears under src/main/java/com/taskmanager/model/ in the Project tool window.

Don't see the model package?

Make sure you typed model.Task (with the dot) in the Name field. If you typed just Task, the file was created directly inside com.taskmanager without a model sub-package. Delete the file, then recreate it with the full name.

help me create a Java record in the correct package

Build the TaskRepository class

The repository pattern separates your data access logic from everything else. Your TaskRepository will keep an in-memory list of tasks and sync it to a tasks.json file using Gson.

  • Right-click the com.taskmanager folder in the Project tool window.
  • Select New then Java Class.
  • Type repository.TaskRepository in the Name field and select Class as the type.
  • Press Enter to create the file.

Replace the entire contents of TaskRepository.java with this code:

package com.taskmanager.repository;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.taskmanager.model.Task;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class TaskRepository {

    private final Path filePath;
    private final Gson gson;
    private final Type taskListType;
    private List<Task> tasks;

    public TaskRepository(Path filePath) {
        this.filePath = filePath;
        this.gson = new GsonBuilder().setPrettyPrinting().create();
        this.taskListType = new TypeToken<List<Task>>() {}.getType();
        this.tasks = loadFromFile();
    }

    public List<Task> findAll() {
        return new ArrayList<>(tasks);
    }

    public Optional<Task> findById(String id) {
        return tasks.stream()
                .filter(task -> task.id().equals(id))
                .findFirst();
    }

    public Task save(Task task) {
        tasks.add(task);
        saveToFile();
        return task;
    }

    public Optional<Task> update(Task updatedTask) {
        for (int i = 0; i < tasks.size(); i++) {
            if (tasks.get(i).id().equals(updatedTask.id())) {
                tasks.set(i, updatedTask);
                saveToFile();
                return Optional.of(updatedTask);
            }
        }
        return Optional.empty();
    }

    public boolean delete(String id) {
        boolean removed = tasks.removeIf(task -> task.id().equals(id));
        if (removed) {
            saveToFile();
        }
        return removed;
    }

    private List<Task> loadFromFile() {
        if (!Files.exists(filePath)) {
            return new ArrayList<>();
        }
        try (Reader reader = Files.newBufferedReader(filePath)) {
            List<Task> loaded = gson.fromJson(reader, taskListType);
            return loaded != null ? new ArrayList<>(loaded) : new ArrayList<>();
        } catch (IOException e) {
            return new ArrayList<>();
        }
    }

    private void saveToFile() {
        try (Writer writer = Files.newBufferedWriter(filePath)) {
            gson.toJson(tasks, taskListType, writer);
        } catch (IOException e) {
            throw new RuntimeException("Failed to save tasks to file", e);
        }
    }
}

What does this code do?

  • The constructor accepts a file path, sets up Gson with pretty printing, and loads any existing tasks from disk.
  • findAll() returns a copy of the tasks list so callers cannot accidentally corrupt the internal state.
  • findById() uses Java streams to search for a matching ID and returns an Optional (empty if not found).
  • save(), update(), and delete() modify the in-memory list and immediately write the updated list to the JSON file.
  • loadFromFile() and saveToFile() handle the file I/O using java.nio.file for reading and writing.

Why TypeToken?

Gson cannot determine generic types like List<Task> at runtime due to Java's type erasure. TypeToken captures the full generic type at compile time so Gson knows how to deserialize the JSON array back into a list of Task objects.

  • Save TaskRepository.java.
  • Check that TaskRepository.java appears under src/main/java/com/taskmanager/repository/ in the Project tool window.

Seeing red underlines on imports?

If IntelliJ shows errors on the Gson imports, your pom.xml dependencies may not have been downloaded yet. Right-click pom.xml in the Project window, then select Maven then Reload project. This fetches missing dependencies.

help me fix unresolved Gson imports in IntelliJ

Test the repository with a temporary main method

Before building the servlet layer, confirm that the repository actually creates and reads tasks.json. You will add a temporary main method, run it, verify the file appears, then remove it.

  • Open TaskRepository.java and add the following main method inside the class body, after the saveToFile() method:
    // Temporary test - delete after verifying
    public static void main(String[] args) {
        TaskRepository repo = new TaskRepository(Path.of("tasks.json"));
        Task testTask = new Task("test-1", "Learn Servlets", "Build a REST API from scratch", false);
        repo.save(testTask);
        System.out.println("Saved tasks: " + repo.findAll());
    }

What does this test do?

It creates a TaskRepository pointing at tasks.json in the project root. It then saves one task and prints the entire task list to the console. If tasks.json appears on disk with valid JSON content, your persistence layer works.

  • Save the file.
  • Click the green play arrow in the gutter next to public static void main and select Run 'TaskRepository.main()'.
  • In the Run tool window at the bottom, you should see output like: Saved tasks: [Task[id=test-1, title=Learn Servlets, description=Build a REST API from scratch, completed=false]].
  • Open the project root in the Project tool window. You should see a tasks.json file.
  • Double-click tasks.json to open it. It should contain a JSON array with your test task.

No tasks.json file appearing?

The file is created relative to the working directory that IntelliJ uses when running the class. By default this is the project root. If the file is not there, check IntelliJ's Run configuration: click the dropdown next to the run button, select Edit Configurations, and confirm the Working directory field points to your task-api project folder.

help me find where tasks.json was created

  • Now that you have confirmed persistence works, delete the entire main method (including the comment above it) from TaskRepository.java.
  • Save TaskRepository.java.

Keep tasks.json for now

You can leave the tasks.json file in place or delete it. The repository handles both cases gracefully. If the file is missing on startup, it starts with an empty list.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both Task.java and TaskRepository.java, and that the temporary main method has been removed.

ⓧ I'd like to double check the full code

Here is the complete code for both files at this point:

package com.taskmanager.model;

public record Task(String id, String title, String description, boolean completed) {
}
package com.taskmanager.repository;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.reflect.TypeToken;
import com.taskmanager.model.Task;

import java.io.IOException;
import java.io.Reader;
import java.io.Writer;
import java.lang.reflect.Type;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;

public class TaskRepository {

    private final Path filePath;
    private final Gson gson;
    private final Type taskListType;
    private List<Task> tasks;

    public TaskRepository(Path filePath) {
        this.filePath = filePath;
        this.gson = new GsonBuilder().setPrettyPrinting().create();
        this.taskListType = new TypeToken<List<Task>>() {}.getType();
        this.tasks = loadFromFile();
    }

    public List<Task> findAll() {
        return new ArrayList<>(tasks);
    }

    public Optional<Task> findById(String id) {
        return tasks.stream()
                .filter(task -> task.id().equals(id))
                .findFirst();
    }

    public Task save(Task task) {
        tasks.add(task);
        saveToFile();
        return task;
    }

    public Optional<Task> update(Task updatedTask) {
        for (int i = 0; i < tasks.size(); i++) {
            if (tasks.get(i).id().equals(updatedTask.id())) {
                tasks.set(i, updatedTask);
                saveToFile();
                return Optional.of(updatedTask);
            }
        }
        return Optional.empty();
    }

    public boolean delete(String id) {
        boolean removed = tasks.removeIf(task -> task.id().equals(id));
        if (removed) {
            saveToFile();
        }
        return removed;
    }

    private List<Task> loadFromFile() {
        if (!Files.exists(filePath)) {
            return new ArrayList<>();
        }
        try (Reader reader = Files.newBufferedReader(filePath)) {
            List<Task> loaded = gson.fromJson(reader, taskListType);
            return loaded != null ? new ArrayList<>(loaded) : new ArrayList<>();
        } catch (IOException e) {
            return new ArrayList<>();
        }
    }

    private void saveToFile() {
        try (Writer writer = Files.newBufferedWriter(filePath)) {
            gson.toJson(tasks, taskListType, writer);
        } catch (IOException e) {
            throw new RuntimeException("Failed to save tasks to file", e);
        }
    }
}

Your data model and persistence layer are solid. Next up, you will build the service layer and the servlet that turns HTTP requests into method calls on your repository.

Create the Service Layer and Servlet

You have a working data model and repository that can read and write tasks to a JSON file. Now it is time to connect that storage layer to the web.

In this step you will build two new layers on top of the repository: a service layer that handles business logic (like generating unique IDs), and a servlet that receives HTTP requests and returns JSON responses. Together, these three layers form the classic Servlet > Service > Repository architecture used in professional Java backends.

In this step, get ready to:
  • Create a TaskService class that wraps the repository with business logic.
  • Create a TaskServlet class that maps HTTP methods to service calls.
  • Verify the running API returns an empty task list.
Create the TaskService class

The service layer sits between the servlet and the repository. Its job is to hold business logic that does not belong in either the HTTP layer or the persistence layer.

  • In the Project tool window, right-click the com.taskmanager package inside src/main/java.
  • Select New then Package and enter com.taskmanager.service.
  • Right-click the new service package, select New then Java Class, and name it TaskService.
  • Replace the generated content with the following code:
package com.taskmanager.service;

import com.taskmanager.model.Task;
import com.taskmanager.repository.TaskRepository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class TaskService {

    private final TaskRepository repository;

    public TaskService(TaskRepository repository) {
        this.repository = repository;
    }

    public List<Task> getAllTasks() {
        return repository.findAll();
    }

    public Optional<Task> getTask(String id) {
        return repository.findById(id);
    }

    public Task createTask(String title, String description) {
        // Generate a unique ID and default completed to false
        String id = UUID.randomUUID().toString();
        Task task = new Task(id, title, description, false);
        return repository.save(task);
    }

    public Optional<Task> updateTask(String id, String title, String description, boolean completed) {
        // Create a new Task record with updated values
        Task updatedTask = new Task(id, title, description, completed);
        return repository.update(updatedTask);
    }

    public boolean deleteTask(String id) {
        return repository.delete(id);
    }
}

What does this code do?

The TaskService wraps TaskRepository and adds business logic on top of raw persistence operations.

  • createTask() generates a UUID for the new task's ID and defaults completed to false. The caller never has to worry about ID generation.
  • updateTask() creates a brand-new Task record with updated values. Java records are immutable, so updating means constructing a replacement.
  • getAllTasks() and getTask() delegate directly to the repository because no extra logic is needed yet.

Why a separate service layer?

You could call the repository directly from the servlet. But separating business logic into its own class means the servlet only handles HTTP concerns (parsing requests, writing responses), and the repository only handles persistence.

When your app grows, this separation makes it easy to add validation, caching, or event publishing without touching the HTTP or storage code.

  • Save TaskService.java.
  • You will not see a change yet. The servlet in the next substep uses this service to handle incoming requests.

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

package com.taskmanager.service;

import com.taskmanager.model.Task;
import com.taskmanager.repository.TaskRepository;

import java.util.List;
import java.util.Optional;
import java.util.UUID;

public class TaskService {

    private final TaskRepository repository;

    public TaskService(TaskRepository repository) {
        this.repository = repository;
    }

    public List<Task> getAllTasks() {
        return repository.findAll();
    }

    public Optional<Task> getTask(String id) {
        return repository.findById(id);
    }

    public Task createTask(String title, String description) {
        String id = UUID.randomUUID().toString();
        Task task = new Task(id, title, description, false);
        return repository.save(task);
    }

    public Optional<Task> updateTask(String id, String title, String description, boolean completed) {
        Task updatedTask = new Task(id, title, description, completed);
        return repository.update(updatedTask);
    }

    public boolean deleteTask(String id) {
        return repository.delete(id);
    }
}
Create the TaskServlet class

A Jakarta Servlet is a Java class that receives HTTP requests and sends back responses. By overriding methods like doGet and doPost, you control exactly what happens for each HTTP method.

  • Right-click the com.taskmanager package inside src/main/java.
  • Select New then Package and enter com.taskmanager.servlet.
  • Right-click the new servlet package, select New then Java Class, and name it TaskServlet.
  • Replace the generated content with the following code:
package com.taskmanager.servlet;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.taskmanager.model.Task;
import com.taskmanager.repository.TaskRepository;
import com.taskmanager.service.TaskService;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;

@WebServlet("/api/tasks/*")
public class TaskServlet extends HttpServlet {

    private TaskService taskService;
    private Gson gson;

    @Override
    public void init() {
        // Set up the service layer when the servlet starts
        Path dataFile = Path.of("tasks.json");
        TaskRepository repository = new TaskRepository(dataFile);
        this.taskService = new TaskService(repository);
        this.gson = new Gson();
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);

        if (taskId == null) {
            // No ID in the path means return all tasks
            List<Task> tasks = taskService.getAllTasks();
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().write(gson.toJson(tasks));
        } else {
            // ID present means return a single task
            Optional<Task> task = taskService.getTask(taskId);
            if (task.isPresent()) {
                response.setStatus(HttpServletResponse.SC_OK);
                response.getWriter().write(gson.toJson(task.get()));
            } else {
                response.setStatus(HttpServletResponse.SC_NOT_FOUND);
                response.getWriter().write("{\"error\":\"Task not found\"}");
            }
        }
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        // Read the JSON body from the request
        String body = new String(request.getInputStream().readAllBytes());
        JsonObject json = JsonParser.parseString(body).getAsJsonObject();

        String title = json.get("title").getAsString();
        String description = json.has("description") ? json.get("description").getAsString() : "";

        Task created = taskService.createTask(title, description);

        response.setStatus(HttpServletResponse.SC_CREATED);
        response.getWriter().write(gson.toJson(created));
    }

    @Override
    protected void doPut(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);
        if (taskId == null) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("{\"error\":\"Task ID required\"}");
            return;
        }

        // Parse the updated task data from the request body
        String body = new String(request.getInputStream().readAllBytes());
        JsonObject json = JsonParser.parseString(body).getAsJsonObject();

        String title = json.get("title").getAsString();
        String description = json.has("description") ? json.get("description").getAsString() : "";
        boolean completed = json.has("completed") && json.get("completed").getAsBoolean();

        Optional<Task> updated = taskService.updateTask(taskId, title, description, completed);

        if (updated.isPresent()) {
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().write(gson.toJson(updated.get()));
        } else {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().write("{\"error\":\"Task not found\"}");
        }
    }

    @Override
    protected void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);
        if (taskId == null) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("{\"error\":\"Task ID required\"}");
            return;
        }

        boolean deleted = taskService.deleteTask(taskId);

        if (deleted) {
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        } else {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().write("{\"error\":\"Task not found\"}");
        }
    }

    private String getTaskIdFromPath(HttpServletRequest request) {
        // Extract the task ID from the URL path after /api/tasks/
        String pathInfo = request.getPathInfo();
        if (pathInfo == null || pathInfo.equals("/")) {
            return null;
        }
        return pathInfo.substring(1);
    }
}

What does this code do?

The @WebServlet("/api/tasks/*") annotation tells Tomcat to route any request starting with /api/tasks to this class. The /* wildcard means it also catches paths like /api/tasks/abc123.

  • init() runs once when the servlet starts. It creates the repository and service objects the HTTP methods use.
  • doGet() checks if a task ID is in the URL. If not, it returns all tasks. If yes, it returns just that one task (or a 404).
  • doPost() reads the JSON body, extracts the title and description, and calls createTask() to generate a new task with a unique ID.
  • doPut() requires a task ID in the URL. It reads the updated fields from the JSON body and replaces the task.
  • doDelete() requires a task ID and removes that task from storage.
  • Save TaskServlet.java.
  • You will not see a change yet. The next substep runs the server so you can confirm everything works together.

Seeing red underlines in IntelliJ?

  • Make sure the package declaration at the top matches the folder path exactly: com.taskmanager.servlet.
  • Check that your pom.xml still has the jakarta.servlet-api and gson dependencies. IntelliJ may need a moment to re-index after you save.
  • If imports show errors, click the red lightbulb icon and select Import class to let IntelliJ resolve them.

Why are my imports not resolving?

✔️ Awesome, I've got everything!

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

ⓧ I'd like to double check the full code

package com.taskmanager.servlet;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.taskmanager.model.Task;
import com.taskmanager.repository.TaskRepository;
import com.taskmanager.service.TaskService;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;

@WebServlet("/api/tasks/*")
public class TaskServlet extends HttpServlet {

    private TaskService taskService;
    private Gson gson;

    @Override
    public void init() {
        Path dataFile = Path.of("tasks.json");
        TaskRepository repository = new TaskRepository(dataFile);
        this.taskService = new TaskService(repository);
        this.gson = new Gson();
    }

    @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);

        if (taskId == null) {
            List<Task> tasks = taskService.getAllTasks();
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().write(gson.toJson(tasks));
        } else {
            Optional<Task> task = taskService.getTask(taskId);
            if (task.isPresent()) {
                response.setStatus(HttpServletResponse.SC_OK);
                response.getWriter().write(gson.toJson(task.get()));
            } else {
                response.setStatus(HttpServletResponse.SC_NOT_FOUND);
                response.getWriter().write("{\"error\":\"Task not found\"}");
            }
        }
    }

    @Override
    protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String body = new String(request.getInputStream().readAllBytes());
        JsonObject json = JsonParser.parseString(body).getAsJsonObject();

        String title = json.get("title").getAsString();
        String description = json.has("description") ? json.get("description").getAsString() : "";

        Task created = taskService.createTask(title, description);

        response.setStatus(HttpServletResponse.SC_CREATED);
        response.getWriter().write(gson.toJson(created));
    }

    @Override
    protected void doPut(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);
        if (taskId == null) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("{\"error\":\"Task ID required\"}");
            return;
        }

        String body = new String(request.getInputStream().readAllBytes());
        JsonObject json = JsonParser.parseString(body).getAsJsonObject();

        String title = json.get("title").getAsString();
        String description = json.has("description") ? json.get("description").getAsString() : "";
        boolean completed = json.has("completed") && json.get("completed").getAsBoolean();

        Optional<Task> updated = taskService.updateTask(taskId, title, description, completed);

        if (updated.isPresent()) {
            response.setStatus(HttpServletResponse.SC_OK);
            response.getWriter().write(gson.toJson(updated.get()));
        } else {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().write("{\"error\":\"Task not found\"}");
        }
    }

    @Override
    protected void doDelete(HttpServletRequest request, HttpServletResponse response) throws IOException {
        response.setContentType("application/json");
        response.setCharacterEncoding("UTF-8");

        String taskId = getTaskIdFromPath(request);
        if (taskId == null) {
            response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
            response.getWriter().write("{\"error\":\"Task ID required\"}");
            return;
        }

        boolean deleted = taskService.deleteTask(taskId);

        if (deleted) {
            response.setStatus(HttpServletResponse.SC_NO_CONTENT);
        } else {
            response.setStatus(HttpServletResponse.SC_NOT_FOUND);
            response.getWriter().write("{\"error\":\"Task not found\"}");
        }
    }

    private String getTaskIdFromPath(HttpServletRequest request) {
        String pathInfo = request.getPathInfo();
        if (pathInfo == null || pathInfo.equals("/")) {
            return null;
        }
        return pathInfo.substring(1);
    }
}
Understand the URL path routing

Every HTTP method in the servlet calls getTaskIdFromPath() to decide whether the request targets the entire task collection or a single task. This small helper is what makes the same /api/tasks/* endpoint serve both purposes.

How does getTaskIdFromPath work?

When a request arrives at /api/tasks, the getPathInfo() method returns null. The helper returns null to signal "this targets the collection."

When a request arrives at /api/tasks/abc123, getPathInfo() returns /abc123. The helper strips the leading / and returns abc123 as the task ID.

Each HTTP method checks this return value: null means operate on the collection, and a non-null value means operate on that specific task.

  • If your Tomcat server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
  • Rebuild and restart the server by running:
mvn clean package cargo:run
  • Open your browser and navigate to http://localhost:8080/task-api/api/tasks.
  • You should see an empty JSON array: [].

Seeing a 404 or a blank page?

  • Make sure your URL includes the context path /task-api before /api/tasks. The full URL is http://localhost:8080/task-api/api/tasks.
  • Check the terminal output for compilation errors. If Maven shows a BUILD FAILURE, read the error message to find which file has a typo.
  • Confirm the @WebServlet("/api/tasks/*") annotation is present at the top of your TaskServlet class.

Why am I getting a 404 on my servlet endpoint?

Your API is live and responding to GET requests. Next up, you will test all four CRUD operations to confirm POST, PUT, and DELETE work correctly too.

Test All CRUD Operations

You have a running server and a complete set of layers working together. Now it is time to prove that every HTTP method does what it should.

In this step you will send requests to your API using IntelliJ IDEA's built-in HTTP Client. You will verify that creating, reading, updating, and deleting tasks all produce the correct responses and status codes.

In this step, get ready to:
  • Create an HTTP test file and test POST to create a task.
  • Test GET for a single task and PUT to update it.
  • Test DELETE and confirm data persists across server restarts.
Create the HTTP test file and test POST

IntelliJ IDEA has a built-in HTTP Client that reads .http files. Each file can hold multiple requests separated by ### lines. You click a green play button in the gutter next to any request to run it.

  • In IntelliJ IDEA, right-click the project root (task-api) in the Project panel.
  • Select New, then File.
  • Name the file requests.http and press Enter.
  • Paste the following content into requests.http:
### Create a task
POST http://localhost:8080/task-api/api/tasks
Content-Type: application/json

{"title": "Learn Servlets", "description": "Build a REST API from scratch"}

### Get all tasks
GET http://localhost:8080/task-api/api/tasks

### Get single task (replace {id} with actual UUID)
GET http://localhost:8080/task-api/api/tasks/{id}

### Update task (replace {id} with actual UUID)
PUT http://localhost:8080/task-api/api/tasks/{id}
Content-Type: application/json

{"title": "Learn Servlets", "description": "Build a REST API from scratch", "completed": true}

### Delete task (replace {id} with actual UUID)
DELETE http://localhost:8080/task-api/api/tasks/{id}

What does this file do?

Each ### line separates a distinct request. The HTTP Client displays a green play button next to each one so you can execute them individually.

The {id} placeholders are not live variables. You will manually replace them with the UUID you receive from the POST response.

  • Make sure your server is still running from the previous step. If you stopped it, restart it by running:
mvn clean package cargo:run
  • In requests.http, click the green play button in the gutter next to the POST request (the first one labeled "Create a task").

The response panel at the bottom of IntelliJ shows the server's reply. You should see a 201 status code and a JSON body containing your task with a generated UUID in the id field.

What should I see?

The response body looks similar to this (your UUID will differ):

{
  "id": "a2c4e6f8-1234-5678-9abc-def012345678",
  "title": "Learn Servlets",
  "description": "Build a REST API from scratch",
  "completed": false
}
  • Copy the id value from the response. You will use it for the next requests.

Not seeing a 201 response?

  • Check that the server is running. The terminal should show Tomcat started on port 8080.
  • Make sure there is a blank line between the Content-Type header and the JSON body. The HTTP Client requires this separator.
  • Verify the URL includes /task-api/ in the path. This is the webapp context path set by the WAR filename.

Why is my POST request returning an error?

Test GET and PUT

Now that you have a task stored, you can fetch it individually and update it.

  • In requests.http, find the "Get single task" request.
  • Replace {id} in the URL with the UUID you copied from the POST response.
  • Click the green play button next to that GET request.

You should see a 200 status code with the single task object in the response body.

  • Now find the "Update task" request in requests.http.
  • Replace {id} in that URL with the same UUID.
  • Click the green play button next to the PUT request.

The response shows a 200 status code. The JSON body now includes "completed": true, confirming the update worked.

Why does PUT return the full object?

Returning the updated resource in the response lets the client confirm every field without making a follow-up GET request. This is a common REST convention for PUT and POST.

Seeing a 404 on GET or PUT?

  • Double-check that you pasted the exact UUID from the POST response. Even one missing character causes a mismatch.
  • Make sure the URL path is /task-api/api/tasks/ followed by the UUID with no trailing slash or extra spaces.

Why is my GET request returning 404 for a task I just created?

Test DELETE and verify persistence

The final operation removes a task entirely. After that, you will restart the server to prove that your file-based persistence actually works.

  • In requests.http, find the "Delete task" request.
  • Replace {id} with the same UUID.
  • Click the green play button next to the DELETE request.

You should see a 204 No Content response with an empty body. This is the standard HTTP status for a successful deletion.

  • Click the green play button next to the "Get all tasks" request to confirm the task is gone.

The response should be an empty JSON array [].

What is a 204 No Content response?

HTTP 204 means the server processed the request successfully but has nothing to send back. It is the correct status for DELETE because the resource no longer exists to return.

Now test that data survives a server restart.

  • Run the POST request again to create a new task.
  • Stop the server by pressing Ctrl+C in the terminal where Tomcat is running.
  • Restart the server by running:
mvn clean package cargo:run
  • Once Tomcat finishes starting, click the green play button next to the "Get all tasks" request.

You should see the task you created before the restart still in the response. The tasks.json file in your project root persisted the data to disk.

Task disappeared after restart?

  • Open tasks.json in the project root and check that it contains a JSON array with your task. If the file is empty or missing, the TaskRepository may not have write permissions to that directory.
  • Make sure you ran the POST request (and saw a 201 response) before stopping the server. The file is only written after a successful mutation.

Why does my task data not survive a server restart?

✔️ Awesome, I've got everything!

Great. Double check you have saved requests.http and that your server is running cleanly.

ⓧ I'd like to double check the full code

Here is the complete requests.http file for this step:

### Create a task
POST http://localhost:8080/task-api/api/tasks
Content-Type: application/json

{"title": "Learn Servlets", "description": "Build a REST API from scratch"}

### Get all tasks
GET http://localhost:8080/task-api/api/tasks

### Get single task (replace {id} with actual UUID)
GET http://localhost:8080/task-api/api/tasks/{id}

### Update task (replace {id} with actual UUID)
PUT http://localhost:8080/task-api/api/tasks/{id}
Content-Type: application/json

{"title": "Learn Servlets", "description": "Build a REST API from scratch", "completed": true}

### Delete task (replace {id} with actual UUID)
DELETE http://localhost:8080/task-api/api/tasks/{id}

All four CRUD operations are working and your data persists to disk. Your REST API is feature-complete. Next up, you will tackle the Secret Mission and add filtering and search to make your API even more powerful.

Secret mission

Add Filtering and Search

Your API returns all tasks every time. Real APIs let users filter and search. Add query parameter support so users can filter by completion status, search by title, or combine both filters in a single request.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs or cloud resources to worry about. Decide whether to keep your project files to continue building, or delete them entirely.

Resources you used:

  • The task-api project folder (all source code, built artifacts, and tasks.json data file).
  • Tomcat 10.1 downloaded by the Cargo plugin (cached in your local Maven repository at ~/.m2/repository/org/apache/tomcat/).

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to extend it further.

  • Your project files stay in the task-api folder wherever you created them.
  • The Tomcat download remains cached in your Maven local repository. This takes up approximately 15 MB but speeds up future builds.
  • The tasks.json file retains all tasks you created during testing.

Pause - I'll come back to this later

Stop the running Tomcat server to free up port 8080 and reclaim memory. Your project files stay intact for when you return.

  • In the IntelliJ terminal where Tomcat is running, press Ctrl+C to stop the server.
  • All source code, configuration, and your tasks.json data file remain on disk.
  • To resume later, open the project in IntelliJ and run mvn clean package cargo:run again.

Delete - I don't want to use this again

Remove all project files and cached downloads to start completely fresh.

  • In the IntelliJ terminal where Tomcat is running, press Ctrl+C to stop the server.
  • Close the project in IntelliJ by clicking File, then Close Project.
  • Delete the project folder by running this command (macOS/Linux):
rm -rf ~/Desktop/task-api

On Windows, run this instead:

rmdir /s /q %USERPROFILE%\Desktop\task-api
  • To remove the cached Tomcat download from your Maven repository, run (macOS/Linux):
rm -rf ~/.m2/repository/org/apache/tomcat/tomcat/10.1.56

On Windows, run this instead:

rmdir /s /q %USERPROFILE%\.m2\repository\org\apache\tomcat\tomcat\10.1.56

Nice Work!

Nice Work!

You just built a complete REST API from scratch using Jakarta Servlets running on Tomcat 10.1. You now understand exactly what happens when an HTTP request hits a Java web server.

What you learned:

  • Built a full REST API with GET, POST, PUT, and DELETE endpoints using Jakarta Servlets, handling JSON request and response bodies with Gson.
  • Implemented a layered architecture (Servlet > Service > Repository) that separates HTTP handling, business logic, and data persistence into clean, reusable layers.
  • Used Java 21 records as immutable data models and persisted data to a local JSON file without needing a database.
  • Secret Mission: Added query parameter filtering by completion status and case-insensitive title search using Java streams.

Ready to quiz yourself?