Skip to content

Latest commit

 

History

History
2950 lines (2309 loc) · 79.6 KB

File metadata and controls

2950 lines (2309 loc) · 79.6 KB

Spring Boot 3 - Spring MVC & Thymeleaf Complete Guide

📋 Table of Contents


🎨 Thymeleaf Overview

What is Thymeleaf?

  • Java templating engine for generating HTML views
  • General-purpose templating engine (not limited to web apps)
  • Separate project from Spring (www.thymeleaf.org)
  • Processes on the server-side
  • Can access Java code, objects, and Spring beans

Template Processing Flow

Browser Request → Spring Controller → Thymeleaf Template (Server) → HTML Response → Browser

⚙️ Basic Setup

Step 1: Add Thymeleaf Dependency

File: pom.xml

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>

Result: Spring Boot auto-configures Thymeleaf templates.

Step 2: Create Spring MVC Controller

File: DemoController.java

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class DemoController {
    
    @GetMapping("/")
    public String sayHello(Model theModel) {
        theModel.addAttribute("theDate", java.time.LocalDateTime.now());
        return "helloworld";
    }
}

Key Points:

  • Returns view name: "helloworld"
  • Spring looks for: src/main/resources/templates/helloworld.html

Step 3: Create Thymeleaf Template

File: src/main/resources/templates/helloworld.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Thymeleaf Demo</title>
</head>
<body>
    <p th:text="'Time on the server is ' + ${theDate}" />
</body>
</html>

Output:

Time on the server is 2025-03-30T11:27:52.297247

Template Structure:

  1. xmlns:th="http://www.thymeleaf.org" - Enable Thymeleaf expressions
  2. ${theDate} - Access model attribute
  3. th:text - Set text content dynamically

Template Location

src/main/resources/templates/
    ├── helloworld.html
    ├── customer-form.html
    └── student-form.html

Rules:

  • Templates must be in templates/ directory
  • Use .html extension for web apps

🎨 CSS Integration

Local CSS Files

Step 1: Create CSS File

Directory Structure:

src/main/resources/static/
    └── css/
        └── demo.css

File: demo.css

.funny {
    font-style: italic;
    color: green;
}

Note: You can create custom subdirectories:

  • static/css/
  • static/images/
  • static/js/

Step 2: Reference CSS in Template

File: helloworld.html

<head>
    <title>Thymeleaf Demo</title>
    <!-- Reference CSS file -->
    <link rel="stylesheet" th:href="@{/css/demo.css}" />
</head>

Key Syntax:

  • @{...} - Reference application context path (app root)
  • Path is relative to static/ directory

Step 3: Apply CSS Class

<body>
    <p th:text="'Time on the server is ' + ${theDate}" class="funny" />
</body>

Static Resource Search Order

Spring Boot searches directories in this order:

/src/main/resources/
    1. /META-INF/resources
    2. /resources
    3. /static
    4. /public

3rd Party CSS Libraries

Bootstrap - Local Installation

  1. Download Bootstrap files
  2. Place in /static/css/ directory
<head>
    <link rel="stylesheet" th:href="@{/css/bootstrap.min.css}" />
</head>

Bootstrap - Remote CDN

<head>
    <link rel="stylesheet" 
          href="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/css/bootstrap.min.css" />
</head>

🏗️ Spring MVC Architecture

Components

Component Description Created By
Web Pages UI layout Developer
Beans Controllers, Services Developer
Configuration XML, Annotations, Java Developer

Front Controller Pattern

DispatcherServlet (Front Controller):

  • Part of Spring Framework (pre-built)
  • Routes all requests
  • Coordinates between components

Developer Creates:

  • Controllers (yellow) - Business logic
  • Model objects (orange) - Data containers
  • View templates (dark green) - Display pages

Request Flow

Browser → DispatcherServlet → Controller → Model → View Template → HTML Response

Controller Responsibilities

@Controller
public class MyController {
    
    @GetMapping("/example")
    public String handleRequest(Model model) {
        // 1. Handle the request
        // 2. Store/retrieve data (DB, web service)
        // 3. Place data in model
        // 4. Return view name
        
        model.addAttribute("data", someData);
        return "view-name";
    }
}

Model

  • Container for application data
  • Can hold any Java object/collection
  • Shared between Controller and View
  • Data retrieved from backend systems (DB, web services)

View Templates

Supported by Spring MVC:

  • Thymeleaf (Recommended)
  • Groovy
  • Velocity
  • Freemarker

More info: www.luv2code.com/spring-mvc-views


📝 Form Handling

Basic Form Flow

User → Form Page → Submit → Controller → Process → Confirmation Page

Application Flow Diagram

Browser Request
    ↓
HelloWorldController (@RequestMapping("/showForm"))
    ↓
helloworld-form.html (Display Form)
    ↓
User Submits Form
    ↓
HelloWorldController (@RequestMapping("/processForm"))
    ↓
helloworld.html (Confirmation Page)

Basic Controller Example

File: HelloWorldController.java

import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
public class HelloWorldController {
    
    // Show initial HTML form
    @RequestMapping("/showForm")
    public String showForm() {
        return "helloworld-form";
    }
    
    // Process HTML form
    @RequestMapping("/processForm")
    public String processForm() {
        return "helloworld";
    }
}

Development Process

  1. Create Controller class
  2. Show HTML form
    • Create controller method
    • Create view page for form
  3. Process HTML form
    • Create controller method to process
    • Create confirmation view page

🔄 Adding Data to Spring Model

Spring Model Overview

The Model is a container for application data accessible in views.

Example: Process Form Data

Controller Method:

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import jakarta.servlet.http.HttpServletRequest;

@Controller
public class HelloWorldController {
    
    @RequestMapping("/processFormVersionTwo")
    public String letsShoutDude(HttpServletRequest request, Model model) {
        
        // Read request parameter from HTML form
        String theName = request.getParameter("studentName");
        
        // Convert to uppercase
        theName = theName.toUpperCase();
        
        // Create message
        String result = "Yo! " + theName;
        
        // Add message to model
        model.addAttribute("message", result);
        
        return "helloworld";
    }
}

View Template

File: helloworld.html

<html xmlns:th="http://www.thymeleaf.org">
<body>
    Hello World of Spring!
    
    The message: <span th:text="${message}" />
</body>
</html>

Adding Multiple Data Items

@RequestMapping("/example")
public String example(Model model) {
    
    // Get data
    String result = "Hello";
    List<Student> theStudentList = studentService.getStudents();
    ShoppingCart theShoppingCart = cartService.getCart();
    
    // Add data to model
    model.addAttribute("message", result);
    model.addAttribute("students", theStudentList);
    model.addAttribute("shoppingCart", theShoppingCart);
    
    return "view-name";
}

🎯 @RequestParam Annotation

Without @RequestParam (Using HttpServletRequest)

@RequestMapping("/processFormVersionTwo")
public String letsShoutDude(HttpServletRequest request, Model model) {
    
    // Manually read parameter
    String theName = request.getParameter("studentName");
    
    // Process...
    model.addAttribute("message", theName.toUpperCase());
    
    return "helloworld";
}

With @RequestParam (Recommended)

import org.springframework.web.bind.annotation.RequestParam;

@RequestMapping("/processFormVersionTwo")
public String letsShoutDude(
        @RequestParam("studentName") String theName,
        Model model) {
    
    // Spring automatically binds parameter to variable
    model.addAttribute("message", theName.toUpperCase());
    
    return "helloworld";
}

How it works:

  • Spring reads studentName parameter from request
  • Binds it to theName variable
  • Cleaner and more readable code

🌐 HTTP Methods

Common HTTP Methods

Method Description Use Case
GET Requests data from resource Read operations, bookmarkable URLs
POST Submits data to resource Create/update, sending sensitive data

GET vs POST Comparison

Feature GET POST
Data Location URL query string Request body
Bookmarkable ✅ Yes ❌ No
Data Length Limited (~2000 chars) No limit
Security Visible in URL Hidden in body
Binary Data ❌ No ✅ Yes
Debugging Easy (visible in URL) Harder

Sending Data with GET

HTML Form:

<form th:action="@{/processForm}" method="GET">
    <input type="text" name="studentName" />
    <input type="submit" value="Submit" />
</form>

URL Format:

http://example.com/processForm?studentName=John&email=john@example.com

Sending Data with POST

HTML Form:

<form th:action="@{/processForm}" method="POST">
    <input type="text" name="studentName" />
    <input type="submit" value="Submit" />
</form>

Request Structure:

POST /processForm HTTP/1.1
Headers...

Body:
studentName=John&email=john@example.com

Handling HTTP Methods in Controller

Generic (All Methods)

@RequestMapping("/processForm")
public String processForm(...) {
    // Handles GET, POST, PUT, DELETE, etc.
    return "view";
}

Constrain to Specific Method

Long Form:

@RequestMapping(path="/processForm", method=RequestMethod.GET)
public String processForm(...) {
    // Only handles GET
    return "view";
}

Shortcut Annotations (Recommended):

@GetMapping("/processForm")
public String processForm(...) {
    // Only handles GET
    return "view";
}

@PostMapping("/processForm")
public String processForm(...) {
    // Only handles POST
    return "view";
}

Other Shortcuts:

  • @PutMapping
  • @DeleteMapping
  • @PatchMapping

🔗 Form Data Binding

Overview

Spring MVC forms use data binding to automatically:

  • Set data from Java objects to form fields (display)
  • Retrieve data from form fields to Java objects (submit)

Big Picture Flow

1. Controller adds Student object to Model
2. Form displays with Student data (via getters)
3. User submits form
4. Spring creates Student object and populates fields (via setters)
5. Controller receives populated Student object
6. Confirmation page displays Student data

Student Class

File: Student.java

public class Student {
    
    private String firstName;
    private String lastName;
    
    public Student() {
    }
    
    // Getters and Setters
    public String getFirstName() {
        return firstName;
    }
    
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    
    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}

Step 1: Show Form - Add Model Attribute

File: StudentController.java

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class StudentController {
    
    @GetMapping("/showStudentForm")
    public String showForm(Model theModel) {
        
        // Create model attribute to hold form data
        theModel.addAttribute("student", new Student());
        
        return "student-form";
    }
}

Step 2: Create HTML Form with Data Binding

File: student-form.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Student Form</title>
</head>
<body>
    <h3>Student Registration Form</h3>
    
    <form th:action="@{/processStudentForm}" 
          th:object="${student}" 
          method="POST">
        
        First name: <input type="text" th:field="*{firstName}" />
        <br><br>
        
        Last name: <input type="text" th:field="*{lastName}" />
        <br><br>
        
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

Key Attributes:

  • th:object="${student}" - Bind form to model attribute
  • th:field="*{firstName}" - Bind input to property
  • *{...} - Shortcut for ${student.firstName}

When Form Loads:

// Spring calls getters to populate fields
student.getFirstName()
student.getLastName()

When Form Submits:

// Spring creates Student instance and calls setters
student.setFirstName(...)
student.setLastName(...)

Step 3: Process Form Submission

File: StudentController.java

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.ModelAttribute;

@Controller
public class StudentController {
    
    @PostMapping("/processStudentForm")
    public String processForm(@ModelAttribute("student") Student theStudent) {
        
        // Log input data
        System.out.println("theStudent: " + theStudent.getFirstName() 
                         + " " + theStudent.getLastName());
        
        return "student-confirmation";
    }
}

Step 4: Create Confirmation Page

File: student-confirmation.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Student Confirmation</title>
</head>
<body>
    <h3>Student Confirmation</h3>
    
    The student is confirmed: 
    <span th:text="${student.firstName} + ' ' + ${student.lastName}" />
</body>
</html>

📋 Form Elements

Drop-Down List

HTML <select> Tag Review

<select name="country">
    <option value="BR">Brazil</option>
    <option value="FR">France</option>
    <option value="DE">Germany</option>
    <option value="IN">India</option>
</select>

Thymeleaf <select> Tag

File: student-form.html

<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
    
    Country:
    <select th:field="*{country}">
        <option th:value="'Brazil'">Brazil</option>
        <option th:value="'France'">France</option>
        <option th:value="'Germany'">Germany</option>
        <option th:value="'India'">India</option>
    </select>
    
    <input type="submit" value="Submit" />
</form>

Update Student Class

public class Student {
    
    private String firstName;
    private String lastName;
    private String country;  // New property
    
    // Getters and setters for country
    public String getCountry() {
        return country;
    }
    
    public void setCountry(String country) {
        this.country = country;
    }
}

Update Confirmation Page

<body>
    Student: <span th:text="${student.firstName} + ' ' + ${student.lastName}" />
    <br><br>
    Country: <span th:text="${student.country}" />
</body>

Radio Buttons

File: student-form.html

<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
    
    Favorite Programming Language:
    <br>
    <input type="radio" th:field="*{favoriteLanguage}" th:value="'Java'" /> Java
    <br>
    <input type="radio" th:field="*{favoriteLanguage}" th:value="'Python'" /> Python
    <br>
    <input type="radio" th:field="*{favoriteLanguage}" th:value="'Go'" /> Go
    <br>
    <input type="radio" th:field="*{favoriteLanguage}" th:value="'JavaScript'" /> JavaScript
    
    <br><br>
    <input type="submit" value="Submit" />
</form>

Key Points:

  • All radio buttons share same th:field (property name)
  • Different th:value for each option
  • Binds to Student.favoriteLanguage property

Update Student Class

public class Student {
    
    private String firstName;
    private String lastName;
    private String favoriteLanguage;  // New property
    
    // Getter and setter
    public String getFavoriteLanguage() {
        return favoriteLanguage;
    }
    
    public void setFavoriteLanguage(String favoriteLanguage) {
        this.favoriteLanguage = favoriteLanguage;
    }
}

Check Boxes

File: student-form.html

<form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
    
    Favorite Operating Systems:
    <br>
    <input type="checkbox" th:field="*{favoriteSystems}" th:value="'Linux'" /> Linux
    <br>
    <input type="checkbox" th:field="*{favoriteSystems}" th:value="'macOS'" /> macOS
    <br>
    <input type="checkbox" th:field="*{favoriteSystems}" th:value="'Microsoft Windows'" /> Microsoft Windows
    
    <br><br>
    <input type="submit" value="Submit" />
</form>

Update Student Class

public class Student {
    
    private String firstName;
    private String lastName;
    private String[] favoriteSystems;  // Array for multiple selections
    
    // Getter and setter
    public String[] getFavoriteSystems() {
        return favoriteSystems;
    }
    
    public void setFavoriteSystems(String[] favoriteSystems) {
        this.favoriteSystems = favoriteSystems;
    }
}

Display in Confirmation Page

<body>
    Favorite Systems:
    <ul>
        <li th:each="system : ${student.favoriteSystems}" th:text="${system}" />
    </ul>
</body>

✅ Form Validation

Bean Validation API

Java's standard Bean Validation API provides:

  • Metadata model for entity validation
  • Support in Spring Boot and Thymeleaf
  • Built-in validation rules
  • Custom validation capabilities

Reference: http://www.beanvalidation.org

Common Validation Annotations

Annotation Description
@NotNull Must not be null
@Size String length constraints
@Min Minimum numeric value
@Max Maximum numeric value
@Pattern Regex pattern matching
@Email Valid email format
Custom Create your own

🔒 Required Fields Validation

Development Process

  1. Create Customer class with validation rules
  2. Add Controller code to show HTML form
  3. Develop HTML form with validation support
  4. Perform validation in Controller
  5. Create confirmation page

Step 1: Create Customer Class with Validation

File: Customer.java

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;

public class Customer {
    
    private String firstName;
    
    @NotNull(message = "is required")
    @Size(min=1, message = "is required")
    private String lastName = "";
    
    public Customer() {
    }
    
    // Getters and Setters
    public String getFirstName() {
        return firstName;
    }
    
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    
    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
}

Why both @NotNull and @Size?

  • @NotNull - Prevents null values
  • @Size(min=1) - Prevents empty strings

Step 2: Controller - Show Form

File: CustomerController.java

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class CustomerController {
    
    @GetMapping("/")
    public String showForm(Model theModel) {
        theModel.addAttribute("customer", new Customer());
        return "customer-form";
    }
}

Step 3: HTML Form with Validation Support

File: customer-form.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Customer Form</title>
    <style>
        .error {
            color: red;
        }
    </style>
</head>
<body>
    <h3>Customer Registration Form</h3>
    
    <form th:action="@{/processForm}" th:object="${customer}" method="POST">
        
        First name: <input type="text" th:field="*{firstName}" />
        <br><br>
        
        Last name (*): <input type="text" th:field="*{lastName}" />
        
        <!-- Show error message if present -->
        <span th:if="${#fields.hasErrors('lastName')}"
              th:errors="*{lastName}"
              class="error"></span>
        
        <br><br>
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

Validation Display Logic:

  • ${#fields.hasErrors('lastName')} - Check if field has errors
  • th:errors="*{lastName}" - Display error message
  • class="error" - Apply CSS styling

Step 4: Process Form with Validation

File: CustomerController.java

import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.PostMapping;
import jakarta.validation.Valid;

@Controller
public class CustomerController {
    
    @PostMapping("/processForm")
    public String processForm(
            @Valid @ModelAttribute("customer") Customer theCustomer,
            BindingResult theBindingResult) {
        
        // Check for validation errors
        if (theBindingResult.hasErrors()) {
            // Return to form if errors exist
            return "customer-form";
        } else {
            // Proceed to confirmation if valid
            return "customer-confirmation";
        }
    }
}

Key Points:

  • @Valid - Tells Spring to perform validation
  • BindingResult - Holds validation results
  • Must come immediately after @Valid parameter

Step 5: Confirmation Page

File: customer-confirmation.html

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Customer Confirmation</title>
</head>
<body>
    <h3>Customer Confirmation</h3>
    
    The customer is confirmed: 
    <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
</body>
</html>

🔧 @InitBinder - Trimming Whitespace

Problem

Previous validation allows whitespace-only input to pass validation.

Example: " " (spaces only) passes @Size(min=1) check ❌

Solution: @InitBinder

Pre-processor that executes before each web request.

File: CustomerController.java

import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.beans.propertyeditors.StringTrimmerEditor;

@Controller
public class CustomerController {
    
    @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
    
    // ... other methods
}

How it works:

  1. @InitBinder runs before each request
  2. StringTrimmerEditor(true) - Trim leading/trailing whitespace
  3. true parameter - Convert whitespace-only strings to null
  4. Registers editor for all String fields

Result: Whitespace-only input now fails @NotNull validation ✅


🔢 Number Range Validation

Validate Number Range with @Min and @Max

Use Case: Free passes field accepts only 0-10.

Step 1: Add Validation Rules

File: Customer.java

import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;

public class Customer {
    
    private String firstName;
    
    @NotNull(message = "is required")
    @Size(min=1, message = "is required")
    private String lastName = "";
    
    @Min(value=0, message="must be greater than or equal to zero")
    @Max(value=10, message="must be less than or equal to 10")
    private int freePasses;
    
    // Getters and setters
    public int getFreePasses() {
        return freePasses;
    }
    
    public void setFreePasses(int freePasses) {
        this.freePasses = freePasses;
    }
}

Step 2: Display Errors in HTML Form

File: customer-form.html

<form th:action="@{/processForm}" th:object="${customer}" method="POST">
    
    First name: <input type="text" th:field="*{firstName}" />
    <br><br>
    
    Last name (*): <input type="text" th:field="*{lastName}" />
    <span th:if="${#fields.hasErrors('lastName')}"
          th:errors="*{lastName}"
          class="error"></span>
    <br><br>
    
    Free passes (0-10): <input type="text" th:field="*{freePasses}" />
    <span th:if="${#fields.hasErrors('freePasses')}"
          th:errors="*{freePasses}"
          class="error"></span>
    <br><br>
    
    <input type="submit" value="Submit" />
</form>

Step 3: Update Confirmation Page

<body>
    Customer: <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
    <br><br>
    Free passes: <span th:text="${customer.freePasses}" />
</body>

Note: Controller validation code remains the same - Spring handles it automatically!


🔤 Regular Expression Validation

Validate Postal Code with Regex

Requirement: Postal code must be exactly 5 alphanumeric characters.

Step 1: Add Validation Rule

File: Customer.java

import jakarta.validation.constraints.Pattern;

public class Customer {
    
    private String firstName;
    
    @NotNull(message = "is required")
    @Size(min=1, message = "is required")
    private String lastName = "";
    
    @Pattern(regexp="^[a-zA-Z0-9]{5}", message="only 5 chars/digits")
    private String postalCode;
    
    // Getter and setter
    public String getPostalCode() {
        return postalCode;
    }
    
    public void setPostalCode(String postalCode) {
        this.postalCode = postalCode;
    }
}

Regex Breakdown:

  • ^ - Start of string
  • [a-zA-Z0-9] - Any letter (upper/lower) or digit
  • {5} - Exactly 5 characters

Step 2: Display Errors in Form

<form th:action="@{/processForm}" th:object="${customer}" method="POST">
    
    Postal Code: <input type="text" th:field="*{postalCode}" />
    <span th:if="${#fields.hasErrors('postalCode')}"
          th:errors="*{postalCode}"
          class="error"></span>
    
    <br><br>
    <input type="submit" value="Submit" />
</form>

Common Regex Patterns

Pattern Description Example
^[a-zA-Z0-9]{5} 5 alphanumeric chars ABC12
^\d{5} 5 digits 12345
^\d{5}(-\d{4})?$ US ZIP code 12345 or 12345-6789
^[A-Z]{2}\d{2}[A-Z]{2}$ Custom format AB12CD
^[a-zA-Z\s]+$ Letters and spaces only John Doe

Regex Resources:


🎨 Custom Validation

Create Custom @CourseCode Annotation

Use Case: Course code must start with "LUV".

Development Process

  1. Create custom validation rule
    • a. Create @CourseCode annotation
    • b. Create CourseCodeConstraintValidator
  2. Add validation rule to Customer class
  3. Display error messages on HTML form
  4. Update confirmation page

Step 1a: Create @CourseCode Annotation

File: CourseCode.java

import jakarta.validation.Constraint;
import jakarta.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Constraint(validatedBy = CourseCodeConstraintValidator.class)
@Target({ ElementType.METHOD, ElementType.FIELD })
@Retention(RetentionPolicy.RUNTIME)
public @interface CourseCode {
    
    // Define default course code
    public String value() default "LUV";
    
    // Define default error message
    public String message() default "must start with LUV";
    
    // Define default groups (required by Bean Validation spec)
    public Class<?>[] groups() default {};
    
    // Define default payloads (required by Bean Validation spec)
    public Class<? extends Payload>[] payload() default {};
}

Annotation Breakdown:

  • @Constraint(validatedBy = ...) - Links to validator class
  • @Target - Where annotation can be applied (methods, fields)
  • @Retention - Annotation available at runtime
  • value() - Default prefix to check
  • message() - Default error message
  • groups() and payload() - Required by Bean Validation spec

Step 1b: Create Constraint Validator

File: CourseCodeConstraintValidator.java

import jakarta.validation.ConstraintValidator;
import jakarta.validation.ConstraintValidatorContext;

public class CourseCodeConstraintValidator 
        implements ConstraintValidator<CourseCode, String> {
    
    private String coursePrefix;
    
    @Override
    public void initialize(CourseCode theCourseCode) {
        // Initialize with value from annotation
        coursePrefix = theCourseCode.value();
    }
    
    @Override
    public boolean isValid(String theCode, 
                          ConstraintValidatorContext theConstraintValidatorContext) {
        
        boolean result;
        
        if (theCode != null) {
            result = theCode.startsWith(coursePrefix);
        } else {
            // Allow null values (use @NotNull for null check)
            result = true;
        }
        
        return result;
    }
}

Key Methods:

  • initialize() - Called once when validation is set up
  • isValid() - Called for each validation check
    • Returns true if valid
    • Returns false if invalid

Generic Types:

  • First type: CourseCode - Annotation type
  • Second type: String - Data type being validated

Step 2: Apply Custom Annotation to Customer Class

File: Customer.java

public class Customer {
    
    private String firstName;
    
    @NotNull(message = "is required")
    @Size(min=1, message = "is required")
    private String lastName = "";
    
    @CourseCode(value="LUV", message="must start with LUV")
    private String courseCode;
    
    // Getter and setter
    public String getCourseCode() {
        return courseCode;
    }
    
    public void setCourseCode(String courseCode) {
        this.courseCode = courseCode;
    }
}

Usage Examples:

// Using custom prefix
@CourseCode(value="TECH", message="must start with TECH")
private String courseCode;

// Using default values
@CourseCode  // Uses default: value="LUV", message="must start with LUV"
private String courseCode;

Step 3: Display Errors in HTML Form

File: customer-form.html

<form th:action="@{/processForm}" th:object="${customer}" method="POST">
    
    First name: <input type="text" th:field="*{firstName}" />
    <br><br>
    
    Last name (*): <input type="text" th:field="*{lastName}" />
    <span th:if="${#fields.hasErrors('lastName')}"
          th:errors="*{lastName}"
          class="error"></span>
    <br><br>
    
    Course Code: <input type="text" th:field="*{courseCode}" />
    <span th:if="${#fields.hasErrors('courseCode')}"
          th:errors="*{courseCode}"
          class="error"></span>
    <br><br>
    
    <input type="submit" value="Submit" />
</form>

Step 4: Update Confirmation Page

<body>
    <h3>Customer Confirmation</h3>
    
    Customer: <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
    <br><br>
    Course Code: <span th:text="${customer.courseCode}" />
</body>

🔧 Handle String Input for Integer Fields

Problem

When user enters text in an integer field (e.g., "abc" for freePasses), Spring throws a type mismatch exception before validation runs.

Solution: Custom Error Messages

Create a properties file to customize error messages.

File: src/main/resources/messages.properties

# Custom error message for type mismatch
typeMismatch.customer.freePasses=Invalid number

Register Properties File (if needed):

File: MyAppConfig.java

import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.support.ResourceBundleMessageSource;

@Configuration
public class MyAppConfig {
    
    @Bean
    public MessageSource messageSource() {
        ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource();
        messageSource.setBasename("messages");
        return messageSource;
    }
}

Note: Spring Boot auto-configures this in most cases.


📝 Complete Examples

Example 1: Basic Form with Validation

Customer.java:

import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Pattern;

public class Customer {
    
    private String firstName;
    
    @NotNull(message = "is required")
    @Size(min=1, message = "is required")
    private String lastName = "";
    
    @Min(value=0, message="must be greater than or equal to zero")
    @Max(value=10, message="must be less than or equal to 10")
    private Integer freePasses;
    
    @Pattern(regexp="^[a-zA-Z0-9]{5}", message="only 5 chars/digits")
    private String postalCode;
    
    @CourseCode(value="LUV", message="must start with LUV")
    private String courseCode;
    
    // Constructors
    public Customer() {
    }
    
    // Getters and Setters
    public String getFirstName() {
        return firstName;
    }
    
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    
    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    
    public Integer getFreePasses() {
        return freePasses;
    }
    
    public void setFreePasses(Integer freePasses) {
        this.freePasses = freePasses;
    }
    
    public String getPostalCode() {
        return postalCode;
    }
    
    public void setPostalCode(String postalCode) {
        this.postalCode = postalCode;
    }
    
    public String getCourseCode() {
        return courseCode;
    }
    
    public void setCourseCode(String courseCode) {
        this.courseCode = courseCode;
    }
}

CustomerController.java:

import org.springframework.beans.propertyeditors.StringTrimmerEditor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.WebDataBinder;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.InitBinder;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import jakarta.validation.Valid;

@Controller
public class CustomerController {
    
    // Pre-process all String form data
    @InitBinder
    public void initBinder(WebDataBinder dataBinder) {
        StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
        dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
    }
    
    @GetMapping("/")
    public String showForm(Model theModel) {
        theModel.addAttribute("customer", new Customer());
        return "customer-form";
    }
    
    @PostMapping("/processForm")
    public String processForm(
            @Valid @ModelAttribute("customer") Customer theCustomer,
            BindingResult theBindingResult) {
        
        // Log validation errors (for debugging)
        if (theBindingResult.hasErrors()) {
            System.out.println("Validation errors:");
            theBindingResult.getAllErrors().forEach(error -> {
                System.out.println(error.getDefaultMessage());
            });
            return "customer-form";
        } else {
            return "customer-confirmation";
        }
    }
}

customer-form.html:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Customer Registration Form</title>
    <style>
        .error {
            color: red;
            font-weight: bold;
        }
    </style>
</head>
<body>
    <h2>Customer Registration Form</h2>
    <i>Fill out the form. Asterisk (*) means required.</i>
    <br><br>
    
    <form th:action="@{/processForm}" th:object="${customer}" method="POST">
        
        First name: 
        <input type="text" th:field="*{firstName}" />
        <br><br>
        
        Last name (*): 
        <input type="text" th:field="*{lastName}" />
        <span th:if="${#fields.hasErrors('lastName')}"
              th:errors="*{lastName}"
              class="error"></span>
        <br><br>
        
        Free passes (0-10): 
        <input type="text" th:field="*{freePasses}" />
        <span th:if="${#fields.hasErrors('freePasses')}"
              th:errors="*{freePasses}"
              class="error"></span>
        <br><br>
        
        Postal Code: 
        <input type="text" th:field="*{postalCode}" />
        <span th:if="${#fields.hasErrors('postalCode')}"
              th:errors="*{postalCode}"
              class="error"></span>
        <br><br>
        
        Course Code: 
        <input type="text" th:field="*{courseCode}" />
        <span th:if="${#fields.hasErrors('courseCode')}"
              th:errors="*{courseCode}"
              class="error"></span>
        <br><br>
        
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

customer-confirmation.html:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Customer Confirmation</title>
</head>
<body>
    <h2>Customer Confirmation</h2>
    
    <p>
        The customer is confirmed: 
        <span th:text="${customer.firstName} + ' ' + ${customer.lastName}" />
    </p>
    
    <p>Free passes: <span th:text="${customer.freePasses}" /></p>
    <p>Postal Code: <span th:text="${customer.postalCode}" /></p>
    <p>Course Code: <span th:text="${customer.courseCode}" /></p>
    
    <br><br>
    <a th:href="@{/}">Back to Form</a>
</body>
</html>

Example 2: Student Form with Multiple Elements

Student.java:

public class Student {
    
    private String firstName;
    private String lastName;
    private String country;
    private String favoriteLanguage;
    private String[] favoriteSystems;
    
    public Student() {
    }
    
    // Full getters and setters
    public String getFirstName() {
        return firstName;
    }
    
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    
    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    
    public String getCountry() {
        return country;
    }
    
    public void setCountry(String country) {
        this.country = country;
    }
    
    public String getFavoriteLanguage() {
        return favoriteLanguage;
    }
    
    public void setFavoriteLanguage(String favoriteLanguage) {
        this.favoriteLanguage = favoriteLanguage;
    }
    
    public String[] getFavoriteSystems() {
        return favoriteSystems;
    }
    
    public void setFavoriteSystems(String[] favoriteSystems) {
        this.favoriteSystems = favoriteSystems;
    }
}

StudentController.java:

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

@Controller
public class StudentController {
    
    @GetMapping("/showStudentForm")
    public String showForm(Model theModel) {
        
        // Create student object
        Student theStudent = new Student();
        
        // Add to model
        theModel.addAttribute("student", theStudent);
        
        return "student-form";
    }
    
    @PostMapping("/processStudentForm")
    public String processForm(@ModelAttribute("student") Student theStudent) {
        
        // Log the input data
        System.out.println("theStudent: " + theStudent.getFirstName() 
                         + " " + theStudent.getLastName());
        System.out.println("Country: " + theStudent.getCountry());
        System.out.println("Favorite Language: " + theStudent.getFavoriteLanguage());
        
        return "student-confirmation";
    }
}

student-form.html:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Student Registration Form</title>
</head>
<body>
    <h3>Student Registration Form</h3>
    
    <form th:action="@{/processStudentForm}" th:object="${student}" method="POST">
        
        First name: <input type="text" th:field="*{firstName}" />
        <br><br>
        
        Last name: <input type="text" th:field="*{lastName}" />
        <br><br>
        
        Country:
        <select th:field="*{country}">
            <option th:value="'Brazil'">Brazil</option>
            <option th:value="'France'">France</option>
            <option th:value="'Germany'">Germany</option>
            <option th:value="'India'">India</option>
            <option th:value="'United States'">United States</option>
        </select>
        <br><br>
        
        Favorite Programming Language:
        <br>
        <input type="radio" th:field="*{favoriteLanguage}" th:value="'Java'" /> Java
        <br>
        <input type="radio" th:field="*{favoriteLanguage}" th:value="'Python'" /> Python
        <br>
        <input type="radio" th:field="*{favoriteLanguage}" th:value="'C++'" /> C++
        <br>
        <input type="radio" th:field="*{favoriteLanguage}" th:value="'JavaScript'" /> JavaScript
        <br><br>
        
        Favorite Operating Systems:
        <br>
        <input type="checkbox" th:field="*{favoriteSystems}" th:value="'Linux'" /> Linux
        <br>
        <input type="checkbox" th:field="*{favoriteSystems}" th:value="'macOS'" /> macOS
        <br>
        <input type="checkbox" th:field="*{favoriteSystems}" th:value="'Microsoft Windows'" /> Microsoft Windows
        <br><br>
        
        <input type="submit" value="Submit" />
    </form>
</body>
</html>

student-confirmation.html:

<!DOCTYPE HTML>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Student Confirmation</title>
</head>
<body>
    <h3>Student Confirmation</h3>
    
    The student is confirmed: 
    <span th:text="${student.firstName} + ' ' + ${student.lastName}" />
    
    <br><br>
    
    Country: <span th:text="${student.country}" />
    
    <br><br>
    
    Favorite Language: <span th:text="${student.favoriteLanguage}" />
    
    <br><br>
    
    Favorite Operating Systems:
    <ul>
        <li th:each="system : ${student.favoriteSystems}" th:text="${system}" />
    </ul>
</body>
</html>

🧪 Testing Your Application

Test Flow

  1. Start Application: Run Spring Boot application
  2. Access Form: Navigate to http://localhost:8080/
  3. Test Validation:
    • Submit empty required fields
    • Enter invalid numbers
    • Enter invalid postal code
    • Enter invalid course code
  4. Verify Error Messages: Check that appropriate errors display
  5. Submit Valid Data: Ensure confirmation page displays correctly

Common Testing Scenarios

Test Case Expected Result
Empty last name Error: "is required"
Whitespace-only last name Error: "is required" (with @InitBinder)
Free passes = -1 Error: "must be greater than or equal to zero"
Free passes = 15 Error: "must be less than or equal to 10"
Free passes = "abc" Error: "Invalid number"
Postal code = "ABC" Error: "only 5 chars/digits"
Postal code = "ABC123" Pass (6 chars) - adjust regex if needed
Course code = "TECH101" Error: "must start with LUV"
Course code = "LUV101" Pass

🚀 Quick Start Checklist

Basic Thymeleaf Setup

  • Add Thymeleaf dependency to pom.xml
  • Create Controller with @Controller
  • Add model attributes in controller methods
  • Create HTML templates in src/main/resources/templates/
  • Use xmlns:th="http://www.thymeleaf.org" in HTML
  • Test basic page rendering

CSS Integration

  • Create CSS files in src/main/resources/static/css/
  • Reference CSS with th:href="@{/css/filename.css}"
  • Test styling displays correctly

Form Handling

  • Create model class (POJO)
  • Add @GetMapping to show form
  • Add model attribute in GET method
  • Create HTML form with th:object and th:field
  • Add @PostMapping to process form
  • Use @ModelAttribute to bind form data
  • Create confirmation page

Form Validation

  • Add validation annotations to model class
  • Add @Valid to controller POST method
  • Add BindingResult parameter after @Valid
  • Display errors in HTML with th:if and th:errors
  • Add @InitBinder for whitespace trimming
  • Test all validation scenarios

Custom Validation

  • Create custom annotation with @interface
  • Create constraint validator implementing ConstraintValidator
  • Apply custom annotation to model fields
  • Test custom validation logic

📚 Additional Resources


⚠️ Important Notes

  1. Template Location: Must be in src/main/resources/templates/
  2. Static Resources: Go in src/main/resources/static/
  3. Thymeleaf Namespace: Always include xmlns:th="http://www.thymeleaf.org"
  4. Model Attribute Names: Must match between controller and view
  5. Validation Order: @Valid parameter must come before BindingResult
  6. @InitBinder: Required to handle whitespace properly
  7. Integer Fields: Use Integer (wrapper) instead of int for optional numeric fields
  8. Custom Validation: Returning true in isValid() allows null values
  9. Error Messages: Display with th:if and th:errors combination
  10. Form Methods: Use @GetMapping to show, @PostMapping to process

🔍 Common Issues & Solutions

Issue: Form data not binding

Solution: Ensure property names match exactly (case-sensitive) between:

  • Model class getter/setter names
  • th:field values in HTML
  • Form field names

Issue: Validation not working

Solution: Check that:

  • @Valid is present on controller parameter
  • BindingResult comes immediately after @Valid parameter
  • Validation annotations are from jakarta.validation.constraints

Issue: Whitespace passes validation

Solution: Implement @InitBinder with StringTrimmerEditor

Issue: Type mismatch errors

Solution: Create messages.properties file with custom error messages

Issue: Custom validation not triggering

Solution: Verify:

  • @Constraint(validatedBy = YourValidator.class) on annotation
  • Validator implements ConstraintValidator<YourAnnotation, DataType>
  • initialize() and isValid() methods are implemented

🎨 Common Thymeleaf Errors & Solutions

Issue: Variables Not Rendering

Problem: Thymeleaf variable shows ${variableName} instead of actual value

Solutions:

<!-- ❌ WRONG - Missing th: namespace -->
<input value="${user.name}" />

<!-- ✅ CORRECT - Use Thymeleaf attributes -->
<input th:value="${user.name}" />

<!-- ❌ WRONG - Invalid reference -->
<p th:text="${undefined.property}">Default</p>

<!-- ✅ CORRECT - Use safe navigation or null check -->
<p th:text="${user?.name}">Default</p>

Issue: CSS/JS Not Loading

Problem: Static resources (CSS, JavaScript) not loading in Thymeleaf templates

Solutions:

<!-- ❌ WRONG - Absolute path doesn't work with Spring context -->
<link rel="stylesheet" href="/css/style.css" />

<!-- ✅ CORRECT - Use th:href with @ syntax -->
<link rel="stylesheet" th:href="@{/css/style.css}" />

<!-- ✅ CORRECT - Static resources go in src/main/resources/static/ -->
src/main/resources/
├── static/
│   ├── css/
│   │   └── style.css
│   └── js/
│       └── script.js
└── templates/
    └── index.html

Issue: Form Data Not Binding

Problem: Form data not binding to controller model

// ❌ WRONG - No @ModelAttribute or wrong object name
@PostMapping("/save")
public String saveUser(User user) {  // Object name mismatch
    return "redirect:/success";
}

// ✅ CORRECT - Explicit model attribute binding
@PostMapping("/save")
public String saveUser(@ModelAttribute("user") User user, BindingResult result) {
    if (result.hasErrors()) {
        return "userForm";
    }
    return "redirect:/success";
}

Issue: Loop Variables Not Working

Problem: th:each not iterating over list

<!-- ❌ WRONG - Incorrect syntax -->
<tr th:each="${user of users}">

<!-- ✅ CORRECT - Use 'in' keyword -->
<tr th:each="user : ${users}">
    <td th:text="${user.firstName}"></td>
    <td th:text="${user.lastName}"></td>
</tr>

<!-- ✅ CORRECT - Access iteration metadata -->
<tr th:each="user, iterStat : ${users}">
    <td th:text="${iterStat.count}"></td>  <!-- 1-based index -->
    <td th:text="${iterStat.index}"></td>  <!-- 0-based index -->
    <td th:text="${iterStat.first}"></td>  <!-- true/false -->
    <td th:text="${iterStat.last}"></td>   <!-- true/false -->
</tr>

Issue: Conditional Rendering Not Working

Problem: th:if not evaluating conditions

<!-- ❌ WRONG - Complex logic without parentheses -->
<p th:if="${user.age} > 18">Adult</p>

<!-- ✅ CORRECT - Wrap in parentheses -->
<p th:if="${user.age > 18}">Adult</p>

<!-- ✅ CORRECT - Use ternary operator -->
<p th:text="${user.age > 18} ? 'Adult' : 'Minor'"></p>

<!-- ✅ CORRECT - Use th:switch/th:case -->
<div th:switch="${user.role}">
    <p th:case="ADMIN">Administrator</p>
    <p th:case="USER">Regular User</p>
    <p th:case="*">Unknown Role</p>
</div>

Issue: Date Formatting

Problem: Dates showing as timestamps or incorrect format

<!-- ❌ WRONG - No format specified -->
<p th:text="${user.createdAt}"></p>  <!-- Shows: 2023-01-15T10:30:45 -->

<!-- ✅ CORRECT - Format using # objects.formatDate -->
<p th:text="${#dates.format(user.createdAt, 'dd/MM/yyyy')}"></p>

<!-- ✅ CORRECT - Use model attribute with @DateTimeFormat -->
@DateTimeFormat(pattern = "yyyy-MM-dd")
private LocalDate dateOfBirth;

Issue: Fragment Not Displaying

Problem: Thymeleaf fragments not included properly

<!-- header.html - Define fragment -->
<div th:fragment="header">
    <h1>My Application</h1>
</div>

<!-- ❌ WRONG - Incorrect fragment syntax -->
<div th:include="header"></div>

<!-- ✅ CORRECT - Use fragment notation -->
<div th:insert="~{header :: header}"></div>

<!-- ✅ CORRECT - Shorthand for insert -->
<div th:insert="header :: header"></div>

<!-- ✅ CORRECT - Use replace for complete replacement -->
<div th:replace="header :: header"></div>

🔧 Form Validation Best Practices

Client-Side + Server-Side Validation

<!-- HTML5 validation + th:errors for server-side -->
<form method="POST" th:object="${user}">
    <input type="email" th:field="*{email}" required />
    <span th:if="${#fields.hasErrors('email')}" th:errors="*{email}"></span>
    
    <input type="password" th:field="*{password}" 
           pattern=".{8,}" title="Must be at least 8 characters" required />
    <span th:if="${#fields.hasErrors('password')}" th:errors="*{password}"></span>
    
    <button type="submit">Register</button>
</form>

Custom Error Messages

# messages.properties
NotBlank.user.firstName=First name is required
Size.user.password=Password must be between {min} and {max} characters
Email.user.email=Please enter a valid email address

Display All Errors

<div th:if="${#fields.hasErrors('*')}">
    <div class="alert alert-danger">
        <ul>
            <li th:each="err : ${#fields.errors('*')}" th:text="${err}"></li>
        </ul>
    </div>
</div>

🎯 Thymeleaf Template Best Practices

1. Use Fragment Composition

<!-- Base layout -->
<html th:fragment="layout">
<head>
    <title th:insert=":: title"></title>
</head>
<body>
    <nav th:insert=":: nav"></nav>
    <main th:insert=":: content"></main>
    <footer th:insert=":: footer"></footer>
</body>
</html>

<!-- Child page -->
<div th:fragment="content">
    <h1>Welcome</h1>
</div>

2. Utility Objects in Thymeleaf

<!-- #dates - Date utilities -->
<p th:text="${#dates.format(date, 'dd/MM/yyyy')}"></p>
<p th:text="${#dates.year(date)}"></p>

<!-- #strings - String utilities -->
<p th:text="${#strings.toUpperCase(text)}"></p>
<p th:text="${#strings.substring(text, 0, 5)}"></p>

<!-- #numbers - Number formatting -->
<p th:text="${#numbers.formatDecimal(price, 1, 2)}"></p>

<!-- #lists - List operations -->
<p th:text="${#lists.size(items)}"></p>
<p th:text="${#lists.isEmpty(items)}"></p>

<!-- #objects - Object utilities -->
<p th:if="${#objects.nullSafe(user.profile.avatar, 'default.jpg')}"></p>

3. Conditional CSS Classes

<div th:classappend="${user.active} ? 'active' : 'inactive'">
    User Status
</div>

<!-- Multiple conditions -->
<button th:classappend="${form.hasErrors()} ? 'error' : ''"
        th:disabled="${form.isSubmitted()}">
    Submit
</button>

📱 Bootstrap Integration Tips

Common Bootstrap Setup

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My App</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" th:href="@{/css/custom.css}">
</head>
<body>
    <nav class="navbar navbar-expand-lg navbar-dark bg-dark">
        <!-- Navigation content -->
    </nav>
    
    <div class="container mt-5">
        <!-- Main content -->
    </div>
    
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
    <script th:src="@{/js/custom.js}"></script>
</body>
</html>

Form Styling with Bootstrap

<form method="POST" th:object="${employee}" class="form-horizontal">
    <div class="mb-3">
        <label for="firstName" class="form-label">First Name</label>
        <input type="text" class="form-control" id="firstName" th:field="*{firstName}" />
        <div class="invalid-feedback" th:if="${#fields.hasErrors('firstName')}">
            <span th:errors="*{firstName}"></span>
        </div>
    </div>
    
    <div class="mb-3">
        <label for="email" class="form-label">Email</label>
        <input type="email" class="form-control" id="email" th:field="*{email}" />
    </div>
    
    <button type="submit" class="btn btn-primary">Save</button>
    <a href="/" class="btn btn-secondary">Cancel</a>
</form>

💾 Database-Integrated MVC Examples

Example: Employee Management Web Application

Step 1: Employee Entity

File: src/main/java/com/example/entity/Employee.java

package com.example.entity;

import jakarta.persistence.*;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;

@Entity
@Table(name = "employees")
public class Employee {
    
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Integer id;
    
    @NotBlank(message = "First name is required")
    @Size(min = 2, max = 50, message = "First name must be between 2 and 50 characters")
    @Column(name = "first_name")
    private String firstName;
    
    @NotBlank(message = "Last name is required")
    @Size(min = 2, max = 50, message = "Last name must be between 2 and 50 characters")
    @Column(name = "last_name")
    private String lastName;
    
    @Email(message = "Email should be valid")
    @NotBlank(message = "Email is required")
    @Column(name = "email")
    private String email;
    
    @NotBlank(message = "Department is required")
    @Column(name = "department")
    private String department;
    
    @Column(name = "salary")
    private Double salary;
    
    // Constructors
    public Employee() {
    }
    
    public Employee(String firstName, String lastName, String email, 
                    String department, Double salary) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.email = email;
        this.department = department;
        this.salary = salary;
    }
    
    // Getters and Setters
    public Integer getId() {
        return id;
    }
    
    public void setId(Integer id) {
        this.id = id;
    }
    
    public String getFirstName() {
        return firstName;
    }
    
    public void setFirstName(String firstName) {
        this.firstName = firstName;
    }
    
    public String getLastName() {
        return lastName;
    }
    
    public void setLastName(String lastName) {
        this.lastName = lastName;
    }
    
    public String getEmail() {
        return email;
    }
    
    public void setEmail(String email) {
        this.email = email;
    }
    
    public String getDepartment() {
        return department;
    }
    
    public void setDepartment(String department) {
        this.department = department;
    }
    
    public Double getSalary() {
        return salary;
    }
    
    public void setSalary(Double salary) {
        this.salary = salary;
    }
    
    @Override
    public String toString() {
        return "Employee{" + "id=" + id + ", firstName='" + firstName + '\'' + 
               ", lastName='" + lastName + '\'' + ", email='" + email + '\'' +
               ", department='" + department + '\'' + ", salary=" + salary + '}';
    }
}

Step 2: Employee Repository

File: src/main/java/com/example/repository/EmployeeRepository.java

package com.example.repository;

import com.example.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;

public interface EmployeeRepository extends JpaRepository<Employee, Integer> {
    
    // Find by department
    List<Employee> findByDepartment(String department);
    
    // Find by last name (containing)
    List<Employee> findByLastNameContainingIgnoreCase(String lastName);
    
    // Custom query - find by email
    @Query("SELECT e FROM Employee e WHERE e.email = :email")
    Employee findByEmail(@Param("email") String email);
    
    // Find high salary employees
    @Query("SELECT e FROM Employee e WHERE e.salary > :salary ORDER BY e.salary DESC")
    List<Employee> findHighSalaryEmployees(@Param("salary") Double salary);
}

Step 3: Employee Service

File: src/main/java/com/example/service/EmployeeService.java

package com.example.service;

import com.example.entity.Employee;
import com.example.repository.EmployeeRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;

@Service
@Transactional
public class EmployeeService {
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    // Get all employees
    public List<Employee> getAllEmployees() {
        return employeeRepository.findAll();
    }
    
    // Get employee by ID
    public Employee getEmployeeById(Integer id) {
        Optional<Employee> employee = employeeRepository.findById(id);
        return employee.orElse(null);
    }
    
    // Save employee
    public Employee saveEmployee(Employee employee) {
        return employeeRepository.save(employee);
    }
    
    // Update employee
    public Employee updateEmployee(Employee employee) {
        return employeeRepository.save(employee);
    }
    
    // Delete employee
    public void deleteEmployee(Integer id) {
        employeeRepository.deleteById(id);
    }
    
    // Find by department
    public List<Employee> findByDepartment(String department) {
        return employeeRepository.findByDepartment(department);
    }
    
    // Search by last name
    public List<Employee> searchByLastName(String lastName) {
        return employeeRepository.findByLastNameContainingIgnoreCase(lastName);
    }
    
    // Get total count
    public long getTotalEmployeeCount() {
        return employeeRepository.count();
    }
}

Step 4: Employee MVC Controller

File: src/main/java/com/example/controller/EmployeeController.java

package com.example.controller;

import com.example.entity.Employee;
import com.example.service.EmployeeService;
import jakarta.validation.Valid;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;

@Controller
@RequestMapping("/employees")
public class EmployeeController {
    
    @Autowired
    private EmployeeService employeeService;
    
    // Display all employees
    @GetMapping("/list")
    public String listEmployees(Model model) {
        model.addAttribute("employees", employeeService.getAllEmployees());
        model.addAttribute("totalCount", employeeService.getTotalEmployeeCount());
        return "employee/list";
    }
    
    // Show add employee form
    @GetMapping("/showForm")
    public String showForm(Model model) {
        model.addAttribute("employee", new Employee());
        return "employee/form";
    }
    
    // Process form submission
    @PostMapping("/save")
    public String saveEmployee(@Valid @ModelAttribute("employee") Employee employee,
                              BindingResult bindingResult,
                              Model model) {
        
        // Check for validation errors
        if (bindingResult.hasErrors()) {
            return "employee/form";
        }
        
        // Save employee
        employeeService.saveEmployee(employee);
        
        // Add success message
        model.addAttribute("successMessage", "Employee saved successfully!");
        
        // Redirect to list
        return "redirect:/employees/list";
    }
    
    // Show edit form
    @GetMapping("/edit/{id}")
    public String editForm(@PathVariable Integer id, Model model) {
        Employee employee = employeeService.getEmployeeById(id);
        if (employee == null) {
            return "redirect:/employees/list";
        }
        model.addAttribute("employee", employee);
        return "employee/form";
    }
    
    // Delete employee
    @GetMapping("/delete/{id}")
    public String deleteEmployee(@PathVariable Integer id) {
        employeeService.deleteEmployee(id);
        return "redirect:/employees/list";
    }
    
    // Search employees
    @GetMapping("/search")
    public String searchEmployees(@RequestParam(required = false) String lastName,
                                 @RequestParam(required = false) String department,
                                 Model model) {
        
        if (lastName != null && !lastName.isEmpty()) {
            model.addAttribute("employees", employeeService.searchByLastName(lastName));
        } else if (department != null && !department.isEmpty()) {
            model.addAttribute("employees", employeeService.findByDepartment(department));
        } else {
            model.addAttribute("employees", employeeService.getAllEmployees());
        }
        
        return "employee/list";
    }
}

Step 5: Employee List Template

File: src/main/resources/templates/employee/list.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Employee Management</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
    <div class="container mt-5">
        <div class="card">
            <div class="card-header bg-primary text-white">
                <div class="row">
                    <div class="col">
                        <h3>Employee Directory</h3>
                    </div>
                    <div class="col text-end">
                        <span class="badge bg-light text-dark">Total: <span th:text="${totalCount}">0</span></span>
                    </div>
                </div>
            </div>
            <div class="card-body">
                <!-- Add Button -->
                <a href="/employees/showForm" class="btn btn-success mb-3">
                    <i class="bi bi-plus-circle"></i> Add New Employee
                </a>
                
                <!-- Search Form -->
                <form th:action="@{/employees/search}" method="GET" class="mb-3">
                    <div class="row g-2">
                        <div class="col-md-4">
                            <input type="text" class="form-control" name="lastName" 
                                   placeholder="Search by Last Name" />
                        </div>
                        <div class="col-md-4">
                            <input type="text" class="form-control" name="department" 
                                   placeholder="Filter by Department" />
                        </div>
                        <div class="col-md-4">
                            <button type="submit" class="btn btn-primary w-100">Search</button>
                        </div>
                    </div>
                </form>
                
                <!-- No employees message -->
                <div th:if="${employees.isEmpty()}" class="alert alert-info">
                    No employees found. <a href="/employees/showForm">Add one now!</a>
                </div>
                
                <!-- Employees table -->
                <table class="table table-striped table-hover" th:unless="${employees.isEmpty()}">
                    <thead class="table-dark">
                        <tr>
                            <th>ID</th>
                            <th>First Name</th>
                            <th>Last Name</th>
                            <th>Email</th>
                            <th>Department</th>
                            <th>Salary</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    <tbody>
                        <tr th:each="employee : ${employees}">
                            <td th:text="${employee.id}"></td>
                            <td th:text="${employee.firstName}"></td>
                            <td th:text="${employee.lastName}"></td>
                            <td th:text="${employee.email}"></td>
                            <td>
                                <span class="badge bg-info" th:text="${employee.department}"></span>
                            </td>
                            <td th:text="${'$' + #numbers.formatDecimal(employee.salary, 1, 2)}"></td>
                            <td>
                                <a th:href="@{/employees/edit/{id}(id=${employee.id})}" 
                                   class="btn btn-sm btn-primary">Edit</a>
                                <a th:href="@{/employees/delete/{id}(id=${employee.id})}" 
                                   class="btn btn-sm btn-danger"
                                   onclick="return confirm('Are you sure?');">Delete</a>
                            </td>
                        </tr>
                    </tbody>
                </table>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Step 6: Employee Form Template

File: src/main/resources/templates/employee/form.html

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Employee Form</title>
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
    <link rel="stylesheet" th:href="@{/css/style.css}">
</head>
<body>
    <div class="container mt-5">
        <div class="row">
            <div class="col-md-6 offset-md-3">
                <div class="card">
                    <div class="card-header bg-primary text-white">
                        <h3 th:if="${employee.id == null}">Add New Employee</h3>
                        <h3 th:if="${employee.id != null}">Edit Employee</h3>
                    </div>
                    <div class="card-body">
                        <form th:action="@{/employees/save}" 
                              th:object="${employee}" 
                              method="POST">
                            
                            <!-- Hidden ID field -->
                            <input type="hidden" th:field="*{id}" />
                            
                            <!-- First Name -->
                            <div class="mb-3">
                                <label for="firstName" class="form-label">First Name *</label>
                                <input type="text" class="form-control" id="firstName" 
                                       th:field="*{firstName}"
                                       th:classappend="${#fields.hasErrors('firstName') ? 'is-invalid' : ''}"
                                       required>
                                <div class="invalid-feedback" 
                                     th:if="${#fields.hasErrors('firstName')}">
                                    <span th:errors="*{firstName}"></span>
                                </div>
                            </div>
                            
                            <!-- Last Name -->
                            <div class="mb-3">
                                <label for="lastName" class="form-label">Last Name *</label>
                                <input type="text" class="form-control" id="lastName" 
                                       th:field="*{lastName}"
                                       th:classappend="${#fields.hasErrors('lastName') ? 'is-invalid' : ''}"
                                       required>
                                <div class="invalid-feedback" 
                                     th:if="${#fields.hasErrors('lastName')}">
                                    <span th:errors="*{lastName}"></span>
                                </div>
                            </div>
                            
                            <!-- Email -->
                            <div class="mb-3">
                                <label for="email" class="form-label">Email *</label>
                                <input type="email" class="form-control" id="email" 
                                       th:field="*{email}"
                                       th:classappend="${#fields.hasErrors('email') ? 'is-invalid' : ''}"
                                       required>
                                <div class="invalid-feedback" 
                                     th:if="${#fields.hasErrors('email')}">
                                    <span th:errors="*{email}"></span>
                                </div>
                            </div>
                            
                            <!-- Department -->
                            <div class="mb-3">
                                <label for="department" class="form-label">Department *</label>
                                <select class="form-select" id="department" 
                                        th:field="*{department}"
                                        th:classappend="${#fields.hasErrors('department') ? 'is-invalid' : ''}"
                                        required>
                                    <option value="">Select Department</option>
                                    <option value="IT">IT</option>
                                    <option value="HR">HR</option>
                                    <option value="Finance">Finance</option>
                                    <option value="Marketing">Marketing</option>
                                    <option value="Operations">Operations</option>
                                </select>
                                <div class="invalid-feedback" 
                                     th:if="${#fields.hasErrors('department')}">
                                    <span th:errors="*{department}"></span>
                                </div>
                            </div>
                            
                            <!-- Salary -->
                            <div class="mb-3">
                                <label for="salary" class="form-label">Salary</label>
                                <input type="number" class="form-control" id="salary" 
                                       th:field="*{salary}" 
                                       step="0.01" min="0">
                            </div>
                            
                            <!-- Submit Button -->
                            <div class="d-grid gap-2">
                                <button type="submit" class="btn btn-primary">Save Employee</button>
                                <a href="/employees/list" class="btn btn-secondary">Cancel</a>
                            </div>
                        </form>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Step 7: Application Configuration

File: src/main/resources/application.properties

# Server Configuration
server.port=8080
server.servlet.context-path=/

# Database Configuration
spring.datasource.url=jdbc:mysql://localhost:3306/employee_db
spring.datasource.username=root
spring.datasource.password=password
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver

# Hibernate/JPA Configuration
spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=false
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
spring.jpa.properties.hibernate.format_sql=true

# Thymeleaf Configuration
spring.thymeleaf.cache=false
spring.thymeleaf.prefix=classpath:/templates/
spring.thymeleaf.suffix=.html
spring.thymeleaf.mode=HTML

# Logging
logging.level.com.example=DEBUG
logging.level.org.springframework.web=INFO

Step 8: Integration Test

File: src/test/java/com/example/controller/EmployeeControllerTest.java

package com.example.controller;

import com.example.entity.Employee;
import com.example.repository.EmployeeRepository;
import com.example.service.EmployeeService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
public class EmployeeControllerTest {
    
    @Autowired
    private MockMvc mockMvc;
    
    @Autowired
    private EmployeeService employeeService;
    
    @Autowired
    private EmployeeRepository employeeRepository;
    
    @BeforeEach
    public void setup() {
        // Clear database before each test
        employeeRepository.deleteAll();
        
        // Add test employee
        Employee emp = new Employee("John", "Doe", "john@example.com", "IT", 50000.0);
        employeeService.saveEmployee(emp);
    }
    
    @Test
    public void testListEmployees() throws Exception {
        mockMvc.perform(get("/employees/list"))
            .andExpect(status().isOk())
            .andExpect(view().name("employee/list"))
            .andExpect(model().attributeExists("employees"))
            .andExpect(model().attributeExists("totalCount"));
    }
    
    @Test
    public void testShowForm() throws Exception {
        mockMvc.perform(get("/employees/showForm"))
            .andExpect(status().isOk())
            .andExpect(view().name("employee/form"))
            .andExpect(model().attributeExists("employee"));
    }
    
    @Test
    public void testSaveEmployeeSuccess() throws Exception {
        mockMvc.perform(post("/employees/save")
            .param("firstName", "Jane")
            .param("lastName", "Smith")
            .param("email", "jane@example.com")
            .param("department", "HR")
            .param("salary", "60000"))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/employees/list"));
    }
    
    @Test
    public void testSaveEmployeeValidationError() throws Exception {
        mockMvc.perform(post("/employees/save")
            .param("firstName", "")  // Empty - will fail validation
            .param("lastName", "Smith")
            .param("email", "jane@example.com")
            .param("department", "HR")
            .param("salary", "60000"))
            .andExpect(status().isOk())
            .andExpect(view().name("employee/form"));
    }
    
    @Test
    public void testEditForm() throws Exception {
        Employee emp = employeeRepository.findAll().get(0);
        
        mockMvc.perform(get("/employees/edit/" + emp.getId()))
            .andExpect(status().isOk())
            .andExpect(view().name("employee/form"))
            .andExpect(model().attributeExists("employee"));
    }
    
    @Test
    public void testDeleteEmployee() throws Exception {
        Employee emp = employeeRepository.findAll().get(0);
        
        mockMvc.perform(get("/employees/delete/" + emp.getId()))
            .andExpect(status().is3xxRedirection())
            .andExpect(redirectedUrl("/employees/list"));
    }
}

🧪 Testing Thymeleaf Templates

Unit Testing Templates

@SpringBootTest
public class TemplateTest {
    
    @Autowired
    private WebApplicationContext context;
    
    private MockMvc mockMvc;
    
    @BeforeEach
    public void setup() {
        mockMvc = MockMvcBuilders.webAppContextSetup(context).build();
    }
    
    @Test
    public void testUserDisplayInTemplate() throws Exception {
        mockMvc.perform(get("/users/1"))
            .andExpect(status().isOk())
            .andExpect(view().name("user/detail"))
            .andExpect(model().attributeExists("user"))
            .andExpect(model().attribute("user", 
                hasProperty("firstName", equalTo("John"))));
    }
    
    @Test
    public void testErrorMessageDisplay() throws Exception {
        mockMvc.perform(post("/users")
            .param("firstName", "")  // Empty - will cause validation error
            .param("lastName", "Doe"))
            .andExpect(status().isOk())
            .andExpect(view().name("user/form"))
            .andExpect(model().attributeHasFieldErrors("user", "firstName"));
    }
}

Happy Coding! 🚀