I wanted a minimal project to really understand the classic Java web flow end-to-end: JSP for views, Servlets for request handling, JDBC for persistence, and a lightweight local runtime.
So I built this project: JspServletBasics.
What I wanted from this project
My goals were simple:
- Keep architecture easy to reason about
- Avoid framework magic while learning fundamentals
- Run locally with as little setup as possible
- Implement full CRUD with validation
That led me to this stack:
- Java 17
- Jakarta Servlet + JSP + JSTL
- Embedded Tomcat 10
- H2 database (file-backed)
- Maven
The project structure I used
src/main/java/com/example/jspservletbasics/
Main.java
dao/ProductDao.java
db/Database.java
model/Product.java
web/HomeServlet.java
web/ProductServlet.java
src/main/resources/
schema.sql
src/main/webapp/
assets/styles.css
WEB-INF/web.xml
WEB-INF/views/products.jsp
WEB-INF/views/product-form.jsp
I separated responsibilities so each layer stays focused:
web/*Servlethandles routing, request parsing, and response flowdao/ProductDaotalks to DB with JDBCdb/Databaseowns schema init + seed datamodel/Productis the domain object- JSP files handle rendering only
How I bootstrapped the app without external Tomcat
Instead of deploying a WAR into an installed Tomcat, I created a Main class that starts embedded Tomcat.
In Main.java, I:
- Read port from
app.portsystem property orPORTenv var - Point Tomcat to
src/main/webapp - Attach compiled classes from
target/classesto/WEB-INF/classes - Start server and block with
await()
This gave me a runnable local app with:
mvn compile exec:java
Default URL: http://localhost:8080
Custom port:
mvn compile exec:java -Dapp.port=9090
Servlet mapping and route design
I kept mappings in WEB-INF/web.xml:
HomeServlet->/ProductServlet->/products/*
HomeServlet just redirects to /products.
Inside ProductServlet, I used path-based switching:
GET /products-> listGET /products/new-> create formGET /products/edit?id=...-> edit formPOST /products/create-> insertPOST /products/update-> updatePOST /products/delete-> delete
That one servlet became the CRUD coordinator while keeping URL structure clear.
Form handling and validation choices
I added a small ProductFormResult record to return:
- parsed product
- validation status
- error message
Validation rules I enforced:
- product name is required
- price is required
- price must be a valid number
- price cannot be negative
- id is required for update
When validation fails, I forward back to product-form.jsp with:
errorMessage- previously entered values (
product) - correct form action and title
When validation passes, I write to DB and redirect back to list with a message query parameter.
JDBC and database setup
I used H2 with a file-backed JDBC URL under the project data/ directory.
Database.initialize() does three things:
- Creates the data directory
- Executes
schema.sql - Seeds sample rows only if table is empty
ProductDao uses plain PreparedStatement for:
findAllfindByIdcreateupdatedeleteById
This gave me SQL control and safe parameter binding without adding ORM complexity.
JSP layer and why WEB-INF views helped
I placed views inside WEB-INF/views so users cannot access them directly by URL.
Only servlets can forward to:
products.jspfor listing/table/actionsproduct-form.jspfor create/edit
In JSP, I used JSTL tags (c:if, c:choose, c:forEach) to keep templates readable and avoid scriptlets.
Problems I avoided by design
A few small decisions made the project smoother:
- Redirect-after-post to avoid duplicate submissions on refresh
- Centralized DB init in servlet
init() - Explicit path normalization (
null/blank ->/) - Reusable
parseIdand trim helpers
Final takeaway
This project gave me exactly what I wanted: a practical understanding of how classic Java web apps work under the hood.
The most useful learning was seeing the full lifecycle clearly:
Request -> Servlet -> DAO -> DB -> Servlet -> JSP -> Response
If you are learning Java web fundamentals, building one CRUD app this way is worth it before jumping to bigger frameworks.