We have a text file containing an array of 30 product objects. We want one table row per product and a More details link in each row. A second template displays the selected product's complete details.
Open the running product table · Online tutorial
Run: extract the ZIP, open a terminal beside pom.xml and run mvn spring-boot:run. Requires JDK 21+ and Maven 3.9+. Choose a free port in src/main/resources/application.properties; this project defaults to 8085. Open http://localhost:8085/products after startup. Stop with Ctrl+C.
GET /products
-> listProducts(model)
-> model.addAttribute("products", products)
-> return "products"
-> Thymeleaf repeats a table row for each product
-> browser receives HTML containing 30 product rows
Click More details for product 1
-> GET /products/1
-> productDetails(1, model)
-> find the matching Product object
-> model.addAttribute("product", product)
-> return "product-details"
-> Thymeleaf renders product-details.html with all 8 fieldssrc/main/resources/data/products.txt is an ordinary UTF-8 text file. Its contents use JSON format: square brackets contain an array and each pair of braces contains one product. There are exactly 30 objects. The file stays on the server; the browser receives rendered HTML.
{
"id": 1,
"name": "Wireless Mouse",
"category": "Electronics",
"price": 699,
"brand": "EduTech",
"stock": 12,
"description": "Wireless Mouse for everyday learning, study and work. Product 1 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
}Java needs Product objects to work with this JSON text. Jackson reads the file and converts its array into Product[]. The Product class describes each object's fields. These data objects are not automatically Spring beans.
@GetMapping({"/", "/products"})
public String listProducts(Model model) {
model.addAttribute("products", products);
return "products";
}Read this slowly: Spring supplies a Model for this request. Our code places the Java array in it under the name products. Returning "products" selects templates/products.html. The attribute name and template name happen to match here, but they serve different jobs.
| Code | Meaning |
|---|---|
| Model model | Spring provides a place to carry named data to this request's HTML view. |
| "products" in addAttribute | The name the template uses to find the array. |
| products after the comma | The actual Product[] Java array containing the 30 objects. |
| return "products" | Select the products.html template, rather than returning text directly. |
A model attribute means a named value placed in Model. Here we use model.addAttribute(). Model does not read the file or calculate values; our Java code prepares the data.
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">Wireless Mouse</td>
<td th:text="${product.category}">Electronics</td>
<td th:text="${product.price}">699</td>
<td><a th:href="@{/products/{id}(id=${product.id})}">More details</a></td>
</tr>th:each reads products from Model. For each object it creates a row and calls that current object product. th:text writes a property as escaped text. For example, product.name reads getName(). One template row becomes 30 rows in the generated response. The placeholder Wireless Mouse is replaced by the actual name in each iteration.
th:href builds /products/1 for product 1, /products/2 for product 2, and so on. Clicking the link sends a new request. The ID travels in the URL; the whole product object is not sent by the link.
@GetMapping("/products/{id}")
public String productDetails(@PathVariable("id") int id, Model model) {
for (Product product : products) {
if (product.getId() == id) {
model.addAttribute("product", product);
return "product-details";
}
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}@PathVariable reads the selected ID. The loop finds the matching object in our server-side array. This request gets its own Model: we explicitly add the chosen object under product. We do not rely on the previous table request's Model. Returning "product-details" selects a different file, templates/product-details.html.
<h2 th:text="${product.name}">Product name</h2>
<p th:text="${product.description}">Description</p>
<p th:text="${product.brand}">Brand</p>The complete details template shows all eight fields: ID, name, category, price, brand, stock, description and warranty months. The same template works for every product because the controller supplies a different product object.
The controller constructor reads products.txt once during application startup. ClassPathResource can read the bundled resource even from the packaged JAR. try-with-resources closes the input stream. JsonMapper converts JSON text into a Product array. This Spring Boot 4 project uses Jackson 3's tools.jackson package. The read-only array is reused; it does not hold per-user request state. Restart after editing the text file; rebuild first if running a packaged JAR.
package com.edupoly.products;
import java.io.IOException;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.databind.json.JsonMapper;
@Controller
public class ProductController {
private final Product[] products;
// Read the text file once when Spring creates this controller.
public ProductController() throws IOException {
var resource = new ClassPathResource("data/products.txt");
try (var input = resource.getInputStream()) {
JsonMapper mapper = JsonMapper.builder().build();
products = mapper.readValue(input, Product[].class);
}
}
@GetMapping({"/", "/products"})
public String listProducts(Model model) {
model.addAttribute("products", products);
return "products";
}
@GetMapping("/products/{id}")
public String productDetails(@PathVariable("id") int id, Model model) {
for (Product product : products) {
if (product.getId() == id) {
model.addAttribute("product", product);
return "product-details";
}
}
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Product not found");
}
}
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Products</title>
<link rel="stylesheet" th:href="@{/style.css}">
</head>
<body>
<main>
<h1>Products</h1>
<p>Basic details for 30 products. Select More details to see a product's complete information.</p>
<div class="table-scroll">
<table>
<thead>
<tr><th>ID</th><th>Name</th><th>Category</th><th>Price (INR)</th><th>Action</th></tr>
</thead>
<tbody>
<tr th:each="product : ${products}">
<td th:text="${product.id}">1</td>
<td th:text="${product.name}">Wireless Mouse</td>
<td th:text="${product.category}">Electronics</td>
<td th:text="${product.price}">699</td>
<td><a th:href="@{/products/{id}(id=${product.id})}">More details</a></td>
</tr>
</tbody>
</table>
</div>
</main>
</body>
</html>
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title th:text="${product.name}">Product details</title>
<link rel="stylesheet" th:href="@{/style.css}">
</head>
<body>
<main class="detail">
<h1>Product details</h1>
<h2 th:text="${product.name}">Wireless Mouse</h2>
<dl>
<dt>ID</dt><dd th:text="${product.id}">1</dd>
<dt>Name</dt><dd th:text="${product.name}">Wireless Mouse</dd>
<dt>Category</dt><dd th:text="${product.category}">Electronics</dd>
<dt>Price (INR)</dt><dd th:text="${product.price}">699</dd>
<dt>Brand</dt><dd th:text="${product.brand}">EduTech</dd>
<dt>Stock</dt><dd th:text="${product.stock}">12</dd>
<dt>Description</dt><dd th:text="${product.description}">Product description</dd>
<dt>Warranty (months)</dt><dd th:text="${product.warrantyMonths}">12</dd>
</dl>
<a th:href="@{/products}">Back to products</a>
</main>
</body>
</html>
The ZIP also contains Product.java, all 30 objects, the startup class, pom.xml, port configuration, CSS and README.html. No database is needed.
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.edupoly</groupId>
<artifactId>spring-products-thymeleaf</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
package com.edupoly.products;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class ProductApplication {
public static void main(String[] args) {
SpringApplication.run(ProductApplication.class, args);
}
}
package com.edupoly.products;
import java.math.BigDecimal;
public class Product {
private int id;
private String name;
private String category;
private BigDecimal price;
private String brand;
private int stock;
private String description;
private int warrantyMonths;
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getCategory() {
return category;
}
public void setCategory(String category) {
this.category = category;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
public String getBrand() {
return brand;
}
public void setBrand(String brand) {
this.brand = brand;
}
public int getStock() {
return stock;
}
public void setStock(int stock) {
this.stock = stock;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public int getWarrantyMonths() {
return warrantyMonths;
}
public void setWarrantyMonths(int warrantyMonths) {
this.warrantyMonths = warrantyMonths;
}
}
# Choose another available port if 8085 is busy.
server.port=8085
[
{
"id": 1,
"name": "Wireless Mouse",
"category": "Electronics",
"price": 699,
"brand": "EduTech",
"stock": 12,
"description": "Wireless Mouse for everyday learning, study and work. Product 1 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 2,
"name": "Mechanical Keyboard",
"category": "Electronics",
"price": 3499,
"brand": "EduTech",
"stock": 19,
"description": "Mechanical Keyboard for everyday learning, study and work. Product 2 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 3,
"name": "USB-C Hub",
"category": "Electronics",
"price": 1599,
"brand": "EduTech",
"stock": 26,
"description": "USB-C Hub for everyday learning, study and work. Product 3 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 4,
"name": "Laptop Stand",
"category": "Electronics",
"price": 1299,
"brand": "EduTech",
"stock": 33,
"description": "Laptop Stand for everyday learning, study and work. Product 4 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 5,
"name": "Webcam",
"category": "Electronics",
"price": 2199,
"brand": "EduTech",
"stock": 40,
"description": "Webcam for everyday learning, study and work. Product 5 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 6,
"name": "Bluetooth Speaker",
"category": "Electronics",
"price": 2499,
"brand": "EduTech",
"stock": 47,
"description": "Bluetooth Speaker for everyday learning, study and work. Product 6 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 7,
"name": "Noise-Cancelling Headphones",
"category": "Electronics",
"price": 4999,
"brand": "EduTech",
"stock": 54,
"description": "Noise-Cancelling Headphones for everyday learning, study and work. Product 7 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 8,
"name": "Portable SSD",
"category": "Electronics",
"price": 6499,
"brand": "EduTech",
"stock": 61,
"description": "Portable SSD for everyday learning, study and work. Product 8 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 9,
"name": "USB Flash Drive",
"category": "Electronics",
"price": 599,
"brand": "EduTech",
"stock": 68,
"description": "USB Flash Drive for everyday learning, study and work. Product 9 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 10,
"name": "Monitor",
"category": "Electronics",
"price": 12999,
"brand": "EduTech",
"stock": 15,
"description": "Monitor for everyday learning, study and work. Product 10 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 11,
"name": "Desk Lamp",
"category": "Electronics",
"price": 899,
"brand": "EduTech",
"stock": 22,
"description": "Desk Lamp for everyday learning, study and work. Product 11 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 12,
"name": "Power Bank",
"category": "Electronics",
"price": 1799,
"brand": "EduTech",
"stock": 29,
"description": "Power Bank for everyday learning, study and work. Product 12 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 13,
"name": "Smartphone Stand",
"category": "Electronics",
"price": 399,
"brand": "EduTech",
"stock": 36,
"description": "Smartphone Stand for everyday learning, study and work. Product 13 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 14,
"name": "HDMI Cable",
"category": "Electronics",
"price": 499,
"brand": "EduTech",
"stock": 43,
"description": "HDMI Cable for everyday learning, study and work. Product 14 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 15,
"name": "Ethernet Adapter",
"category": "Electronics",
"price": 999,
"brand": "EduTech",
"stock": 50,
"description": "Ethernet Adapter for everyday learning, study and work. Product 15 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 16,
"name": "Graphics Tablet",
"category": "Electronics",
"price": 3999,
"brand": "EduTech",
"stock": 57,
"description": "Graphics Tablet for everyday learning, study and work. Product 16 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 17,
"name": "Microphone",
"category": "Electronics",
"price": 2999,
"brand": "EduTech",
"stock": 64,
"description": "Microphone for everyday learning, study and work. Product 17 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 18,
"name": "Laptop Backpack",
"category": "Electronics",
"price": 1499,
"brand": "EduTech",
"stock": 71,
"description": "Laptop Backpack for everyday learning, study and work. Product 18 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 19,
"name": "Screen Cleaner",
"category": "Electronics",
"price": 249,
"brand": "EduTech",
"stock": 18,
"description": "Screen Cleaner for everyday learning, study and work. Product 19 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 20,
"name": "Mouse Pad",
"category": "Electronics",
"price": 299,
"brand": "EduTech",
"stock": 25,
"description": "Mouse Pad for everyday learning, study and work. Product 20 includes a simple user guide and protective packaging.",
"warrantyMonths": 12
},
{
"id": 21,
"name": "Notebook",
"category": "Study Essentials",
"price": 149,
"brand": "StudyWorks",
"stock": 32,
"description": "Notebook for everyday learning, study and work. Product 21 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 22,
"name": "Ballpoint Pen Set",
"category": "Study Essentials",
"price": 199,
"brand": "StudyWorks",
"stock": 39,
"description": "Ballpoint Pen Set for everyday learning, study and work. Product 22 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 23,
"name": "Water Bottle",
"category": "Study Essentials",
"price": 599,
"brand": "StudyWorks",
"stock": 46,
"description": "Water Bottle for everyday learning, study and work. Product 23 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 24,
"name": "Desk Organizer",
"category": "Study Essentials",
"price": 449,
"brand": "StudyWorks",
"stock": 53,
"description": "Desk Organizer for everyday learning, study and work. Product 24 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 25,
"name": "Reading Light",
"category": "Study Essentials",
"price": 699,
"brand": "StudyWorks",
"stock": 60,
"description": "Reading Light for everyday learning, study and work. Product 25 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 26,
"name": "Calculator",
"category": "Study Essentials",
"price": 499,
"brand": "StudyWorks",
"stock": 67,
"description": "Calculator for everyday learning, study and work. Product 26 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 27,
"name": "Whiteboard",
"category": "Study Essentials",
"price": 1299,
"brand": "StudyWorks",
"stock": 14,
"description": "Whiteboard for everyday learning, study and work. Product 27 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 28,
"name": "Marker Set",
"category": "Study Essentials",
"price": 299,
"brand": "StudyWorks",
"stock": 21,
"description": "Marker Set for everyday learning, study and work. Product 28 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 29,
"name": "Book Stand",
"category": "Study Essentials",
"price": 799,
"brand": "StudyWorks",
"stock": 28,
"description": "Book Stand for everyday learning, study and work. Product 29 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
},
{
"id": 30,
"name": "Travel Mug",
"category": "Study Essentials",
"price": 649,
"brand": "StudyWorks",
"stock": 35,
"description": "Travel Mug for everyday learning, study and work. Product 30 includes a simple user guide and protective packaging.",
"warrantyMonths": 6
}
]
body{margin:0;padding:28px 16px;background:#f5f7fb;color:#17253d;font:16px/1.6 system-ui,sans-serif}main{max-width:1100px;margin:20px auto;padding:28px;background:#fff;border:1px solid #dbe2ec;border-radius:12px}h1{margin-top:0}a{color:#2457a6;text-underline-offset:3px}.table-scroll{overflow-x:auto}table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:12px;border-bottom:1px solid #dbe2ec}th{background:#edf3fc}tbody tr:hover{background:#f8faff}.detail{max-width:760px}dl{display:grid;grid-template-columns:160px minmax(0,1fr);gap:12px 20px;margin:24px 0}dt{font-weight:650}dd{margin:0;overflow-wrap:anywhere}@media(max-width:500px){main{padding:16px}dl{grid-template-columns:1fr;gap:4px}dd{margin-bottom:12px}}
References: Thymeleaf reference and Spring MVC web content.
Focus: Give Java data to HTML without building HTML strings in the controller.
| Term | Our concrete example | Its place in Spring |
|---|---|---|
| Model | listProducts(Model model) receives the view-data holder for the table request. | Spring MVC supplies a Model to carry named data to the selected view. |
| Model attribute | addAttribute("products", products) puts our Product array under the name products. | A model attribute is a named value available to the view for this request. |
| View name / template | return "products" selects products.html; return "product-details" selects product-details.html. | Spring MVC uses the view name to select the HTML template to render. |
| Thymeleaf | th:each repeats rows, th:text displays values, and th:href builds each details link. | Thymeleaf renders HTML on the server using the values supplied through Model. |
Product.java describes ordinary data objects. The 30 Product objects are not registered as Spring beans in this example. The product-details request receives a new Model; the controller adds its selected product again.
You should now be able to say: The controller supplies products through Model. Thymeleaf turns those products into HTML rows.
One small check: When /products/1 is requested, which product is added to Model and which template is selected?
These are the next learning steps, not additional projects already implemented in this bundle. Each proposed change gives the new terminology a concrete example.
| Next stage | One useful change | Words to learn when we make that change |
|---|---|---|
| 8. A separate product-file reader | Move the existing file-reading code into ProductFileReader and have Spring supply that object to ProductController through its constructor. Print object creation and request messages to observe reuse. | @Component, dependency, constructor injection, DI, IoC and default singleton scope. |
| 9. Product operations | Add ProductService for rules such as choosing products currently in stock. | @Service, business rule and separation of responsibilities. |
| 10. Product data access | Place file-based reading and lookup behind a product repository. | Repository pattern, @Repository and data access. |
| 11. MongoDB storage | Replace the text-file storage with MongoDB while keeping the page flow familiar. | @Document, ID, MongoRepository, persistence and CRUD. |
| 12. Explicit bean configuration | Supply a helper through a configured factory method and inspect how it is registered. | @Configuration, @Bean, factory method, bean definition and registry. |
Until Stage 8 is implemented, our examples do not demonstrate Spring injecting a separate ProductFileReader into ProductController. The current product controller still reads the file itself. Keep the original getInputStream() and try-with-resources code for that step.