The MVC Pattern with Servlets & JSP
You now know how to write a Servlet that handles an HTTP request and a JSP that renders HTML. The natural next question is: who owns what responsibility? That question is precisely what the Model-View-Controller (MVC) pattern answers, and it is the architectural backbone of every JSP-based web application worth maintaining.
What MVC Means in a Servlet/JSP Context
The three roles map cleanly to the technologies you already have:
- Model — plain Java objects (often called beans) that hold data and encapsulate business logic. They know nothing about HTTP or HTML.
- View — the JSP file. Its only job is to render the data it is handed. It contains no SQL, no
HttpServletRequest parsing, no business rules.
- Controller — the Servlet. It receives the request, validates input, invokes the model layer (service / DAO), places results in the right scope, then forwards to the appropriate JSP.
The cardinal rule: A JSP should never create a database connection. A Servlet should never output raw HTML. When these rules are broken you end up with code that is impossible to test and painful to change.
A Realistic Example: A Product Listing Page
Let us build a complete, working slice: a user visits /products, the controller loads the product list, and the JSP renders it. We will write the Model bean, a simple DAO, the Controller servlet, and the View JSP.
Step 1 — The Model Bean
package com.example.model;
public class Product {
private int id;
private String name;
private double price;
private int stock;
public Product(int id, String name, double price, int stock) {
this.id = id;
this.name = name;
this.price = price;
this.stock = stock;
}
// Getters only — the bean is read-only after construction
public int getId() { return id; }
public String getName() { return name; }
public double getPrice() { return price; }
public int getStock() { return stock; }
}
The bean is a pure Java object: no Jakarta imports, no annotations beyond what a framework might add. This makes it trivially unit-testable.
Step 2 — The DAO (Data Access Layer)
package com.example.dao;
import com.example.model.Product;
import javax.sql.DataSource;
import java.sql.*;
import java.util.ArrayList;
import java.util.List;
public class ProductDao {
private final DataSource ds;
public ProductDao(DataSource ds) {
this.ds = ds;
}
public List<Product> findAll() throws SQLException {
String sql = "SELECT id, name, price, stock FROM products ORDER BY name";
List<Product> list = new ArrayList<>();
try (Connection conn = ds.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
list.add(new Product(
rs.getInt("id"),
rs.getString("name"),
rs.getDouble("price"),
rs.getInt("stock")
));
}
}
return list;
}
}
Step 3 — The Controller Servlet
package com.example.controller;
import com.example.dao.ProductDao;
import com.example.model.Product;
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;
import java.util.List;
@WebServlet("/products")
public class ProductController extends HttpServlet {
private ProductDao productDao;
@Override
public void init() throws ServletException {
// DataSource is retrieved from JNDI or a factory — never created inline
javax.sql.DataSource ds = (javax.sql.DataSource)
getServletContext().getAttribute("dataSource");
productDao = new ProductDao(ds);
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
try {
List<Product> products = productDao.findAll();
// Place data into request scope so the JSP can read it
req.setAttribute("products", products);
req.setAttribute("count", products.size());
// Forward to the view — the Servlet's job is done
req.getRequestDispatcher("/WEB-INF/views/products.jsp")
.forward(req, resp);
} catch (SQLException e) {
throw new ServletException("Could not load products", e);
}
}
}
Always store JSP views under /WEB-INF/. Files inside WEB-INF are invisible to the browser — they can only be reached via a forward or include from server code. This prevents a user from bypassing the controller and hitting the JSP directly with no model data.
Step 4 — The View JSP
<%@ page contentType="text/html;charset=UTF-8" %>
<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %>
<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Products (${count})</title>
</head>
<body>
<h1>Product Catalogue — ${count} item(s)</h1>
<c:choose>
<c:when test="${empty products}">
<p>No products found.</p>
</c:when>
<c:otherwise>
<table border="1" cellpadding="6">
<tr>
<th>ID</th><th>Name</th><th>Price</th><th>Stock</th>
</tr>
<c:forEach var="p" items="${products}">
<tr>
<td>${p.id}</td>
<td><c:out value="${p.name}"/></td>
<td><fmt:formatNumber value="${p.price}" type="currency"/></td>
<td>${p.stock}</td>
</tr>
</c:forEach>
</table>
</c:otherwise>
</c:choose>
</body>
</html>
Notice what the JSP does not contain: no import statements, no SQL, no business logic, no scriptlet tags (<% %>). It reads from ${products} via EL and iterates with JSTL — clean, testable markup.
The Forward vs Redirect Decision
After processing a GET request the controller uses forward. For a form submission (POST) that mutates state, the standard pattern is Post/Redirect/Get (PRG): the controller processes the form, then issues a sendRedirect to a GET URL. This prevents the browser's "resubmit form?" dialog on refresh.
// In doPost — after saving the new product
resp.sendRedirect(req.getContextPath() + "/products");
// Browser follows the redirect with a GET, controller runs again, JSP renders fresh data
Never forward after a POST that modifies state. If the user refreshes the forwarded page, the browser replays the POST, causing duplicate writes. Always redirect after a successful write operation.
Scope Strategy: Request vs Session vs Application
Where you place model data determines its lifetime:
- Request scope (
req.setAttribute) — data lives for a single request/response cycle. Use this for query results, form validation errors, and anything page-specific.
- Session scope (
req.getSession().setAttribute) — data lives until the session expires or is invalidated. Use this for the logged-in user, shopping cart, or user preferences.
- Application scope (
getServletContext().setAttribute) — data lives for the lifetime of the application. Use this sparingly: look-up tables, shared configuration, cached reference data. Thread safety is your responsibility.
Wiring the DataSource: Using a Context Listener
The example above fetches the DataSource from ServletContext. The standard place to initialise shared resources is a ServletContextListener:
package com.example.listener;
import com.zaxxer.hikari.HikariConfig;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.servlet.ServletContextEvent;
import jakarta.servlet.ServletContextListener;
import jakarta.servlet.annotation.WebListener;
@WebListener
public class AppContextListener implements ServletContextListener {
private HikariDataSource pool;
@Override
public void contextInitialized(ServletContextEvent sce) {
HikariConfig cfg = new HikariConfig();
cfg.setJdbcUrl(System.getenv("DB_URL"));
cfg.setUsername(System.getenv("DB_USER"));
cfg.setPassword(System.getenv("DB_PASS"));
cfg.setMaximumPoolSize(10);
pool = new HikariDataSource(cfg);
// Make the pool available to all servlets
sce.getServletContext().setAttribute("dataSource", pool);
}
@Override
public void contextDestroyed(ServletContextEvent sce) {
if (pool != null) pool.close();
}
}
The listener runs once when the application starts, wires up the connection pool, and closes it cleanly when the server shuts down. Every Servlet then retrieves the shared pool from ServletContext — no static singletons, no manual lifecycle management per servlet.
Summary
In a Servlet + JSP MVC application the responsibility split is strict: the Servlet (Controller) owns request parsing, input validation, model invocation, and scope assignment; the JSP (View) owns rendering only; the bean (Model) owns data and business logic. Enforcing this separation yields code that is easy to test, easy to replace, and easy for a new team member to navigate. In the next lesson you will go deeper into how data flows from the controller into the view — handling collections, nested objects, and type-safe attribute retrieval.