Project: A Database-Backed CRUD Web App
This capstone lesson ties together everything covered in the tutorial: a pooled DataSource, a DAO interface with a PreparedStatement-based implementation, a thin service layer that owns transactions, and JSP views that drive the full Create / Read / Update / Delete cycle. You will build a minimal Product Catalogue application — realistic enough to show every moving part, small enough to fit in a single lesson.
Project Layout
The Maven WAR project follows a clean layered structure. Every layer has a single responsibility and depends only on the layer below it.
product-catalogue/
├── pom.xml
└── src/main/
├── java/com/example/catalogue/
│ ├── db/ DataSourceProvider.java
│ ├── model/ Product.java
│ ├── dao/ ProductDao.java (interface)
│ │ ProductDaoImpl.java
│ ├── service/ ProductService.java
│ └── servlet/ ProductServlet.java
└── webapp/
├── WEB-INF/
│ ├── web.xml
│ └── views/
│ ├── list.jsp
│ ├── form.jsp
│ └── error.jsp
└── index.jsp (redirects to /products)
Step 1 — The Model
Product is a plain Java object. Keep it free of any persistence logic.
package com.example.catalogue.model;
public class Product {
private int id;
private String name;
private String sku;
private double price;
private int stock;
public Product() {}
public Product(int id, String name, String sku, double price, int stock) {
this.id = id; this.name = name; this.sku = sku;
this.price = price; this.stock = stock;
}
// standard getters and setters omitted for brevity
public int getId() { return id; }
public void setId(int id) { this.id = id; }
public String getName() { return name; }
public void setName(String n) { this.name = n; }
public String getSku() { return sku; }
public void setSku(String s) { this.sku = s; }
public double getPrice() { return price; }
public void setPrice(double p) { this.price = p; }
public int getStock() { return stock; }
public void setStock(int s) { this.stock = s; }
}
Step 2 — The DAO Interface and Implementation
Define the contract first; the implementation is the only class that touches SQL.
package com.example.catalogue.dao;
import com.example.catalogue.model.Product;
import java.sql.SQLException;
import java.util.List;
public interface ProductDao {
List<Product> findAll() throws SQLException;
Product findById(int id) throws SQLException;
void insert(Product p) throws SQLException;
void update(Product p) throws SQLException;
void delete(int id) throws SQLException;
}
The implementation injects a DataSource through its constructor — this makes it trivially testable.
package com.example.catalogue.dao;
import com.example.catalogue.model.Product;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class ProductDaoImpl implements ProductDao {
private final DataSource ds;
public ProductDaoImpl(DataSource ds) { this.ds = ds; }
// ---- helpers ----------------------------------------
private Product map(ResultSet rs) throws SQLException {
return new Product(
rs.getInt("id"),
rs.getString("name"),
rs.getString("sku"),
rs.getDouble("price"),
rs.getInt("stock")
);
}
// ---- CRUD -------------------------------------------
@Override
public List<Product> findAll() throws SQLException {
String sql = "SELECT id, name, sku, price, stock FROM products ORDER BY name";
List<Product> list = new ArrayList<>();
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) list.add(map(rs));
}
return list;
}
@Override
public Product findById(int id) throws SQLException {
String sql = "SELECT id, name, sku, price, stock FROM products WHERE id = ?";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? map(rs) : null;
}
}
}
@Override
public void insert(Product p) throws SQLException {
String sql = "INSERT INTO products (name, sku, price, stock) VALUES (?, ?, ?, ?)";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) {
ps.setString(1, p.getName());
ps.setString(2, p.getSku());
ps.setDouble(3, p.getPrice());
ps.setInt(4, p.getStock());
ps.executeUpdate();
try (ResultSet keys = ps.getGeneratedKeys()) {
if (keys.next()) p.setId(keys.getInt(1));
}
}
}
@Override
public void update(Product p) throws SQLException {
String sql = "UPDATE products SET name=?, sku=?, price=?, stock=? WHERE id=?";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setString(1, p.getName());
ps.setString(2, p.getSku());
ps.setDouble(3, p.getPrice());
ps.setInt(4, p.getStock());
ps.setInt(5, p.getId());
ps.executeUpdate();
}
}
@Override
public void delete(int id) throws SQLException {
String sql = "DELETE FROM products WHERE id = ?";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setInt(1, id);
ps.executeUpdate();
}
}
}
Why pass DataSource through the constructor instead of looking it up? Constructor injection keeps the DAO decoupled from JNDI and from any specific pool library. In a unit test you can pass an in-memory H2 DataSource; in production the servlet container supplies HikariCP. Same DAO, zero changes.
Step 3 — The Service Layer
The service owns validation and transaction boundaries. For simple single-table CRUD the service is thin, but it is the right place to add business rules later (e.g., "do not delete a product that has open orders").
package com.example.catalogue.service;
import com.example.catalogue.dao.ProductDao;
import com.example.catalogue.model.Product;
import java.sql.SQLException;
import java.util.List;
public class ProductService {
private final ProductDao dao;
public ProductService(ProductDao dao) { this.dao = dao; }
public List<Product> listAll() throws SQLException {
return dao.findAll();
}
public Product getById(int id) throws SQLException {
Product p = dao.findById(id);
if (p == null) throw new IllegalArgumentException("Product not found: " + id);
return p;
}
public void create(Product p) throws SQLException {
validate(p);
dao.insert(p);
}
public void edit(Product p) throws SQLException {
validate(p);
dao.update(p);
}
public void remove(int id) throws SQLException {
dao.delete(id);
}
private void validate(Product p) {
if (p.getName() == null || p.getName().isBlank())
throw new IllegalArgumentException("Name is required.");
if (p.getSku() == null || p.getSku().isBlank())
throw new IllegalArgumentException("SKU is required.");
if (p.getPrice() < 0)
throw new IllegalArgumentException("Price cannot be negative.");
}
}
Step 4 — The Servlet (Front Controller)
A single ProductServlet maps to /products and dispatches on an action query parameter. It initialises the DAO and service once at startup using @WebServlet with loadOnStartup = 1.
package com.example.catalogue.servlet;
import com.example.catalogue.dao.ProductDaoImpl;
import com.example.catalogue.db.DataSourceProvider;
import com.example.catalogue.model.Product;
import com.example.catalogue.service.ProductService;
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;
import java.sql.SQLException;
@WebServlet(urlPatterns = "/products", loadOnStartup = 1)
public class ProductServlet extends HttpServlet {
private ProductService service;
@Override
public void init() throws ServletException {
try {
service = new ProductService(
new ProductDaoImpl(DataSourceProvider.get())
);
} catch (Exception e) {
throw new ServletException("Failed to initialise service", e);
}
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String action = req.getParameter("action");
if (action == null) action = "list";
try {
switch (action) {
case "new" -> showForm(req, resp, new Product());
case "edit" -> showEditForm(req, resp);
case "delete" -> handleDelete(req, resp);
default -> showList(req, resp);
}
} catch (SQLException | IllegalArgumentException e) {
req.setAttribute("error", e.getMessage());
req.getRequestDispatcher("/WEB-INF/views/error.jsp").forward(req, resp);
}
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String action = req.getParameter("action");
try {
if ("create".equals(action)) handleCreate(req, resp);
else if ("update".equals(action)) handleUpdate(req, resp);
} catch (SQLException | IllegalArgumentException e) {
req.setAttribute("error", e.getMessage());
req.getRequestDispatcher("/WEB-INF/views/error.jsp").forward(req, resp);
}
}
// ---- private helpers --------------------------------
private void showList(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException, SQLException {
req.setAttribute("products", service.listAll());
req.getRequestDispatcher("/WEB-INF/views/list.jsp").forward(req, resp);
}
private void showForm(HttpServletRequest req, HttpServletResponse resp, Product p)
throws ServletException, IOException {
req.setAttribute("product", p);
req.getRequestDispatcher("/WEB-INF/views/form.jsp").forward(req, resp);
}
private void showEditForm(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException, SQLException {
int id = Integer.parseInt(req.getParameter("id"));
showForm(req, resp, service.getById(id));
}
private void handleDelete(HttpServletRequest req, HttpServletResponse resp)
throws IOException, SQLException {
service.remove(Integer.parseInt(req.getParameter("id")));
resp.sendRedirect(req.getContextPath() + "/products");
}
private void handleCreate(HttpServletRequest req, HttpServletResponse resp)
throws IOException, SQLException {
service.create(buildFromRequest(req, 0));
resp.sendRedirect(req.getContextPath() + "/products");
}
private void handleUpdate(HttpServletRequest req, HttpServletResponse resp)
throws IOException, SQLException {
int id = Integer.parseInt(req.getParameter("id"));
service.edit(buildFromRequest(req, id));
resp.sendRedirect(req.getContextPath() + "/products");
}
private Product buildFromRequest(HttpServletRequest req, int id) {
Product p = new Product();
p.setId(id);
p.setName(req.getParameter("name"));
p.setSku(req.getParameter("sku"));
p.setPrice(Double.parseDouble(req.getParameter("price")));
p.setStock(Integer.parseInt(req.getParameter("stock")));
return p;
}
}
Post-Redirect-Get (PRG) pattern: After every successful POST (create or update), the servlet issues a sendRedirect rather than a forward. This prevents the browser from re-submitting the form when the user refreshes, which would create duplicate records.
Step 5 — The JSP Views
list.jsp — iterates the product list and provides action links.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<html><body>
<h1>Products</h1>
<a href="${pageContext.request.contextPath}/products?action=new">+ New Product</a>
<table>
<tr><th>Name</th><th>SKU</th><th>Price</th><th>Stock</th><th>Actions</th></tr>
<c:forEach var="p" items="${products}">
<tr>
<td><c:out value="${p.name}"/></td>
<td><c:out value="${p.sku}"/></td>
<td><c:out value="${p.price}"/></td>
<td><c:out value="${p.stock}"/></td>
<td>
<a href="products?action=edit&id=${p.id}">Edit</a>
<a href="products?action=delete&id=${p.id}"
onclick="return confirm('Delete this product?')">Delete</a>
</td>
</tr>
</c:forEach>
</table>
</body></html>
form.jsp — shared for both create and edit. When product.id is non-zero the form is in edit mode.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<html><body>
<h1><c:choose>
<c:when test="${product.id == 0}">New Product</c:when>
<c:otherwise>Edit Product</c:otherwise>
</c:choose></h1>
<form method="post" action="${pageContext.request.contextPath}/products">
<input type="hidden" name="action" value="${product.id == 0 ? 'create' : 'update'}"/>
<c:if test="${product.id != 0}">
<input type="hidden" name="id" value="${product.id}"/>
</c:if>
<label>Name: <input name="name" value="<c:out value='${product.name}'/>" required/></label><br/>
<label>SKU: <input name="sku" value="<c:out value='${product.sku}'/>" required/></label><br/>
<label>Price:<input name="price" type="number" step="0.01" value="${product.price}" required/></label><br/>
<label>Stock:<input name="stock" type="number" value="${product.stock}" required/></label><br/>
<button type="submit">Save</button>
<a href="${pageContext.request.contextPath}/products">Cancel</a>
</form>
</body></html>
Always use <c:out> when rendering user-supplied text in JSP. Without it, a product named <script>alert(1)</script> would execute in the browser. <c:out> HTML-escapes the value automatically, blocking reflected XSS.
Step 6 — Wiring Everything Together
The DataSourceProvider initialises HikariCP once and hands the same pool to every DAO. Reading credentials from environment variables keeps secrets out of source control.
package com.example.catalogue.db;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import javax.sql.DataSource;
public class DataSourceProvider {
private static final HikariDataSource DS;
static {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(System.getenv("DB_URL")); // e.g. jdbc:mysql://localhost/catalogue
cfg.setUsername(System.getenv("DB_USER"));
cfg.setPassword(System.getenv("DB_PASS"));
cfg.setMaximumPoolSize(10);
DS = new HikariDataSource(cfg);
}
public static DataSource get() { return DS; }
}
The DDL for the products table is straightforward:
CREATE TABLE products (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
sku VARCHAR(60) NOT NULL UNIQUE,
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
stock INT NOT NULL DEFAULT 0
);
Key Design Decisions
- One servlet, action dispatch: keeps URL mapping simple. For larger apps, move to a framework (Spring MVC) or a JAX-RS resource class.
- DAO per entity:
ProductDao owns all SQL for products. No SQL leaks into the servlet or service.
- Service owns validation: the DAO trusts data; the service enforces rules. This boundary makes unit-testing business logic without a database straightforward.
- PRG after POST: prevents duplicate submissions on refresh.
- Constructor injection throughout: every class receives its dependencies; no static look-ups in business logic. The object graph is assembled in
init().
Summary
You now have a complete, layered CRUD web application: DataSource (pooling) → DAO (SQL, row mapping) → Service (validation, business rules) → Servlet (HTTP dispatch, PRG) → JSP (display, XSS-safe output). Each layer is independently testable and replaceable. This architecture scales cleanly — swap the DAO implementation for one backed by Spring Data JPA tomorrow, and neither the service nor the servlet changes at all.