Java Web & Servlets Fundamentals

Project: A Simple Servlet Web App

18 min Lesson 10 of 13

Project: A Simple Servlet Web App

This final lesson pulls together everything from the tutorial — lifecycle, request handling, form processing, ServletContext, forwarding, and response building — into a small but realistic multi-page application: a Task Manager. Users can list tasks, add a new task via a form, and delete one by ID. There is no database; tasks live in application scope so you can focus on the servlet layer without JDBC noise.

Goal of this lesson: See how individual concepts compose into a working web application, and understand the structural decisions a developer makes when all the pieces are in play at once.

Project Layout

The finished project has a standard Maven web layout:

task-manager/ ├── pom.xml └── src/main/ ├── java/com/example/tasks/ │ ├── model/Task.java │ ├── store/TaskStore.java <!-- application-scoped singleton --> │ ├── AppInitListener.java <!-- ServletContextListener --> │ ├── ListTasksServlet.java │ ├── AddTaskServlet.java │ └── DeleteTaskServlet.java └── webapp/ ├── WEB-INF/ │ ├── web.xml │ └── views/ │ ├── task-list.jsp │ └── add-task.jsp └── index.jsp <!-- redirect to /tasks -->

The Model and Store

Task is a plain Java record — immutable by definition, no boilerplate:

package com.example.tasks.model; public record Task(int id, String title, String priority) {}

TaskStore is a thread-safe in-memory store that lives as a ServletContext attribute:

package com.example.tasks.store; import com.example.tasks.model.Task; import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.atomic.AtomicInteger; public class TaskStore { private final CopyOnWriteArrayList<Task> tasks = new CopyOnWriteArrayList<>(); private final AtomicInteger idSeq = new AtomicInteger(1); public void add(String title, String priority) { tasks.add(new Task(idSeq.getAndIncrement(), title, priority)); } public boolean delete(int id) { return tasks.removeIf(t -> t.id() == id); } public List<Task> all() { return List.copyOf(tasks); // defensive snapshot } }
Why CopyOnWriteArrayList? Servlet containers are multi-threaded — two requests can arrive simultaneously. CopyOnWriteArrayList makes reads lock-free and writes safe without forcing you to write explicit synchronized blocks. For a read-heavy list this is the right trade-off.

Initializing the Store at Startup

Rather than lazy-initialising the store inside a servlet, register a ServletContextListener so the store is ready before the first request arrives:

package com.example.tasks; import com.example.tasks.store.TaskStore; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletContextEvent; import jakarta.servlet.ServletContextListener; import jakarta.servlet.annotation.WebListener; @WebListener public class AppInitListener implements ServletContextListener { @Override public void contextInitialized(ServletContextEvent sce) { TaskStore store = new TaskStore(); store.add("Write project README", "high"); store.add("Add unit tests", "medium"); store.add("Deploy to staging", "low"); ServletContext ctx = sce.getServletContext(); ctx.setAttribute("taskStore", store); ctx.log("TaskStore initialised with " + store.all().size() + " seed tasks."); } @Override public void contextDestroyed(ServletContextEvent sce) { sce.getServletContext().removeAttribute("taskStore"); } }

ListTasksServlet — Read the List

package com.example.tasks; import com.example.tasks.store.TaskStore; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/tasks") public class ListTasksServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); req.setAttribute("tasks", store.all()); // hand data to the view req.setAttribute("priority", req.getParameter("priority")); // remember filter req.getRequestDispatcher("/WEB-INF/views/task-list.jsp") .forward(req, resp); } }

Notice the pattern: the servlet fetches data, attaches it as request attributes, then forwards to a JSP. The JSP never touches the store directly — servlets own business logic, JSPs own presentation.

AddTaskServlet — Process the Form

package com.example.tasks; import com.example.tasks.store.TaskStore; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/tasks/add") public class AddTaskServlet extends HttpServlet { /** GET — show the empty form */ @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.getRequestDispatcher("/WEB-INF/views/add-task.jsp").forward(req, resp); } /** POST — validate, store, redirect */ @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String title = req.getParameter("title"); String priority = req.getParameter("priority"); if (title == null || title.isBlank()) { req.setAttribute("error", "Title is required."); req.getRequestDispatcher("/WEB-INF/views/add-task.jsp").forward(req, resp); return; } if (!List.of("high", "medium", "low").contains(priority)) { priority = "medium"; // sanitise unexpected values } TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); store.add(title.strip(), priority); // PRG — redirect after write so a browser refresh does not re-POST resp.sendRedirect(req.getContextPath() + "/tasks"); } }
Always apply the Post/Redirect/Get (PRG) pattern after a successful POST. Without it, pressing the browser back button or refreshing re-submits the form — your users accidentally create duplicate records and wonder why. sendRedirect issues an HTTP 302 so the next request is always a GET.

DeleteTaskServlet — Mutate via POST

package com.example.tasks; import com.example.tasks.store.TaskStore; import jakarta.servlet.ServletException; import jakarta.servlet.annotation.WebServlet; import jakarta.servlet.http.HttpServlet; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; @WebServlet("/tasks/delete") public class DeleteTaskServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String raw = req.getParameter("id"); try { int id = Integer.parseInt(raw); TaskStore store = (TaskStore) getServletContext().getAttribute("taskStore"); store.delete(id); } catch (NumberFormatException ignored) { // bad input — silently skip and redirect } resp.sendRedirect(req.getContextPath() + "/tasks"); } }

Deletions are always POST, never GET. A GET-based delete can be triggered by a browser prefetching links, a search engine crawling URLs, or a user accidentally clicking a link — all of which would silently destroy data.

The JSP Views

task-list.jsp iterates the task list that the servlet placed in request scope:

<%@ page contentType="text/html; charset=UTF-8" %> <%@ taglib prefix="c" uri="jakarta.tags.core" %> <!DOCTYPE html> <html><head><title>Task Manager</title></head> <body> <h1>Tasks</h1> <a href="${pageContext.request.contextPath}/tasks/add">+ Add Task</a> <table> <tr><th>ID</th><th>Title</th><th>Priority</th><th></th></tr> <c:forEach var="t" items="${tasks}"> <tr> <td>${t.id}</td> <td><c:out value="${t.title}"/></td> <td>${t.priority}</td> <td> <form method="post" action="${pageContext.request.contextPath}/tasks/delete"> <input type="hidden" name="id" value="${t.id}"/> <button type="submit">Delete</button> </form> </td> </tr> </c:forEach> </table> </body></html>
<c:out> prevents XSS. Always render user-supplied text with <c:out value="..."/> rather than bare ${expression}. The JSTL tag HTML-escapes the value, so a task title containing <script>alert(1)</script> renders as literal text instead of executing.

Wiring It Together: What You Just Built

Step back and look at the data flow for a typical "add task" round trip:

  1. User visits /tasks/add (GET) → AddTaskServlet.doGet forwards to add-task.jsp.
  2. User fills the form and submits (POST to /tasks/add) → AddTaskServlet.doPost validates, writes to TaskStore, then calls sendRedirect("/tasks").
  3. Browser follows the 302 with a GET to /tasksListTasksServlet.doGet reads from TaskStore, sets request attributes, forwards to task-list.jsp.
  4. JSP renders HTML; browser displays the updated list.

Every concept from this tutorial appears in that flow: annotation-based servlet mapping, lifecycle callbacks through the listener, doGet/doPost dispatch, parameter reading, request attributes, RequestDispatcher.forward, sendRedirect, and application-scoped shared state via ServletContext.

Where to Go from Here

This application has no authentication, no persistence, and no error pages — all real applications need those. The natural next step is to replace TaskStore with a JDBC DAO, add session-based login, and introduce a global exception handler. Those are exactly the topics covered in the subsequent tutorials in this specialisation.

Keep servlets thin. The moment business logic grows beyond a few lines, extract it into a plain Java service class (like TaskStore) that has no servlet dependency. That service becomes unit-testable without spinning up a container — which dramatically speeds up your feedback loop.

Summary

You have built a complete, multi-page Java Servlet application: a ServletContextListener initialises shared state at startup; three servlets handle GET and POST for listing, adding, and deleting tasks; JSP views render HTML from request attributes; and the PRG pattern prevents accidental duplicate submissions. This architecture — thin servlets delegating to services, views only rendering data — scales cleanly to real production systems and is the foundation on which frameworks like Spring MVC are built.