Java 3 - 2026

Week 3

Week 1 - Setup Pet Clinic App. Deployment using Docker.
Week 2 - Domains, AWS, ER Diagrams and Databases, Understand the Pet Clinic Setup.

Week 3 - Create, Read, Update, Delete (CRUD) functionality.
Lesson 3 - Homepage with content from the database. Custom not found and error pages.
Lesson 4 - Users, roles, and permissions setup from Java 2. User Registration.

Lesson 5 - Login and logout. Cookies. User permission access.

Lesson 6 - Edit and delete user profile. Password reset.
Lesson 7 - Pagination. Filtering and limiting records by category.
Lesson 8 - Web Sockets.
Lesson 9 - Internationalization. Date and currency formatting.
Lesson 10 - Email and SMS messaging. Failed login attempts.
Lesson 11 - Shopping Cart. Payment processing.

Course Plan

  • Modify the Pet Clinic application based on the user stories and personas you created in Java 2.
  • Derive an entity-relationship diagram and SQL queries for the data you will collect.
  • Create user interface drawings and wireframes for all web layouts.
  • Write use case narratives and build diagrams for each feature you add.
  • Write unit tests as necessary.

Expectations

  • Click the PetClinicApplication dropdown and select "Edit Configurations..."
  • Click the button that says "Modify options".
  • Change "On 'Update' action" to "Update classes and resources"
  • Change "On frame deactivation" also to "Update classes and resources".
  • Open WelcomeController.java (see system package) and welcome.html (see resources > templates).
  • Note that return "welcome"; means "display the welcome.html page".
  • Run the program and open http://localhost:8080
  • If you get a "Failed to clean up stale outputs" error. Stop the server. Delete your red "build" folder. Click the Main Menu, click "Build", then click "Build Project".

Edit Configurations

  • Add <h3>Hello World</h3> to welcome.html. Go back to the web browser. Wait for the project to update before reloading.
  • Add a System.out.println("Hello World"); statement in the welcome() method. Go back to the web browser. Wait for the project to update before reloading.

Make Changes

  • By default, this project uses Sass (Syntactically Awesome Style Sheets).
  • The "src/main/scss/" files generate the CSS files in the "src/main/resources/static.resources/css/" folder.
  • If you do want to use Sass, you have to enable Maven and disable Gradle. I think this is a little too complicated for this project. Therefore, you may delete the "src/main/scss" folder.
  • Open "resources/static/resources/css/petclinic.css" with the server running
  • On line 211 , change background-color: var(--bs-body-bg); to something like --bs-primary
  • Refreshing the browser won't display the change. You must press Ctrl + Shift + R to make a hard refresh to tell the browser to grab a new copy of the static CSS file instead of using a cached version.
  • Stop the server before continuing.

Disable SCSS

  • In our upcoming Java code (School.java), we will define enum constants in uppercase (standard Java convention):
    public enum SchoolStatus { ACTIVE, INACTIVE, SUSPENDED;}
  • However, in your database, the values are currently stored in lowercase ('active').

  • When Spring Boot reads 'active' from the database, it will try to find an Enum named active, but will fail (because it only sees ACTIVE), and will crash.

  • To fix this, you should update your database to use uppercase values to match Java standards.

Database Tables

-- 1. Reset the Tables
DROP TABLE IF EXISTS locations;
DROP TABLE IF EXISTS schools;

-- 2. Create Schools (Note: ENUM values are now UPPERCASE)
CREATE TABLE schools (
    id INT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(255) NOT NULL,
    domain VARCHAR(255) NOT NULL,
    status_id ENUM('ACTIVE', 'INACTIVE', 'SUSPENDED') DEFAULT 'ACTIVE',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME DEFAULT NULL,
    UNIQUE INDEX idx_schools_domain (domain)
);

-- 3. Create Locations (Note: ENUM values are now UPPERCASE)
CREATE TABLE locations (
    id INT AUTO_INCREMENT PRIMARY KEY,
    school_id INT NOT NULL,
    parent_location_id INT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    address VARCHAR(255),
    latitude DECIMAL(8,4),
    longitude DECIMAL(8,4),
    status_id ENUM('DRAFT', 'ACTIVE', 'CLOSED', 'COMING_SOON') DEFAULT 'ACTIVE',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    deleted_at DATETIME DEFAULT NULL,
    CONSTRAINT fk_locations_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
    CONSTRAINT fk_locations_parent FOREIGN KEY (parent_location_id) REFERENCES locations(id) ON DELETE SET NULL
);
  • This section matches the structure of Owner (the "Parent" entity) and Pet (the "Child" entity) to my School and Location tables.
  • Create a new package folder called "school": src/main/java/org/springframework/samples/petclinic/school/
  • See the next slides to create School and Location classes to map your database tables to Java objects.
  • We must extend NamedEntity because both tables have an id and a name, just like Pet and PetType.
  • Owner extended Person, but since a School isn't a person, I had School extend NamedEntity directly (which Person extends).
  • I added the createdAt and updatedAt fields but marked them as insertable = false, updatable = false. This tells Java: "Read these from the database, but don't try to write them." This lets your DEFAULT CURRENT_TIMESTAMP logic handle the actual data entry.

school package

  • Using Lombok is an industry standard for reducing "boilerplate" code. It allows you to replace dozens of lines of constructors and getter/setter methods with a single annotation.
  • For Gradle (build.gradle): If you switch back to Gradle, add this to your dependencies block. Click the Gradle elephant icon in IntelliJ to reload/sync changes.
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'
  • For Maven (pom.xml): Add this inside the <dependencies> block.
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>

Lombok

  • Since Lombok works by generating code during compilation, IntelliJ needs to know this is happening so it doesn't show you red errors saying "Method not found."

  • Go to Settings > Build, Execution, Deployment > Compiler > Annotation Processors.

  • Check the box Enable annotation processing.

  • Click Apply and OK.

Lombok

  • The School class is similar to Owner.java. It holds the list of locations. The Location class is defined on the next slide.
  • I mapped ENUM strings to inner enum classes (SchoolStatus) and used @Enumerated(EnumType.STRING) to ensure they save correctly to your database.

School class

package org.springframework.samples.petclinic.school;

import jakarta.persistence.*;
import jakarta.validation.constraints.NotEmpty;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

@Entity
@Table(name = "schools")
@Getter
@Setter
// Intercept the delete command and turn it into an update
@SQLDelete(sql = "UPDATE schools SET deleted_at = NOW() WHERE id = ?")
// Automatically filter out deleted rows when reading data
@SQLRestriction("deleted_at IS NULL")
public class School extends NamedEntity { // Inherits 'id' and 'name'

	@Column(name = "domain")
	@NotEmpty
	private String domain;

	@Enumerated(EnumType.STRING)
	@Column(name = "status_id")
	private SchoolStatus status = SchoolStatus.ACTIVE;

	@Column(name = "created_at", insertable = false, updatable = false)
	private LocalDateTime createdAt;

	@Column(name = "updated_at", insertable = false, updatable = false)
	private LocalDateTime updatedAt;

	@Column(name = "deleted_at")
	private LocalDateTime deletedAt;

	@OneToMany(cascade = CascadeType.ALL, mappedBy = "school", fetch = FetchType.EAGER)
	private List<Location> locations = new ArrayList<>();

	// We keep this helper method manually because Lombok @Setter
	// simply replaces the list, it doesn't handle the bi-directional link.
	public void addLocation(Location location) {
		location.setSchool(this);
		getLocations().add(location);
	}

	public enum SchoolStatus {
		ACTIVE, INACTIVE, SUSPENDED;
	}
}
  • In a relational database, Hard Deletes (actually running DELETE FROM locations...) are dangerous.

  • Scenario: You hold a "Summer 2026 Volleyball League" at "Gym A".

  • The Problem: In 2027, "Gym A" is torn down, so you delete it from the system.

  • The Crash: If you hard-delete "Gym A", your "Summer 2026" historical records will either vanish (Cascade Delete) or crash your app (ForeignKey Constraint Violation) when you try to view the old schedule.

  • By using a deleted_at timestamp:

    • History is Preserved: The league still points to "Gym A".

    • User Experience: You can filter "Gym A" out of new dropdown menus, but keep it visible in old reports.

    • Restoration: If the deletion was accidental, you just set the deleted_at field back to NULL to restore it.

Deleting Data

  • Later if you call locationRepository.delete(gymA): Hibernate sees the @SQLDelete annotation. Instead of running DELETE FROM, it runs UPDATE locations SET deleted_at = NOW() ....

  • When you call locationRepository.findAll(): Hibernate sees the @SQLRestriction annotation. It automatically appends AND deleted_at IS NULL to your query, so the deleted gym doesn't show up in your list of active locations.

Deleting Data

  • The Location "child" class is similar to Pet.java. It belongs to a school and can have a parent location (like a room inside a building).

Location class

package org.springframework.samples.petclinic.school;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.annotations.SQLDelete;
import org.hibernate.annotations.SQLRestriction;
import org.springframework.samples.petclinic.model.NamedEntity;

import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "locations")
@Getter // Auto-generates getters for all fields
@Setter // Auto-generates setters for all fields
@SQLDelete(sql = "UPDATE locations SET deleted_at = NOW() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Location extends NamedEntity {

    @ManyToOne
    @JoinColumn(name = "school_id")
    private School school;

    @ManyToOne
    @JoinColumn(name = "parent_location_id")
    private Location parentLocation;

    @Column(name = "description")
    private String description;

    @Column(name = "address")
    private String address;

    @Column(name = "latitude")
    private BigDecimal latitude;

    @Column(name = "longitude")
    private BigDecimal longitude;

    @Enumerated(EnumType.STRING)
    @Column(name = "status_id")
    private LocationStatus status = LocationStatus.ACTIVE;

    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;

    public enum LocationStatus {
        DRAFT, ACTIVE, CLOSED, COMING_SOON;
    }
    
    // No more manual code below this line!
}
  • The SchoolRepository interface gives you access to methods like findAll, save, and findById without writing SQL.

  • SchoolRepository.java matches the setup of OwnerRepository.java.

SchoolRepository interface

package org.springframework.samples.petclinic.school;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.Repository;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collection;

public interface SchoolRepository extends Repository<School, Integer> {

    /**
     * Retrieve all Schools from the data store.
     */
    @Transactional(readOnly = true)
    Collection<School> findAll();

    /**
     * Retrieve Schools by page (for pagination in the UI)
     */
    @Transactional(readOnly = true)
    Page<School> findAll(Pageable pageable);

    /**
     * Save a School to the data store, either inserting or updating it.
     */
    void save(School school);

    /**
     * Retrieve a School by its id.
     */
    @Transactional(readOnly = true)
    School findById(Integer id);
}
  • The SchoolController class handles the web traffic. I have simplified this to just the "List" view to get you started, similar to VetController or the list portion of OwnerController.

SchoolController class

package org.springframework.samples.petclinic.school;

import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;

import java.util.List;

@Controller
class SchoolController {

    private final SchoolRepository schoolRepository;

    public SchoolController(SchoolRepository schoolRepository) {
        this.schoolRepository = schoolRepository;
    }

    @GetMapping("/schools")
    public String showSchoolList(@RequestParam(defaultValue = "1") int page, Model model) {
        // Pagination setup (5 items per page)
        Pageable pageable = PageRequest.of(page - 1, 5);
        Page<School> schoolPage = schoolRepository.findAll(pageable);

        model.addAttribute("currentPage", page);
        model.addAttribute("totalPages", schoolPage.getTotalPages());
        model.addAttribute("totalItems", schoolPage.getTotalElements());
        model.addAttribute("listSchools", schoolPage.getContent());

        return "schools/schoolList"; // You will need to create this HTML file
    }
}
  • If using Maven, when you run the program, you'll get an error saying "Formatting violations found in the following files:", then it lists the Location, School, SchoolController, and SchoolRepository files.

  • The error is happening because this project includes a strict code enforcement plugin called spring-javaformat (defined in your pom.xml). It ensures every file in the project follows the exact same spacing, indentation, and import order rules.

  • Open the Maven tool window (right side of IntelliJ).

  • Expand Plugins.

  • Expand spring-javaformat.

  • Double-click spring-javaformat:apply.

Spring Formatting

  • Based on the pagination limit of 5 items per page we defined in the Controller, we need at least 6 schools to trigger a second page.

  • I have generated 15 schools below (which will create 3 full pages) and attached several locations to them, including some nested "child" locations to test that relationship.

Database Insert Statements

-- 1. Populate Schools (15 entries to test pagination)
INSERT INTO schools (name, domain, status_id) VALUES 
('Kirkwood Community College', 'kirkwood.edu', 'active'),
('University of Iowa', 'uiowa.edu', 'active'),
('Iowa State University', 'iastate.edu', 'active'),
('University of Northern Iowa', 'uni.edu', 'active'),
('Coe College', 'coe.edu', 'active'),
('Mount Mercy University', 'mtmercy.edu', 'active'),
('Drake University', 'drake.edu', 'active'),
('Grinnell College', 'grinnell.edu', 'active'),
('Luther College', 'luther.edu', 'active'),
('Simpson College', 'simpson.edu', 'inactive'), -- Testing status
('Wartburg College', 'wartburg.edu', 'active'),
('Cornell College', 'cornellcollege.edu', 'active'),
('Loras College', 'loras.edu', 'active'),
('Clarke University', 'clarke.edu', 'suspended'), -- Testing status
('St. Ambrose University', 'sau.edu', 'active');

-- Locations for Kirkwood (ID 1)
INSERT INTO locations (school_id, name, description, address, status_id) VALUES
(1, 'Main Campus', 'The primary campus in Cedar Rapids', '6301 Kirkwood Blvd SW, Cedar Rapids, IA', 'active');

-- Locations for Univ of Iowa (ID 2)
INSERT INTO locations (school_id, name, description, address, status_id) VALUES
(2, 'Carver-Hawkeye Arena', 'Main sports arena', '1 Elliott Dr, Iowa City, IA', 'active');

-- 3. Populate Child Locations (Nested Rooms/Areas)
-- We need to know the IDs of the parent locations created above. 
-- Assuming 'Main Campus' is ID 1 and 'Carver-Hawkeye Arena' is ID 2:

-- Inside Kirkwood Main Campus (Parent ID: 1)
INSERT INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 1, 'Michael J Gould Rec Center', 'Student recreation facility', 'active'),
(1, 1, 'Johnson Hall', 'Athletics building and gymnasium', 'active');

-- Inside Carver-Hawkeye Arena (Parent ID: 2)
INSERT INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(2, 2, 'Main Court', 'The primary basketball court', 'active'),
(2, 2, 'Weight Room', 'Athlete training facility', 'coming_soon');

-- Inside Michael J Gould Rec Center (Grandchild location! Parent ID: 3)
INSERT INTO locations (school_id, parent_location_id, name, description, status_id) VALUES
(1, 3, 'Basketball Court 1', 'North court', 'active'),
(1, 3, 'Basketball Court 2', 'South court', 'active');
  • Does IntelliJ display a yellow bar saying "Module JDK is misconfigured" when opening Java files or after clicking the Gradle elephant icon?

  • This is a common annoyance in IntelliJ when working with Gradle. It happens because IntelliJ has two different places where it tracks the Java version: the Project SDK (for the editor) and the Gradle JVM (for the build tool).

  • Open Settings (Windows/Linux) or Preferences (Mac).

  • Navigate to Build, Execution, Deployment > Build Tools > Gradle.

  • Look for the Gradle JVM dropdown at the bottom.

  • Do not leave it as Project SDK or JAVA_HOME.

  • Click the dropdown and select the specific version you are using (e.g., "Microsoft OpenJDK").

  • Click Apply and OK.

Default SDK Notice

  • Open "src/main/resources/templates/fragments/layout.html".

  • Add a link for your new School Controller by pasting this code block inside the <ul class="nav navbar-nav me-auto"> section (e.g., before or after the "Find Owners" block).

HTML files

<li th:replace="~{::menuItem ('/schools','schools','find schools','graduation-cap',#{findSchools})}">
  <span class="fa fa-search" aria-hidden="true"></span>
  <span th:text="#{findSchools}">Find schools</span>
</li>
  • The <span> tags inside the <li> tags don't matter. These are dummy code. Because th:replace swaps the entire tag out, the inner HTML is ignored by Thymeleaf.
  • It is only there so that if you open the raw HTML file in a browser (without running the server), you can still see what the menu looks like (Natural Templating).
  • Update icons using Font Awesome v4.
  • The code th:replace="~{::menuItem (...)}" is a Thymeleaf "Fragment Expression" that works like a function call.

  • th:replace: This tells Thymeleaf to completely remove the <li> tag you see in the HTML and replace it with the content of the fragment specified.

  • ::menuItem: This points to a fragment defined earlier in the same file (line 30 of layout.html) with th:fragment="menuItem (link,active,title,glyph,text)".

Explanation of th:replace

  • The Parameters: The values inside the parentheses are passed to that fragment to generate the dynamic link:

    1. '/' and '/owners/find': The URL the link goes to.

    2. 'home' and 'owners': The Active ID. If this matches the page's ID, the tab is highlighted.

    3. 'homepage' and 'find owners': The Tooltip text (title attribute).

    4. 'home' and 'search': The Icon name (uses FontAwesome, e.g., fa-search).

    5. #{home} and #{findOwners}: The Display Text. The #{...} syntax means "look this up in messages.properties" (for translation).

Explanation of th:replace

  • In the IntelliJ Project window (left side), expand the messages folder.

  • Eight messages files is too much for this project. I will keep the: en, es, and ko internationalization message files.

  • DO NOT DELETE these files, but delete the others:

    • messages.properties ( Critical: This is the default/fallback file. If a user visits from a country you don't support, the app falls back to this file.)

    • messages_en.properties (Specific English)

    • messages_es.properties (Spanish)

    • messages_ko.properties (Korean)

  • When deleting files, I uncheck "Safe delete" and "Search in comments and strings"

message.properties files

  • The current messages_es file contains this property:

    address=Dirección

    The current messages_ko file contains this property:

    address=주소

  • This is a character encoding mismatch known as Mojibake.

  • Your files are successfully saved in UTF-8 format (which is good).

  • However, your editor (IntelliJ) or the application is trying to read them as ISO-8859-1 (the old default for Java properties files).

  • For example, the Spanish word "Dirección":

    • In UTF-8, the character ó is stored as two bytes: C3 B3.

    • In ISO-8859-1, C3 is displayed as àand B3 is displayed as ³.

    • Result: Dirección.

message.properties files

  • You need to explicitly tell the application to read these message files using UTF-8.

  • Open your src/main/resources/application.properties file and add this line under the # Internationalization section:
    spring.messages.encoding=UTF-8

Tell Spring Boot to use UTF-8

 

  • In IntelliJ, go to Settings (or Preferences on Mac).

  • Navigate to Editor > File Encodings.

  • Look for the section "Properties Files (*.properties)".

  • Change the "Default encoding for properties files" dropdown to UTF-8.

  • Check the box that says "Transparent native-to-ascii conversion".

  • Click Apply and OK.

  • After doing this, the text in your editor should automatically snap back to Dirección and 주소.

Display Correct Characters

  • Add these lines to the bottom of src/main/resources/messages/messages.properties to support the new School and Location features.

messages.properties

# Schools & Locations
school=School
schools=Schools
findSchools=Find Schools
addSchool=Add School
updateSchool=Update School
editSchool=Edit School
location=Location
locations=Locations
domain=Domain
status=Status
  • Add these lines to the bottom of messages_es.properties:

# Escuelas y Ubicaciones
school=Escuela
schools=Escuelas
findSchools=Buscar escuelas
addSchool=Añadir escuela
updateSchool=Actualizar escuela
editSchool=Editar escuela
location=Ubicación
locations=Ubicaciones
domain=Dominio
status=Estado
  • Add these lines to the bottom of messages_ko.properties:

# 학교 및 위치
school=학교
schools=학교 목록
findSchools=학교 찾기
addSchool=학교 추가
updateSchool=학교 수정
editSchool=학교 편집
location=위치
locations=위치 목록
domain=도메인
status=상태
  • Since you are modifying the available languages, one of your automated tests might fail if it was expecting those files to exist.

  • Run your tests (Press Ctrl + Shift + F10 on the src/test/java folder).

  • If the tests run, watch out for I18nPropertiesSyncTest.java.

    • If it passes: You are done! (The test likely scans the directory dynamically).

    • If it fails: You can safely delete that test file or update it to match your new list of languages. It is just a "quality assurance" test and deleting it won't break the application.

Language Tests

  • Create a new folder called "schools" in "src/main/resources/templates/".

  • Inside that folder, create a file called "schoolsList.html".

  • Note that these file names must match the return statement in the SchoolController.showSchoolList() method.

  • I have adapted the table columns to match our School fields (Domain, Status, Locations) and updated the pagination links to point to /schools.

schoolsList HTML file

<!DOCTYPE html>

<html xmlns:th="https://www.thymeleaf.org" th:replace="~{fragments/layout :: layout (~{::body},'schools')}">

<body>

  <h2 th:text="#{schools}">Schools</h2>

  <table id="schools" class="table table-striped">
    <thead>
      <tr>
        <th th:text="#{name}">Name</th>
        <th th:text="#{domain}">Domain</th>
        <th th:text="#{status}">Status</th>
        <th th:text="#{locations}">Locations</th>
      </tr>
    </thead>
    <tbody>
      <tr th:each="school : ${listSchools}">
        <td>
          <a th:href="@{/schools/__${school.id}__}" th:text="${school.name}" /></a>
        </td>
        <td th:text="${school.domain}" />
        <td th:text="${school.status}" />
        <td>
          <span th:text="${school.locations.size()}" />
        </td>
      </tr>
    </tbody>
  </table>

  <div th:if="${totalPages > 1}">
    <span th:text="#{pages}">Pages:</span>
    <span>[</span>
    <span th:each="i: ${#numbers.sequence(1, totalPages)}">
      <a th:if="${currentPage != i}" th:href="@{'/schools?page=' + ${i}}">[[${i}]]</a>
      <span th:unless="${currentPage != i}">[[${i}]]</span>
    </span>
    <span>] </span>
    <span>
      <a th:if="${currentPage > 1}" th:href="@{'/schools?page=1'}" th:title="#{first}" class="fa fa-fast-backward"></a>
      <span th:unless="${currentPage > 1}" th:title="#{first}" class="fa fa-fast-backward"></span>
    </span>
    <span>
      <a th:if="${currentPage > 1}" th:href="@{'/schools?page=__${currentPage - 1}__'}" th:title="#{previous}"
        class="fa fa-step-backward"></a>
      <span th:unless="${currentPage > 1}" th:title="#{previous}" class="fa fa-step-backward"></span>
    </span>
    <span>
      <a th:if="${currentPage < totalPages}" th:href="@{'/schools?page=__${currentPage + 1}__'}" th:title="#{next}"
        class="fa fa-step-forward"></a>
      <span th:unless="${currentPage < totalPages}" th:title="#{next}" class="fa fa-step-forward"></span>
    </span>
    <span>
      <a th:if="${currentPage < totalPages}" th:href="@{'/schools?page=__${totalPages}__'}" th:title="#{last}"
        class="fa fa-fast-forward"></a>
      <span th:unless="${currentPage < totalPages}" th:title="#{last}" class="fa fa-fast-forward"></span>
    </span>
  </div>
</body>

</html>
  • Layout Fragment: Changed 'owners' to 'schools' in the th:replace tag so the "Find schools" navbar item stays highlighted.

  • Table Headers: Swapped Address/City/Telephone for Domain, Status, and Locations using the message keys we added earlier.

  • Data Loop: Iterates over ${listSchools} instead of ${listOwners}.

  • Pagination URLs: Updated all pagination links (First, Prev, Next, Last) to point to @{'/schools?page=...'} instead of /owners.

Changes from ownersList.html

  • Locations Column

    • school.locations: Accesses the List<Location> in your School object.

    • .size(): This is a standard Java method for Lists. Thymeleaf (via SpEL) allows you to call Java methods directly in your templates.

    • Null Safety: Since your School.java initializes the list with new ArrayList<>() (instead of null), this is safe and will display "0" if no locations exist.

Changes from ownersList.html

  • Open your build.gradle file.

  • While the spring-boot-starter-webmvc-test dependency is excellent for testing specific parts of your app (like just the Controller), it doesn't provide the full suite of testing tools.

  • spring-boot-starter-test provides everything needed for standard unit testing, including: JUnit 5, Mockito, AssertJ, Hamcrest, Spring Test (MockMvc, @MockBean, etc.)

  • Add this line to your dependencies block, ideally right at the top of the testImplementation section so it acts as the foundation:
    testImplementation 'org.springframework.boot:spring-boot-starter-test'

  • After adding, make sure to click the Elephant icon (Load Gradle Changes) in the floating notification or the Gradle tool window to download the library.

Gradle Test Dependency

  • Mockito & JUnit: These guarantee you have the core engines to run the @MockBean code on the next slide.

  • AssertJ: This allows you to write fluent assertions (e.g., assertThat(school.getName()).isEqualTo("...")) which are arguably more readable than standard Java assertions you wrote in Java 2.

  • Hamcrest: This is a framework for writing "matchers"—objects that check if a value matches a certain rule.

    • Its main goal is to make your tests read like English sentences. Instead of writing code like assertEquals(expected, actual), Hamcrest lets you write declarative statements like assertThat(actual, is(equalTo(expected))).

  • ​AssertJ and Hamcrest are rivals. You may find tutorials that use one or the other.

spring-boot-starter-test

  • Open your SchoolController class. Right-click the class name, select "Show Context Actions", select "Create test". Keep the default settings. Click OK.

  • Add the following code to test the showSchoolList method.

SchoolControllerTest

package org.springframework.samples.petclinic.school;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.WebMvcTest;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;

import java.util.List;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

/**
 * Test class for the {@link SchoolController}
 */
@WebMvcTest(SchoolController.class)
class SchoolControllerTest {

	private static final int TEST_SCHOOL_ID = 1;

	@Autowired
	private MockMvc mockMvc;

	@MockitoBean
	private SchoolRepository schools;

	private School school;

	@BeforeEach
	void setup() {
		// Create a dummy school to be returned by the mocked repository
		school = new School();
		school.setId(TEST_SCHOOL_ID);
		school.setName("Kirkwood Community College");
		school.setDomain("kirkwood.edu");
		school.setStatus(School.SchoolStatus.ACTIVE);
	}

	@Test
	void testShowSchoolList() throws Exception {
		// 1. Arrange: Create a "Page" of schools to mock the database response
		// matches the 5 items per page logic in your controller
		Pageable pageable = PageRequest.of(0, 5);
		Page<School> schoolPage = new PageImpl<>(List.of(school), pageable, 1);

		// Tell the mock: "When the controller asks for all schools, give them this list"
		given(this.schools.findAll(any(Pageable.class))).willReturn(schoolPage);

		// 2. Act & Assert: Perform the GET request and verify the results
		mockMvc.perform(get("/schools").param("page", "1"))
			.andExpect(status().isOk())
			.andExpect(model().attributeExists("listSchools"))
			.andExpect(model().attributeExists("totalPages"))
			.andExpect(model().attributeExists("currentPage"))
			.andExpect(view().name("schools/schoolList"));
	}
}
  • Spring Boot version 4.0.0 was officially released on November 20, 2025.

  • Your build.gradle file specifies id 'org.springframework.boot' version '4.0.1' on line 4. That means we are using bleeding edge technology and online resources may reference older versions.

  • Spring Boot 4 introduced a new package structure, meaning v3 import statements are not valid for v4.

  • Be sure to note that when writing your own unit tests.

Spring Boot 

  • Open Docker Desktop. Click the Images tab to see your previous images.

  • If you are running out of space, delete any old containers/images you no longer need. When deleting an image, you may get "Image is in use. Delete the container that's using it and try again."

  • Build a new image.
    docker build -t <your-docker-username>/<your-projectname>:<your-version> .

  • For example:
    docker build -t mlhaus/petclinic:v2 .

  • Refresh your list of images in Docker Desktop to see the newly created one.

  • In the world of containers, tags like v1 are treated as "immutable". The best practice is to increment the version number (v2, v3) or use the Git commit hash (e.g., :git-a1b2c3d).

Build an Image

  • Use Docker to run your image locally.
    docker run -p 8099:8080 `
      -e MYSQL_URL="jdbc:mysql://<your-db-host>:3306/<your-db-name>?useSSL=true" `
      -e MYSQL_USER="<your-db-user>" `
      -e MYSQL_PASS="<your-db-password>" `
    <your-docker-username>/<your-projectname>:<your-version>

  • Remember to use backticks ` with Windows Powershell or backslashes \ with Git Bash.

  • Change MYSQL_PASS to MYSQL_PASSWORD if that is what you used.

  • Visit http://localhost:8099 to confirm it works.

  • Press Ctrl + C in the terminal to stop the server.

Run the Image Locally

  • Use Docker to push your image to Docker Hub.
    docker push <your-docker-username>/<your-projectname>:<your-version>

  • Go to your Docker Hub Repositories list to see your image.
    https://hub.docker.com/repositories/

  • Click the repository name to see your tag list.

Push the Image to Docker Hub

  • To deploy a new Docker container image to an existing Azure Container App, you need to update the container app's image reference using the Azure CLI.

  • Update the container app with the new image tag.
    az containerapp update `
        --name <your-container-app-name> `
        --resource-group <your-resource-group-name> `
        --image <your-docker-username>/<your-imagename>:<your-version>

  • During the "create" phase you included environment variables. You do not need to include them again.

  • View your live Azure Container App.
    https://portal.azure.com/#browse/Microsoft.App%2FcontainerApps​

  • "Find Owners" should be changed to "Find Schools".

  • For extra credit, set up a custom domain.

Deploy the Image to Azure

  • In ISO/IEC 12207:2008, Process 7.1.4 refers to the Software Detailed Design Process

  • The purpose of the Software Detailed Design process is to provide a detailed design for the software components, including data structures, databases, and internal interfaces, that enables coding to take place. 

  • In other words, you need to spend time planning and designing before coding.

  • In a Spring Boot application, behavior (Sequence) and data state (State Chart) diagrams are often valuable.

SDLC ISO/IEC 12207

  • Description: Create New School

  • Actor: Administrator

  • Precondition: User is logged in and viewing the School List.

  • Normal Flow:

    1. User clicks "Add School".

    2. System displays the School Creation Form.

    3. User enters required values "Name" and "Domain".

    4. System validates that the domain does not already exist.

    5. System saves the new School with status ACTIVE.

    6. System redirects user to the School Details page (or School List).

  • Alternative Flows:

    • Validation Fail: Name or Domain is empty -> System re-displays form with error "Name is required" or "Domain is required".

    • Duplicate Fail: Domain exists -> System re-displays form with error "Domain already registered".

Use Case Narrative

Sequence Diagram

sequenceDiagram
    autonumber
    actor User as School Admin
    participant Browser
    participant Controller as SchoolController
    participant Repo as SchoolRepository
    participant View as Thymeleaf<br>Template Engine

    Note over User, View: Phase 1: Requesting the Form

    User->>Browser: Click "Add School" button
    Browser->>Controller: GET /schools/new
    activate Controller
    Controller-->>Controller: new School()
    Controller->>View: return "schools/createOrUpdateSchoolForm"
    deactivate Controller
    View-->>Browser: Render HTML Form

    Note over User, View: Phase 2: Submitting the Data

    User->>Browser: Fill out and submit form
    Browser->>Controller: POST /schools/new
    activate Controller
    Controller->>Controller: Validate form
    alt Validation Failed
        Controller->>View: return "schools/createOrUpdateSchoolForm"
        View-->>Browser: Render HTML Form with Error Messages
    else Validation Passed
        Controller->>Repo: save(school)
        activate Repo
        Repo-->>Controller: School Entity Saved
        deactivate Repo
        Controller-->>Browser: Redirect to "/schools"
    end
    deactivate Controller

    Browser->>Controller: GET /schools (Redirect)
    activate Controller
    Controller->>Repo: findAll(Pageable)
    activate Repo
    Repo-->>Controller: Page<School>
    deactivate Repo
    Controller->>View: return "schools/schoolList"
    deactivate Controller
    View-->>Browser: Render HTML Updated School List
  • A State Chart Diagram models the lifecycle of a single object.

  • It shows every possible "state" the object can be in and exactly what events cause it to move from one state to another.

  • New School: The user typed data in the form, no ID is set yet.

  • Active School: The normal state. The school is visible.

  • Inactive/Suspended School: The school exists in the system but is flagged (e.g., stopped paying for a subscription or closed).

  • Deleted School: The school was "removed" from the database with a deleted_at timestamp. It is hidden from queries.

State Chart Diagram

stateDiagram
    [*] --> New : User clicks "Add School"
    
    New --> Active : save() [Default]
    
    state "Persisted (In Database)" as Persisted {
        
        state "Visible / Live" as Live {
            Active --> Inactive : Set Status = INACTIVE
            Inactive --> Active : Set Status = ACTIVE
            
            Active --> Suspended : Set Status = SUSPENDED
            Suspended --> Active : Set Status = ACTIVE
        }
        
        Live --> Deleted : delete() (Set deleted_at = NOW)
        
        state "Soft Deleted" as Deleted {
             [*] --> Hidden
             note right of Hidden : Excluded from default queries \n(@SQLRestriction)
        }
        
        Deleted --> Live : Restore (Set deleted_at = NULL)
    }
  • When using Microsoft OneDrive or a cloud storage, if you new unit tests, you will likely get an error: "Caused by: java.nio.file.AccessDeniedException".

  • Build tools like Gradle create, modify, and delete thousands of temporary files (inside build/) in milliseconds.

    • When Gradle creates a file, OneDrive notices the new file and immediately tries to lock it to upload it to the cloud, Gradle tries to write to or rename that file a millisecond later, and Gradle is denied access because OneDrive is holding the lock.

  • Option 1: Move the project to a non-synced folder to avoid the syncing process fighting with the compiler.

  • Option 2: Pause Sync. Click the OneDrive icon in your taskbar. Select Pause Syncing > 2 hours.

    • In IntelliJ, run Clean (Gradle tab > Tasks > build > clean).

  • Run your tests again.

Cloud Storage Problem

  • Following a Test-Driven Development (TDD) approach, we can write the tests before the implementation of any code.

  • Add these import statements.
    import org.junit.jupiter.api.DisplayName;​
    import static org.mockito.Mockito.verify;

    import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;

  • Add following new test methods inside the class.

SchoolControllerTest

@Test
@DisplayName("User clicks \"Add School\" -> GET /schools/new")
void testInitCreationForm() throws Exception {
	mockMvc.perform(get("/schools/new"))
		.andExpect(status().isOk())
		.andExpect(view().name("schools/createOrUpdateSchoolForm"))
		.andExpect(model().attributeExists("school"));
}
  • Add these new test methods inside the class.

SchoolControllerTest

@Test
@DisplayName("Validation Passed -> verify that the controller tells the repository to save() the school and then redirects us.")
void testProcessCreationFormSuccess() throws Exception {
	mockMvc.perform(post("/schools/new")
			.param("name", "University of Iowa")
			.param("domain", "uiowa.edu"))
		.andExpect(status().is3xxRedirection())
		.andExpect(redirectedUrl("/schools"));

	// Verify that the repository.save() method was actually called
	verify(schools).save(any(School.class));
}

@Test
@DisplayName("Validation Failed -> send an empty domain and ensure the controller returns us to the form instead of saving.")
void testProcessCreationFormHasErrors() throws Exception {
	mockMvc.perform(post("/schools/new")
			.param("name", "Bad School")
			.param("domain", "")) // Empty domain should trigger @NotEmpty
		.andExpect(status().isOk()) // 200 OK because we are re-rendering the form, not redirecting
		.andExpect(model().attributeHasErrors("school"))
		.andExpect(model().attributeHasFieldErrors("school", "domain"))
		.andExpect(view().name("schools/createOrUpdateSchoolForm"));
}

If you run SchoolControllerTest, it should fail: 404 Not Found (because /schools/new is not mapped in the controller yet).

  • In ISO 12207, Process 7.1.5 refers to translating the logic defined in your Sequence Diagram directly into Java syntax.

  • Open the SchoolController.java and add the following code.

  • Add these imports for the validation logic and the binding result (which holds validation errors).
    import jakarta.validation.Valid;
    import org.springframework.validation.BindingResult;
    import org.springframework.web.bind.annotation.GetMapping;
    import org.springframework.web.bind.annotation.PostMapping;

SchoolController Get and Post

@GetMapping("/schools/new")
public String initCreationForm(Map<String, Object> model) {
	// Phase 1 of Sequence Diagram
    // 1. Create the blank object (State: New)
	School school = new School();
	// 2. Add it to the model so the Thymeleaf form can bind data to it
	model.put("school", school);
	// 3. Return the view
	return "schools/createOrUpdateSchoolForm";
}

@PostMapping("/schools/new")
public String processCreationForm(@Valid School school, BindingResult result) {
	// Phase 2 of Sequence Diagram
	// 1. Check Validation
	if (result.hasErrors()) {
		// Validation Failed: Return to the form to show errors
		return "schools/createOrUpdateSchoolForm";
	}
	// 2. Save Data (Validation Passed)
	// Note: The status defaults to ACTIVE because of your School.java definition
	schoolRepository.save(school);
	// 3. Redirect to the list
	return "redirect:/schools";
}
  • If you try to run the tests or the program, it will crash because we haven't created the HTML Template yet.

  • Create a file called "createOrUpdateSchoolForm.html" in the "src/main/resources/templates/schools/" folder.

  • In the Spring PetClinic architecture, forms rely on a reusable fragment called inputField to handle the labels, inputs, and error messages automatically to keep your form code very clean.

HTML Template

<html xmlns:th="https://www.thymeleaf.org"
      th:replace="~{fragments/layout :: layout (~{::body},'schools')}">

<body>

<h2 th:text="#{addSchool}">Add School</h2>

<form th:object="${school}" class="form-horizontal" id="add-school-form" method="post">

  <input th:replace="~{fragments/inputField :: input ('name', #{label.school.name}, 'text')}" />

  <input th:replace="~{fragments/inputField :: input ('domain', #{label.school.domain}, 'text')}" />

  <div class="form-group">
    <div class="col-sm-offset-2 col-sm-10">
      <button class="btn btn-primary" type="submit" th:text="#{addSchool}">Add School</button>
    </div>
  </div>

</form>

</body>
</html>
  • th:object="${school}": This binds the entire form to the School object we created in the Controller's initCreationForm method.

  • th:replace="~{fragments/inputField ...}": Instead of writing 10 lines of HTML for every input (label, input box, validation error span), we call this helper file.

    • It automatically checks bindingResult for errors.

    • It highlights the box red if validation fails.

    • It preserves the user's input if the page reloads due to an error.

Thymeleaf Features

  • By default, you will see a generic message like "must not be empty".

  • If you want to customize this (e.g., "School Name is required"), you can define them in your messages.properties file.

  • Spring looks for keys in the format: Annotation.objectName.fieldName

  • The NamedEntity class uses @NotBlank instead of @NotEmpty.

Custom Error Messages

label.school.name=Name
label.school.domain=Domain
NotBlank.school.name=School Name is required
NotEmpty.school.domain=Domain Name is required
label.school.name=Nombre
label.school.domain=Dominio
NotBlank.school.name=El nombre de la escuela es obligatorio
NotEmpty.school.domain=El nombre del dominio es obligatorio
label.school.name=이름
label.school.domain=도메인
NotBlank.school.name=학교 이름은 필수입니다
NotEmpty.school.domain=도메인 이름은 필수입니다
  • Update the contents of fragments/inputField.html to display red error messages.

  • Restart the application. Changes to fragments often need a full restart to clear the cache.

inputField.html

<!DOCTYPE html>

<html xmlns:th="https://www.thymeleaf.org">
<body>
<th:block th:fragment="input (name, label, type)">

  <div class="mb-3"> <label th:for="${name}" class="form-label" th:text="${label}">Label</label>

    <div class="position-relative"> <div th:switch="${type}">
      <input th:case="'text'"
             th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
             type="text" th:field="*{__${name}__}" />

      <input th:case="'date'"
             th:class="${#fields.hasErrors(name)} ? 'form-control is-invalid' : 'form-control'"
             type="date" th:field="*{__${name}__}" />
    </div>

      <span th:if="${#fields.hasErrors(name)}"
            class="fa fa-remove position-absolute text-danger"
            style="top: 10px; right: 10px;"
            aria-hidden="true"></span>

      <div th:if="${#fields.hasErrors(name)}" class="invalid-feedback" style="display:block">
        <span th:errors="*{__${name}__}">Error Message</span>
      </div>

    </div>
  </div>
</th:block>
</body>
</html>
  • We need to add a link on the schoolList.html page so users can actually find this new form.

  • Open src/main/resources/templates/schools/schoolList.html and add this button code right below your <h2> and above your <table>:

The "Add School" Button

<a th:href="@{/schools/new}" class="btn btn-primary" style="margin-bottom: 15px;">
  <span class="fa fa-plus"></span>
  <span th:text="#{addSchool}">Add School</span>
</a>
  • Now you can run the tests and app.

  • Click "Find Schools," click "Add School," and try creating a new entry. It should save and redirect you back to the list.

  • Validation occurs, but no error or success messages display. We'll add that next.

  • Your Controller checks for errors (if (result.hasErrors())),

  • Your School entity must define what an "error" actually is.

  • You need to explicitly tell Java that the name and domain fields are Required with @NotEmpty annotations.

  • Browser: You submit an empty form.

  • Controller: Spring sees @Valid School school. It looks at the School class.

  • Validator: It sees @NotEmpty on the name field. It checks the data. It sees it is empty.

  • Result: It flags a field error on "name" or "domain".

  • Controller Logic: result.hasErrors() now returns true.

  • Thymeleaf: The inputField fragment sees the error and automatically renders the red styling and the error message.

Data Validation

  • On the add school page, add a button to go back to the list of schools.

  • Validate .edu domain

  • Sort and filter the list of schools

  • update and delete a school

  • language toggle

  • Use local storage to remember language toggle.

Next

  • Owner.java:

    • Relationships: Has a One-to-Many relationship with Pet. It manages this list with methods like addPet() and getPet().

    • Validation: Includes constraints like @Pattern(regexp = "\\d{10}") to ensure telephone numbers are exactly 10 digits.

  • Pet.java:

    • Relationships:

      • One-to-Many with Visit (a pet can have multiple doctor visits).
      • Many-to-One with PetType (a pet belongs to one species).

    • Logic: It automatically sorts visits by date using @OrderBy("date ASC").

Relationships, Validation, Logic

  • OwnerRepository.java: The primary way to access owner data.

    • Key Feature: It extends JpaRepository and adds a custom search method: findByLastNameStartingWith, which supports the "Find Owner" search bar feature.

  • PetTypeRepository.java: Provides a list of all valid animal types (findPetTypes), ordered by name, which is used to populate dropdown menus in the UI.

Repository Layer (The Database Access)

  • OwnerController.java: Manages the high-level Owner operations.

    • Routes: Handles listing owners (/owners), searching (/owners/find), and creating/editing owner profiles (/owners/new, /owners/{ownerId}/edit).

  • Note the use of @GetMapping for GET requests and @PostMapping for POST requests.

  • One bug I discovered is

Controller Layer (The HTTP Request Handling)

  • PetController.java: Manages the "sub-resource" of adding or editing a pet for a specific owner.

    • Routes: It operates under the /owners/{ownerId} path. For example, creating a new pet is handled at /owners/{ownerId}/pets/new.

  • VisitController.java: Manages the creation of new visits.

    • Routes: This is deeply nested: /owners/{ownerId}/pets/{petId}/visits/new. It ensures that before a visit is created, the system validates that the Pet and Owner actually exist.

Controller Layer (The HTTP Request Handling)

  • PetValidator.java: A custom validator that ensures a Pet has a name, a birthdate, and a type. It is manually applied in the PetController.

  • PetTypeFormatter.java: Helps Spring MVC convert the string "Cat" from a web form into the actual PetType Java object, and vice versa.

Support Classes (Validation & Formatting)

  • It is possible to replace Thymeleaf with a frontend JavaScript framework like React, Angular, or Vue.

  • This involves shifting your application from a Server-Side Rendered (SSR) architecture to a Client-Side Rendered (CSR) or Single Page Application (SPA) architecture.

  • Here is how that transition works:

    • Thymeleaf: The Spring Boot server generates the full HTML page. It merges the data (e.g., a list of Vets) with the template (vetList.html) and sends the finished HTML to the browser.

    • React/Angular/Vue: The Spring Boot server becomes a REST API. It sends only the raw data (usually in JSON format). The JavaScript framework running in the browser receives that JSON and builds the HTML dynamically.

Frontend JavaScript Frameworks

  • A benefit of the layered architecture (Controller > Service > Repository) is that you do not need to change your Database or Repository layers—you only need to modify the Controller layer.

  • If you look at VetController.java, the application already has an endpoint ready for a JavaScript framework to use:

Frontend JavaScript Frameworks

// This method is for Thymeleaf (Returns a View)
@GetMapping("/vets.html")
public String showVetList(...) { ... }

// This method is for External Clients/JS Frameworks (Returns JSON/XML)
@GetMapping({ "/vets" })
public @ResponseBody Vets showResourcesVetList() {
    Vets vets = new Vets();
    vets.getVetList().addAll(this.vetRepository.findAll());
    return vets;
}
  • The @ResponseBody annotation tells Spring: "Do not look for a Thymeleaf template. Just take this Java object, convert it to JSON, and send it back."

  • A React or Angular app would make a fetch('/vets') call to this URL, receive the list of doctors, and render the table itself.

  • To fully replace Thymeleaf, you would:

  • Update Controllers: Change your @Controller classes to @RestController (which automatically applies @ResponseBody to every method).

  • Return Data, Not Strings: Instead of returning strings like "owners/createOrUpdateOwnerForm", your methods would return Owner objects or ResponseEntity objects.

  • Delete Templates: You would eventually delete the src/main/resources/templates folder since the Java app no longer generates HTML.

  • Frontend Build: You would build your React/Angular app separately. You can then either run it on a separate server (like Node.js) that talks to your Spring Boot API, or package the built JavaScript files into the Spring Boot static folder to serve them together.

Frontend JavaScript Frameworks

  • the src/test folder contains the automated tests that ensure the application works correctly. They are organized to test different "layers" of the application, from simple logic to full database integrations.

  • These files test start up the full Spring Boot application context to verify that all components work together.

  • PetClinicIntegrationTests.java: The main integration test suite that checks standard application flows.

  • MySqlIntegrationTests.java and PostgresIntegrationTests.java: These are specialized tests that verify the application works correctly when connected to real MySQL or PostgreSQL databases (often using Testcontainers) instead of the default in-memory H2 database.

Integration Tests

  • Located in subpackages like owner and vet, these tests use @WebMvcTest. They focus only on the URL routing and HTML generation. They "mock" (fake) the service layer so they can test the UI without needing a real database.

  • OwnerControllerTests.java: Verifies that URLs like /owners/new return the correct view and that form submissions work.

Controller Tests (The Web Layer)

  • These test individual classes in isolation.

  • PetValidatorTests.java: Checks specifically that the PetValidator correctly identifies invalid inputs (like a missing name).

  • VetTests.java: Tests the internal logic of the Vet entity, such as serialization.

    • Serialization is the process of converting an object's state into a sequence of bytes, which can then be saved to a file, stored in a database, or transmitted over a network.

    • The reverse process, converting the byte stream back into a live object, is called deserialization.

  • PetTypeFormatterTests.java: Ensures the string-to-object conversion for Pet Types functions as expected.

Unit Tests (Specific Logic)

  • CrashControllerTests.java: specifically tests that the "Error" page triggers correctly.

  • I18nPropertiesSyncTest.java: A quality assurance test that ensures your translation files (like Spanish and English) stay in sync and don't have missing keys.

  • EntityUtils.java: Not a test itself, but a helper class used by other tests to look up objects in collections.

System & Utility Tests

  • This is the Maven configuration file. It tells Java exactly which third-party libraries (dependencies) your project needs to run.

  • Parent POM: You will see a <parent> tag for spring-boot-starter-parent. This is the "magic" that manages versions for you. You don't need to specify the version for every single library because the parent knows which versions work together.

  • Starters: Notice dependencies like spring-boot-starter-web, spring-boot-starter-data-jpa, and spring-boot-starter-thymeleaf. These are pre-packaged bundles. For example, starter-web automatically gives you Tomcat (server), Spring MVC (framework), and Jackson (JSON parser) in one go.

  • Java Version: It explicitly sets the Java version to 17 (<java.version>17</java.version>).

pom.xml

  • Located in src/main/resources/, this is where you configure the application's behavior without changing Java code.

  • Database Settings: The line database=h2 tells the app to use the H2 in-memory database by default. This is why you don't need to install MySQL to run the demo; the database is created in RAM when you start the app and destroyed when you stop it.

  • Database Initialization: It points to schema.sql (to create tables) and data.sql (to load dummy data).

  • Logging: You can control how much info appears in the console. For example, logging.level.org.springframework=INFO.

application.properties

  • This is the file with the public static void main method that launches the app.

  • @SpringBootApplication: This single annotation actually triggers three powerful features:

    1. Configuration: It marks the class as a source of bean definitions.

    2. EnableAutoConfiguration: It tells Spring Boot to configure your app based on the libraries in your pom.xml 

    3. ComponentScan: It tells Spring to scan the current package (and all sub-packages) for your Controllers, Services, and Repositories.

PetClinicApplication.java

  • While application.properties is good for simple key-value pairs, complex configuration is done in Java classes marked with @Configuration.

    1. @EnableCaching: This annotation turns on Spring's caching ability.

    2. @Bean: The method petclinicCacheConfigurationCustomizer() is marked with @Bean. This tells Spring: "Execute this method and manage the object it returns.". It is used here to set up a specific cache for "vets" so the database doesn't have to be queried every time the user refreshes the veterinarian list.

CacheConfiguration.java

  • The term "Bean" is used in two distinct ways in this project. It can be confusing because they share the same name, but they serve different purposes.

What is a Java Bean?

  • In the comments of files like Vet.java and Owner.java, you will see them described as a "Simple JavaBean domain object".

  • A standard JavaBean is simply a Java class that follows a specific set of naming conventions so other tools can easily use it.

  • Private Properties: Data fields are hidden (e.g., private String address; in Owner.java).

  • Public Getters/Setters: The data is accessed via public methods named get... and set... (e.g., getAddress() and setAddress()).

  • In your project: Classes like Vet, Owner, Pet, and Visit are standard JavaBeans. They are just containers for your data.

  • No-Argument Constructor: It usually has a default constructor so frameworks can create an empty instance of it easily.

  • JavaBean is a pattern for writing data classes (like Vet or Owner).

 The "JavaBean" (Data Standard)

  • In CacheConfiguration.java and WebConfiguration.java, you saw the @Bean annotation.

  • A Spring Bean is an object that is created, configured, and managed by the Spring Framework itself (the "Spring Container").

  • Managed Lifecycle: You do not create these objects with new (e.g., new VetController()). Instead, Spring creates them for you at startup.

  • Dependency Injection: Spring "injects" these beans into other beans that need them. For example, VetRepository is a Spring Bean that is automatically injected into the VetController.

  • Spring Bean is a specific object living in the Spring system (like VetController or the CacheManager).

The "Spring Bean" (Managed Component)

  • GitHub Actions works as your "robot butler." Instead of you manually running gradle build, docker build, and docker push on your laptop, GitHub does it automatically every time you push code.

    Here is the workflow:

  • You push code to your GitHub repository.

  • GitHub Actions wakes up, spins up a temporary server (runner).

  • It builds your jar using Gradle.

  • It logs in to Docker Hub (using secrets you provide).

  • It builds and pushes your Docker image.

GitHub Actions

  • You should never put your Docker Hub password directly in the file. You use GitHub Secrets.

  • Go to your GitHub repository.

  • Click Settings -> Secrets and variables -> Actions.

  • Click New repository secret.

  • Add these two:

    • Name: DOCKERHUB_USERNAME

      • Value: your_actual_username

    • Name: DOCKERHUB_TOKEN

      • Value: (Go to Docker Hub -> Account Settings -> Security -> New Access Token. Use this token instead of your real password.)

Set up Secrets

  • In your project, create this directory structure and file: .github/workflows/deploy.yml

  • Paste this content in. I have customized it for Gradle and Java 17.

Create the Workflow File

name: Build and Push Docker Image

on:
  push:
    branches: [ "main" ]

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
    # 1. Download your code
    - name: Checkout code
      uses: actions/checkout@v4

    # 2. Set up Java 17 (Same as your local environment)
    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin' # or 'microsoft' if you prefer

    # 3. Build the JAR file with Gradle
    # We skip tests here to speed it up, but you can remove '-x test' to be safer
    - name: Build with Gradle
      run: ./gradlew clean bootJar -x test -x jar

    # 4. Set up Docker Buildx (required for modern Docker builds)
    - name: Set up Docker Buildx
      uses: docker/setup-buildx-action@v3

    # 5. Log in to Docker Hub
    - name: Log in to Docker Hub
      uses: docker/login-action@v3
      with:
        username: ${{ secrets.DOCKERHUB_USERNAME }}
        password: ${{ secrets.DOCKERHUB_TOKEN }}

    # 6. Build and Push the Docker image
    - name: Build and push
      uses: docker/build-push-action@v5
      with:
        context: .
        push: true
        tags: ${{ secrets.DOCKERHUB_USERNAME }}/petclinic:latest
  • Commit this file and push it to GitHub.
    git add .
    git commit -m "Add GitHub Action workflow"
    git push

  • Go to the "Actions" tab in your GitHub repository. You will see the workflow running. When it turns green, check Docker Hub—your image will have been freshly updated!

Trigger it

  • Right now, GitHub pushes the image, but Azure doesn't know about it yet. You have two choices:

  • Continuous Deployment (CD) in Azure:

    • Go to your Azure Container App in the portal.

    • Under Application -> Revision management (or Continuous deployment), you can enable a setting that says "Create a new revision when a new image is pushed."

    • Note: This often requires a webhook setup.

  • Add an Azure Step to the YAML:

    • You can add a final step to the GitHub Action that runs az containerapp update to tell Azure to pull the new image immediately.

  • Would you like the code snippet to force Azure to update at the end of this workflow?

How does Azure know to update?

  • X

More Create Table Statements

DROP TABLE IF EXISTS leagues;
CREATE TABLE leagues (
    id INT AUTO_INCREMENT PRIMARY KEY,
    school_id INT NOT NULL,
    location_id INT, -- Default location
    user_id INT, -- League Manager/Contact
    name VARCHAR(255) NOT NULL,
    description TEXT,
    registration_start DATETIME,
    registration_end DATETIME,
    league_start DATETIME,
    league_end DATETIME,
    is_public TINYINT DEFAULT 1,
    type ENUM('male', 'female', 'coed') NOT NULL,
    capacity INT,
    capacity_type ENUM('team', 'individual') NOT NULL,
    fee DECIMAL(6,2),
    status_id ENUM('draft', 'active', 'inactive', 'postponed', 'cancelled', 'past') DEFAULT 'draft',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_leagues_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
    CONSTRAINT fk_leagues_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
    CONSTRAINT fk_leagues_manager FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,

    -- Performance: Quickly find active leagues for a specific school
    INDEX idx_leagues_school_status (school_id, status_id)
);

DROP TABLE IF EXISTS teams;
CREATE TABLE teams (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    captain_user_id INT NOT NULL,
    name VARCHAR(255) NOT NULL,
    logo_url VARCHAR(255),
    status_id ENUM('active', 'inactive', 'suspended') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_teams_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_teams_captain FOREIGN KEY (captain_user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Performance: List all teams in a league
    INDEX idx_teams_league (league_id)
);

DROP TABLE IF EXISTS team_users;
CREATE TABLE team_users (
    id INT AUTO_INCREMENT PRIMARY KEY,
    team_id INT NOT NULL,
    user_id INT NOT NULL,
    role ENUM('member', 'captain') DEFAULT 'member',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_team_users_team FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE,
    CONSTRAINT fk_team_users_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: A user cannot join the same team twice
    UNIQUE INDEX idx_team_users_unique (team_id, user_id)
);
  • X

More Create Table Statements

DROP TABLE IF EXISTS events;
CREATE TABLE events (
    id INT AUTO_INCREMENT PRIMARY KEY,
    league_id INT NOT NULL,
    location_id INT,
    user_id INT, -- Event organizer/referee
    name VARCHAR(255) NOT NULL,
    description TEXT,
    event_start DATETIME,
    event_end DATETIME,
    status_id ENUM('draft', 'active', 'ongoing', 'postponed', 'cancelled', 'final') DEFAULT 'draft',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_events_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_events_location FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE SET NULL,
    CONSTRAINT fk_events_contact FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL,

    -- Integrity & Performance: Check for double-bookings at a location
    INDEX idx_events_location_time (location_id, event_start)
);

DROP TABLE IF EXISTS matches;
CREATE TABLE matches (
    id INT AUTO_INCREMENT PRIMARY KEY,
    event_id INT NOT NULL,
    home_team_id INT,
    away_team_id INT,
    winner_team_id INT,
    home_score INT DEFAULT 0,
    away_score INT DEFAULT 0,
    status ENUM('scheduled', 'in_progress', 'final', 'forfeit') DEFAULT 'scheduled',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_matches_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_matches_home FOREIGN KEY (home_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_away FOREIGN KEY (away_team_id) REFERENCES teams(id) ON DELETE SET NULL,
    CONSTRAINT fk_matches_winner FOREIGN KEY (winner_team_id) REFERENCES teams(id) ON DELETE SET NULL,

    -- Performance: Essential for calculating standings (W/L records)
    INDEX idx_matches_home (home_team_id),
    INDEX idx_matches_away (away_team_id)
);
  • X

More Create Table Statements

DROP TABLE IF EXISTS messages;
CREATE TABLE messages (
    id INT AUTO_INCREMENT PRIMARY KEY,
    user_id_from INT NOT NULL,
    user_id_to INT,
    league_id INT,
    event_id INT,
    parent_message_id INT, -- For threaded replies
    message VARCHAR(255) NOT NULL,
    is_flagged TINYINT DEFAULT 0,
    status_id ENUM('draft', 'active', 'hidden') DEFAULT 'active',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,

    CONSTRAINT fk_messages_from FOREIGN KEY (user_id_from) REFERENCES users(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_to FOREIGN KEY (user_id_to) REFERENCES users(id) ON DELETE SET NULL,
    CONSTRAINT fk_messages_league FOREIGN KEY (league_id) REFERENCES leagues(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_event FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
    CONSTRAINT fk_messages_parent FOREIGN KEY (parent_message_id) REFERENCES messages(id) ON DELETE CASCADE,

    -- Performance: Quickly load chat history for a league or event
    INDEX idx_messages_context (league_id, event_id)
);

DROP TABLE IF EXISTS message_reactions;
CREATE TABLE message_reactions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    message_id INT NOT NULL,
    user_id INT NOT NULL,
    reaction ENUM('like', 'dislike', 'love', 'hug', 'sad', 'angry'),
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    
    CONSTRAINT fk_reactions_message FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE,
    CONSTRAINT fk_reactions_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,

    -- Integrity: One reaction per user per message
    UNIQUE INDEX idx_reactions_unique (message_id, user_id)
);

Java 3 - Week 3

By Marc Hauschildt

Java 3 - Week 3

  • 132