Project: An MVC Web App with JSP
This final lesson brings together every concept from the tutorial — Servlets as controllers, JSP pages as views, EL, JSTL, includes, and the MVC pattern — into a single coherent, runnable project. You will build a Product Catalog mini-app: list all products, view a single product's detail, and add a new product through a form. The goal is not just working code but code that a professional would not be embarrassed to check in.
Project Structure
A clean MVC layout keeps controller logic, view templates, and model classes in their own layers. Here is the directory tree for the WAR you will build:
src/main/
├── java/com/example/catalog/
│ ├── model/
│ │ └── Product.java <-- plain Java bean (the Model)
│ ├── dao/
│ │ └── ProductDao.java <-- data-access object (thin service layer)
│ └── web/
│ ├── ProductListServlet.java <-- GET /products
│ ├── ProductDetailServlet.java <-- GET /products/{id}
│ └── ProductFormServlet.java <-- GET + POST /products/new
└── webapp/
├── WEB-INF/
│ ├── web.xml <-- servlet mappings (or use annotations)
│ └── views/
│ ├── layout/
│ │ ├── header.jspf <-- shared header include
│ │ └── footer.jspf <-- shared footer include
│ ├── productList.jsp
│ ├── productDetail.jsp
│ └── productForm.jsp
└── static/
└── style.css
Why put JSPs inside WEB-INF? Files under WEB-INF are not directly accessible via HTTP — only code on the server can forward to them. This forces every request through a Servlet controller; a user can never bypass the controller and hit a raw JSP URL.
The Model: Product.java
The model is a plain, immutable-friendly Java record (Jakarta EE 10 / Java 17+). Records eliminate boilerplate getters and work perfectly with EL in JSP.
package com.example.catalog.model;
public record Product(
int id,
String name,
String description,
double price,
int stock
) {}
The DAO Layer
The DAO shields the controller from SQL. In a real project it would use JDBC or JPA; here we seed in-memory data to keep the focus on the MVC wiring.
package com.example.catalog.dao;
import com.example.catalog.model.Product;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.atomic.AtomicInteger;
public class ProductDao {
private static final AtomicInteger COUNTER = new AtomicInteger(3);
private static final List<Product> STORE = new CopyOnWriteArrayList<>(List.of(
new Product(1, "Mechanical Keyboard", "TKL, Cherry MX Brown switches", 129.99, 42),
new Product(2, "USB-C Hub", "7-in-1, 4K HDMI, 100W PD", 49.99, 98),
new Product(3, "Webcam HD", "1080p, built-in noise cancel", 79.99, 15)
));
public List<Product> findAll() {
return List.copyOf(STORE);
}
public Optional<Product> findById(int id) {
return STORE.stream().filter(p -> p.id() == id).findFirst();
}
public Product save(String name, String description, double price, int stock) {
Product p = new Product(COUNTER.incrementAndGet(), name, description, price, stock);
STORE.add(p);
return p;
}
}
Controller 1 — ProductListServlet
This controller loads the full product list, puts it in the request scope, and forwards to the JSP view. Notice the clean separation: zero HTML here, zero business logic in the JSP.
package com.example.catalog.web;
import com.example.catalog.dao.ProductDao;
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("/products")
public class ProductListServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.setAttribute("products", dao.findAll());
req.setAttribute("pageTitle", "Product Catalog");
req.getRequestDispatcher("/WEB-INF/views/productList.jsp")
.forward(req, resp);
}
}
The View: productList.jsp
The JSP uses JSTL <c:forEach> to iterate and EL to read attributes — no scriptlets, no Java imports inside the template.
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fmt" uri="jakarta.tags.fmt" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>${pageTitle}</title>
<link rel="stylesheet" href="${pageContext.request.contextPath}/static/style.css">
</head>
<body>
<%@ include file="/WEB-INF/views/layout/header.jspf" %>
<h1>${pageTitle}</h1>
<a href="${pageContext.request.contextPath}/products/new" class="btn">+ Add Product</a>
<c:choose>
<c:when test="${empty products}">
<p class="empty">No products yet.</p>
</c:when>
<c:otherwise>
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Price</th><th>Stock</th><th></th></tr>
</thead>
<tbody>
<c:forEach var="p" items="${products}">
<tr>
<td>${p.id}</td>
<td>${fn:escapeXml(p.name)}</td>
<td>
<fmt:formatNumber value="${p.price}" type="currency" currencySymbol="$"/>
</td>
<td>${p.stock}</td>
<td>
<a href="${pageContext.request.contextPath}/products/${p.id}">View</a>
</td>
</tr>
</c:forEach>
</tbody>
</table>
</c:otherwise>
</c:choose>
<%@ include file="/WEB-INF/views/layout/footer.jspf" %>
</body>
</html>
Always use fn:escapeXml() on user-supplied strings rendered into HTML. A product name containing <script> would otherwise execute in the browser — a classic stored XSS vulnerability. Add the fn taglib: <%@ taglib prefix="fn" uri="jakarta.tags.functions" %>.
Controller 2 — ProductDetailServlet
This controller extracts a path parameter from the URL (e.g. /products/2), looks up the product, and handles the 404 case explicitly.
@WebServlet("/products/*")
public class ProductDetailServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
// pathInfo is "/{id}" for URL /products/2
String pathInfo = req.getPathInfo();
if (pathInfo == null || pathInfo.equals("/")) {
resp.sendRedirect(req.getContextPath() + "/products");
return;
}
try {
int id = Integer.parseInt(pathInfo.substring(1));
dao.findById(id).ifPresentOrElse(
product -> {
req.setAttribute("product", product);
try {
req.getRequestDispatcher("/WEB-INF/views/productDetail.jsp")
.forward(req, resp);
} catch (Exception e) {
throw new RuntimeException(e);
}
},
() -> {
try { resp.sendError(HttpServletResponse.SC_NOT_FOUND, "Product not found"); }
catch (IOException e) { throw new RuntimeException(e); }
}
);
} catch (NumberFormatException e) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid product ID");
}
}
}
Controller 3 — ProductFormServlet (GET + POST)
The form controller handles both GET (render the empty form) and POST (validate, save, redirect). The Post/Redirect/Get pattern prevents duplicate submissions on browser refresh.
@WebServlet("/products/new")
public class ProductFormServlet extends HttpServlet {
private final ProductDao dao = new ProductDao();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/productForm.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String name = req.getParameter("name") == null ? "" : req.getParameter("name").trim();
String desc = req.getParameter("desc") == null ? "" : req.getParameter("desc").trim();
String priceStr = req.getParameter("price");
String stockStr = req.getParameter("stock");
// --- simple server-side validation ---
List<String> errors = new ArrayList<>();
if (name.isEmpty()) errors.add("Name is required.");
double price = 0;
int stock = 0;
try { price = Double.parseDouble(priceStr); if (price < 0) errors.add("Price must be non-negative."); }
catch (NumberFormatException e) { errors.add("Price must be a number."); }
try { stock = Integer.parseInt(stockStr); if (stock < 0) errors.add("Stock must be non-negative."); }
catch (NumberFormatException e) { errors.add("Stock must be a whole number."); }
if (!errors.isEmpty()) {
req.setAttribute("errors", errors);
req.setAttribute("form", Map.of("name", name, "desc", desc,
"price", priceStr, "stock", stockStr));
req.getRequestDispatcher("/WEB-INF/views/productForm.jsp").forward(req, resp);
return;
}
dao.save(name, desc, price, stock);
// PRG: redirect after successful write
resp.sendRedirect(req.getContextPath() + "/products");
}
}
The Form View: productForm.jsp
The form JSP re-populates fields and shows validation errors when the controller forwards back to it after a failed POST.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
...
<c:if test="${not empty errors}">
<ul class="errors">
<c:forEach var="e" items="${errors}">
<li>${fn:escapeXml(e)}</li>
</c:forEach>
</ul>
</c:if>
<form method="post" action="${pageContext.request.contextPath}/products/new">
<label>Name
<input type="text" name="name"
value="${fn:escapeXml(form.name)}" required>
</label>
<label>Description
<textarea name="desc">${fn:escapeXml(form.desc)}</textarea>
</label>
<label>Price
<input type="number" name="price" step="0.01"
value="${fn:escapeXml(form.price)}">
</label>
<label>Stock
<input type="number" name="stock"
value="${fn:escapeXml(form.stock)}">
</label>
<button type="submit">Save Product</button>
</form>
Key Patterns This Project Demonstrates
- Single-responsibility controllers: each Servlet handles one resource and at most two HTTP methods (
GET / POST). Business logic belongs in the DAO, not the Servlet.
- Views behind WEB-INF: JSPs are never reachable directly — always through a controller forward.
- EL + JSTL instead of scriptlets: the JSPs contain zero Java code, making them maintainable by anyone who can read HTML.
- Post/Redirect/Get: successful form submissions redirect, preventing duplicate saves on reload.
- XSS prevention: every user-supplied string rendered into HTML goes through
fn:escapeXml().
- Structured error handling: validation errors are collected into a list, stored as a request attribute, and displayed by the view — not scattered across the controller with inline HTML.
The shared DAO instance is fine for this demo but not thread-safe in general. A real application would inject a DAO per request (CDI, Spring, etc.) or ensure the DAO itself is stateless and delegates to a thread-safe resource like a connection pool. Servlet instances are shared across threads — never store mutable, per-request state in instance fields.
Running the Project
Deploy the WAR to Tomcat 10+ (which supports Jakarta EE 10 namespace). Navigate to http://localhost:8080/catalog/products. You should see the seeded product list, be able to click through to a detail page, and submit the add-product form — with validation feedback if you omit required fields.
Where to Go Next
This project intentionally uses raw Servlets and JSPs so that every moving part is visible. In professional projects the same MVC pattern is implemented by frameworks like Spring MVC (which adds a DispatcherServlet, annotation-driven controllers, and Thymeleaf or FreeMarker templates) or Jakarta Faces (JSF). Understanding the fundamentals built in this tutorial makes those frameworks transparent rather than magical — you know what they are automating on your behalf.