Stage 6: introduce Model and Thymeleaf with a product catalog

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.

See the two requests separately
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 fields

1. Start with the data file

src/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.

First product — excerpt from the complete 30-product file
{
  "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.

2. Pass the array to the table template

The list method — part of ProductController.java
@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.

CodeMeaning
Model modelSpring provides a place to carry named data to this request's HTML view.
"products" in addAttributeThe name the template uses to find the array.
products after the commaThe 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.

3. Repeat one HTML row for each product

The row inside products.html
<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.

4. Build a link for the current row

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.

5. Select one product and a different template

The details method — part of ProductController.java
@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.

6. Display the selected object's details

Examples inside 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.

7. Understand the file-reading step

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.

8. Verify the result yourself

  1. Open /products and confirm 30 rows and 30 More details links.
  2. Click product 1: the address becomes /products/1 and all its fields appear.
  3. Return to the table and open product 30. The same detail template now shows Travel Mug.
  4. Try /products/999: the controller returns HTTP 404 because there is no matching product.
  5. Edit a product name in products.txt, restart and verify the table and detail page both use the new name.
Open the complete Java and template files
src/main/java/com/edupoly/products/ProductController.java — complete file
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");
    }
}
src/main/resources/templates/products.html — complete file
<!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>
src/main/resources/templates/product-details.html — complete file
<!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.

All remaining files for manual creation

pom.xml
pom.xml — complete file
<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>
src/main/java/com/edupoly/products/ProductApplication.java
src/main/java/com/edupoly/products/ProductApplication.java — complete file
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);
    }
}
src/main/java/com/edupoly/products/Product.java
src/main/java/com/edupoly/products/Product.java — complete file
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;
    }
}
src/main/resources/application.properties
src/main/resources/application.properties — complete file
# Choose another available port if 8085 is busy.
server.port=8085
src/main/resources/data/products.txt
src/main/resources/data/products.txt — complete file
[
  {
    "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
  }
]
src/main/resources/static/style.css
src/main/resources/static/style.css — complete file
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.

After Stage 6: the Spring words to recognize

Focus: Give Java data to HTML without building HTML strings in the controller.

TermOur concrete exampleIts place in Spring
ModellistProducts(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 attributeaddAttribute("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 / templatereturn "products" selects products.html; return "product-details" selects product-details.html.Spring MVC uses the view name to select the HTML template to render.
Thymeleafth: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?

When ready: useful next stages after Stage 7

A useful continuation: planned next stages

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 stageOne useful changeWords to learn when we make that change
8. A separate product-file readerMove 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 operationsAdd ProductService for rules such as choosing products currently in stock.@Service, business rule and separation of responsibilities.
10. Product data accessPlace file-based reading and lookup behind a product repository.Repository pattern, @Repository and data access.
11. MongoDB storageReplace the text-file storage with MongoDB while keeping the page flow familiar.@Document, ID, MongoRepository, persistence and CRUD.
12. Explicit bean configurationSupply 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.