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:
- User visits
/tasks/add (GET) → AddTaskServlet.doGet forwards to add-task.jsp.
- User fills the form and submits (POST to
/tasks/add) → AddTaskServlet.doPost validates, writes to TaskStore, then calls sendRedirect("/tasks").
- Browser follows the 302 with a GET to
/tasks → ListTasksServlet.doGet reads from TaskStore, sets request attributes, forwards to task-list.jsp.
- 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.