Build a Therapy Center Desktop App
Build a JavaFX desktop app with Hibernate ORM, BCrypt auth, and design patterns.
Introduction
30 Second Summary
Think about how a clinic manages hundreds of patients, tracks their appointments, records payments, and keeps everything secure. Behind every scheduling system and medical database is a carefully designed application that connects all these moving parts.
In this project, you will build a complete desktop management system for the Serenity Mental Health Therapy Center using Java, Hibernate ORM, and JavaFX. You will design database entities, implement secure authentication, and create a full user interface for managing therapists, patients, therapy programs, sessions, and payments.
What You'll Build
A JavaFX desktop application backed by Hibernate ORM that manages a therapy center's complete operations through a layered architecture with Facade and Factory design patterns.
By the end of this project, you'll have:
- A Hibernate ORM database layer with 6+ entities, One-to-Many and Many-to-Many relationships, and HQL-powered search queries.
- A BCrypt-secured authentication system with role-based access control for Admin and Receptionist users.
- A complete JavaFX desktop interface with FXML screens for CRUD operations, session booking, and payment tracking.
- A layered architecture demonstrating Facade and Factory design patterns with custom exceptions and RegEx validation.
- Secret Mission: Add a statistics dashboard with revenue reports and HQL aggregate queries.
Are there any prerequisites?
You should have basic Java knowledge (classes, interfaces, collections) and MySQL installed and running on your machine. You will also need IntelliJ IDEA and JDK 21 installed.
Set Up the Maven Project and Hibernate
Before you can build the Serenity therapy center system, you need a solid foundation. Every enterprise Java application starts with a build tool that manages libraries and a framework that handles the database.
In this step, you will set up Maven (your project's build tool and dependency manager) and Hibernate ORM (the framework that lets you talk to your database using Java objects instead of raw SQL).
In this step, get ready to:
- Create a Maven project in IntelliJ IDEA with all required dependencies.
- Configure Hibernate to connect to a MySQL database.
- Test the connection by letting Hibernate auto-create a database table from a Java class.
Create a new Maven project in IntelliJ IDEA
Without an ORM like Hibernate, you would write raw SQL strings inside your Java code, manually execute them, and then map every database row back into a Java object field by field. ORM eliminates that pain by acting as a translator between your Java objects and database tables.
What is ORM?
Think of ORM (Object-Relational Mapping) as a translator between two people who speak different languages. Java speaks in "objects" (classes, fields, methods) and your database speaks in "tables" (rows, columns, SQL).
Without ORM, you write SQL manually, execute it, loop through results, and set each field yourself. With Hibernate as your ORM, you just say "save this object" and Hibernate writes the correct SQL for you.
What is Maven?
Maven is like a shopping list for your project. Instead of manually downloading JAR files from the internet and adding them to your project, you list what you need in a file called pom.xml and Maven automatically downloads everything for you.
Maven also compiles your code, runs tests, and packages your application into a deployable file.
Now let's create the project.
- In IntelliJ IDEA, click **File** in the top menu bar.
- Click **New**, then **Project**.
- In the New Project dialog, select Maven Archetype from the left panel.
- Set the Name field to serenity-therapy-system.
- Confirm the JDK dropdown shows version 21.
- For Archetype, select maven-archetype-quickstart.
- Expand Advanced Settings and set GroupId to com.serenity.
- Set ArtifactId to serenity-therapy-system.
- Click Create.
IntelliJ creates a project with a basic pom.xml file. You need to replace its contents with the full set of dependencies your project requires.
- Open pom.xml in the project root (it appears at the top of the Project panel on the left).
- Replace the entire file contents with the following:
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.serenity</groupId>
<artifactId>serenity-therapy-system</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<javafx.version>21</javafx.version>
</properties>
<dependencies>
<!-- Hibernate Core -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.6.0.Final</version>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.7.0</version>
</dependency>
<!-- jBCrypt -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<!-- JavaFX -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>${javafx.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>com.serenity.view.SerenityApp</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
What does each section of pom.xml do?
- groupId is your organization's unique identifier (like a domain name in reverse).
- artifactId is your project's name. Together with groupId, it uniquely identifies your project in the Maven ecosystem.
- properties sets project-wide values. Here you set Java 21 as the compiler target and define a reusable JavaFX version variable.
- dependencies is your shopping list. Maven downloads Hibernate, MySQL Connector, jBCrypt, and JavaFX automatically.
- build > plugins configures the JavaFX Maven plugin so you can run your app with a single command.
- Save pom.xml.
- When IntelliJ shows a popup asking to load Maven changes, click Load Maven Changes (or press Cmd+Shift+I on macOS / Ctrl+Shift+O on Windows).
IntelliJ downloads all the libraries listed in your dependencies section. You can see the progress in the bottom status bar.
Maven not downloading dependencies?
Check that your internet connection is active. Maven needs to download libraries from the internet the first time.
If IntelliJ still shows red underlines, try right-clicking the project in the Project panel, then selecting Maven > Reload Project.
Help me fix my Maven dependency download issue
Now set up the package structure that organizes your code into layers.
- In the Project panel, expand src/main/java.
- Right-click the com.serenity package (or create it if it does not exist).
- Select New > Package.
- Create the following 7 packages one at a time: com.serenity.entity, com.serenity.dao, com.serenity.service, com.serenity.controller, com.serenity.view, com.serenity.util, com.serenity.exception.
Why these 7 packages?
Each package has a single responsibility. This is called a layered architecture.
- entity holds your database-mapped Java classes.
- dao (Data Access Object) handles all direct database communication.
- service contains business logic that coordinates between DAOs.
- controller connects your UI buttons and forms to service methods.
- view holds your JavaFX application class and FXML screen files.
- util stores helper classes like your Hibernate connection manager.
- exception holds custom exception classes for clear error handling.
- Also create the folder src/main/resources if it does not already exist (right-click src/main > New > Directory > type resources).
Your project structure now has a place for every type of code you will write.
Configure Hibernate and the MySQL database
Hibernate needs an instruction manual that tells it where your database lives, what credentials to use, and how to behave. This instruction manual is a file called hibernate.cfg.xml.
First, you need a database for Hibernate to connect to.
- Open a terminal window (or the MySQL command line client).
- Log into MySQL by running:
mysql -u root -p
- Enter your MySQL root password when prompted.
- Create the database by running:
CREATE DATABASE serenity_db;
USE serenity_db;
- You should see Query OK, 1 row affected confirming the database was created.
Can't connect to MySQL?
Make sure the MySQL service is running. On macOS, check System Preferences > MySQL. On Windows, open Services and look for MySQL.
Help me connect to MySQL on my machine
Now create the Hibernate configuration file that points to this database.
- In IntelliJ, right-click the src/main/resources folder.
- Select New > File.
- Name the file hibernate.cfg.xml and press Enter.
- Paste the following configuration into the file:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.url">jdbc:mysql://localhost:3306/serenity_db</property>
<property name="hibernate.connection.username">root</property>
<property name="hibernate.connection.password">your_password</property>
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.show_sql">true</property>
<property name="hibernate.format_sql">true</property>
<mapping class="com.serenity.entity.User"/>
</session-factory>
</hibernate-configuration>
What does each property mean?
- driver_class tells Hibernate which JDBC driver to use for MySQL.
- connection.url is the address of your database. localhost:3306 means MySQL is running on your machine on port 3306.
- dialect tells Hibernate which flavor of SQL to generate. MySQLDialect auto-detects your MySQL version.
- hbm2ddl.auto=update means Hibernate will automatically create or update database tables to match your Java classes.
- show_sql=true prints every SQL statement Hibernate generates to your console, so you can see what it is doing behind the scenes.
- mapping class registers an entity class so Hibernate knows about it. You will add more mappings as you create more entities.
Replace your_password
Change your_password in the file to your actual MySQL root password. If you never set one, try leaving the value empty (just the two tags with nothing between them).
What are the other hbm2ddl.auto options?
- create drops all tables and recreates them every time you start the app. You lose all data.
- create-drop does the same as create, but also drops tables when the app shuts down.
- update adds new tables and columns without deleting existing data. Best for development.
- validate only checks that your entities match the existing tables. It never modifies the database.
Next, you need a Java class that creates and manages the connection to your database. This is your HibernateUtil class.
What is a SessionFactory?
A SessionFactory is Hibernate's main connection manager. It reads your hibernate.cfg.xml, sets up connection pooling, and creates individual Sessions when your code needs to talk to the database.
Creating a SessionFactory is expensive (it parses config, validates mappings, sets up caches). So you create exactly one and reuse it for the entire application. This is the Factory pattern in action.
- Right-click the com.serenity.util package.
- Select New > Java Class.
- Name it HibernateUtil and press Enter.
- Replace the generated class contents with the following:
package com.serenity.util;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
public class HibernateUtil {
// Single SessionFactory instance shared across the entire application
private static final SessionFactory sessionFactory = buildSessionFactory();
private static SessionFactory buildSessionFactory() {
try {
// Read hibernate.cfg.xml and build the factory
return new Configuration().configure("hibernate.cfg.xml").buildSessionFactory();
} catch (Exception ex) {
System.err.println("SessionFactory creation failed: " + ex.getMessage());
throw new ExceptionInInitializerError(ex);
}
}
// Public method to get the shared SessionFactory
public static SessionFactory getSessionFactory() {
return sessionFactory;
}
// Call this when your application shuts down
public static void shutdown() {
getSessionFactory().close();
}
}
What does this code do?
- buildSessionFactory() reads your hibernate.cfg.xml and creates the one SessionFactory your app will use.
- The static final field means the factory is created once when the class loads and never recreated.
- getSessionFactory() gives any class in your project access to the shared factory.
- shutdown() cleanly closes the database connection when your app exits.
- Save HibernateUtil.java.
Test the connection with a User entity
The best way to confirm Hibernate is working is to let it create a table from a Java class. You will create a simple User entity. If Hibernate can turn this class into a users table in MySQL, your entire setup is working correctly.
- Right-click the com.serenity.entity package.
- Select New > Java Class.
- Name it User and press Enter.
- Replace the generated class contents with the following:
package com.serenity.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", nullable = false, unique = true)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
// Required no-argument constructor for Hibernate
public User() {}
// Getters and setters
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
}
What do these annotations do?
- @Entity tells Hibernate this class maps to a database table.
- @Table(name = "users") specifies the exact table name in MySQL.
- @Id marks the primary key field.
- @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment this field for each new row.
- @Column maps a field to a specific column name and adds constraints like nullable = false (the column cannot be empty).
- Save User.java.
The User class is already registered in your hibernate.cfg.xml (the <mapping class="com.serenity.entity.User"/> line you added earlier). Now run the application to see Hibernate create the table.
- Create a temporary main class to trigger Hibernate. Right-click com.serenity and select New > Java Class.
- Name it Main.
- Add the following code to it:
package com.serenity;
import com.serenity.util.HibernateUtil;
import org.hibernate.SessionFactory;
public class Main {
public static void main(String[] args) {
System.out.println("Starting Hibernate...");
// This triggers HibernateUtil to build the SessionFactory
SessionFactory sf = HibernateUtil.getSessionFactory();
System.out.println("Hibernate started successfully!");
System.out.println("Tables created. Check your MySQL database.");
HibernateUtil.shutdown();
}
}
What does this code do?
Calling HibernateUtil.getSessionFactory() forces Hibernate to initialize. During initialization, Hibernate reads your config, connects to MySQL, and because hbm2ddl.auto=update is set, it creates any missing tables that match your entity classes.
- Right-click inside the Main.java file.
- Select Run 'Main.main()'.
In the console output at the bottom of IntelliJ, you should see Hibernate SQL statements followed by "Hibernate started successfully!" and "Tables created."
- Go back to your MySQL terminal and verify the table was created by running:
USE serenity_db;
SHOW TABLES;
You should see a users table listed in the output. Hibernate just created a database table from your Java class without you writing a single line of SQL.
Seeing a connection refused error?
Double check that the password in your hibernate.cfg.xml matches your actual MySQL root password exactly.
Make sure the database serenity_db exists. If you see "Unknown database," go back to MySQL and run the CREATE DATABASE command again.
Confirm MySQL is running on port 3306 (the default). If you changed the port during installation, update the URL in your config.
Help me debug my Hibernate connection error
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here are all the files you should have at this point.
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.serenity</groupId>
<artifactId>serenity-therapy-system</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<javafx.version>21</javafx.version>
</properties>
<dependencies>
<!-- Hibernate Core -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
<version>6.6.0.Final</version>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>9.7.0</version>
</dependency>
<!-- jBCrypt -->
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
<!-- JavaFX -->
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-controls</artifactId>
<version>${javafx.version}</version>
</dependency>
<dependency>
<groupId>org.openjfx</groupId>
<artifactId>javafx-fxml</artifactId>
<version>${javafx.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openjfx</groupId>
<artifactId>javafx-maven-plugin</artifactId>
<version>0.0.8</version>
<configuration>
<mainClass>com.serenity.view.SerenityApp</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.url">jdbc:mysql://localhost:3306/serenity_db</property>
<property name="hibernate.connection.username">root</property>
<property name="hibernate.connection.password">your_password</property>
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.show_sql">true</property>
<property name="hibernate.format_sql">true</property>
<mapping class="com.serenity.entity.User"/>
</session-factory>
</hibernate-configuration>
package com.serenity.util;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
public class HibernateUtil {
private static final SessionFactory sessionFactory = buildSessionFactory();
private static SessionFactory buildSessionFactory() {
try {
return new Configuration().configure("hibernate.cfg.xml").buildSessionFactory();
} catch (Exception ex) {
System.err.println("SessionFactory creation failed: " + ex.getMessage());
throw new ExceptionInInitializerError(ex);
}
}
public static SessionFactory getSessionFactory() {
return sessionFactory;
}
public static void shutdown() {
getSessionFactory().close();
}
}
package com.serenity.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", nullable = false, unique = true)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
public User() {}
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
}
Your project foundation is solid. Hibernate can connect to MySQL and automatically create tables from your Java classes. Next up, you will design the full set of entities that model a therapy center: therapists, patients, programs, sessions, and payments.
Design and Create All Entities
In the previous step, you set up your project and proved that Hibernate can talk to MySQL by auto-creating a table from the User entity.
Now it is time to design the actual data model for the therapy center. You need entities for therapists, patients, therapy programs, sessions, and payments. Each entity is a Java class that Hibernate maps directly to a database table using JPA annotations.
In this step, get ready to:
- Create entity classes for Therapist, TherapyProgram, and Patient with full JPA annotations.
- Create entity classes for TherapySession and Payment using enum status fields.
- Register all entities and verify Hibernate auto-creates the tables in MySQL.
Understand what makes a class a Hibernate entity
A regular Java class becomes a Hibernate entity when you add specific JPA annotations. Think of an entity as a Java class that has a "mirror image" as a database table. Each object of that class is one row, and each field is one column.
What do the key JPA annotations mean?
Here is a breakdown of the core annotations you will use on every entity:
- @Entity tells Hibernate "this class maps to a database table."
- @Table(name="...") specifies the exact table name in the database.
- @Id marks the field that serves as the primary key.
- @GeneratedValue(strategy = GenerationType.IDENTITY) tells the database to auto-increment this field.
- @Column(name="...", nullable=false) maps a field to a specific column with constraints like "cannot be null" or "must be unique."
You saw these annotations on the User entity in the previous step. Now you will apply them across five new entities, each with different field types and constraints.
- Create a new Java class called Therapist.java in the com.serenity.entity package by right-clicking the entity folder, selecting New, then Java Class.
- Copy the following code into the file:
package com.serenity.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "therapists")
public class Therapist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long therapistId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "specialization", nullable = false, length = 100)
private String specialization;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "availability_status", nullable = false)
private boolean availabilityStatus = true;
public Therapist() {}
public Long getTherapistId() { return therapistId; }
public void setTherapistId(Long therapistId) { this.therapistId = therapistId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSpecialization() { return specialization; }
public void setSpecialization(String specialization) { this.specialization = specialization; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public boolean isAvailabilityStatus() { return availabilityStatus; }
public void setAvailabilityStatus(boolean availabilityStatus) { this.availabilityStatus = availabilityStatus; }
}
What does this code do?
- @Table(name = "therapists") maps this class to a table called therapists in your database.
- length = 100 limits the column to 100 characters. This prevents someone from entering an entire essay as a name.
- unique = true on the email field means no two therapists can share the same email address.
- availabilityStatus defaults to true so new therapists are available by default.
- The empty no-argument constructor is required by Hibernate to create instances of this class via reflection.
- Save Therapist.java.
What is the Entity Lifecycle?
Every Hibernate entity object moves through four states during its life. This is a common VIVA question.
- Transient means the object was just created with new. Hibernate does not know about it yet.
- Persistent means it is attached to a Hibernate Session. Any changes you make are automatically tracked and saved.
- Detached means the Session was closed but the object still exists in memory. Changes are NOT tracked.
- Removed means the object has been marked for deletion. It will be removed from the database on the next commit.
Create the TherapyProgram and Patient entities
The TherapyProgram entity introduces two new concepts. The primary key is a manually-assigned String (formatted like MT1001) instead of an auto-incremented number. The fee field uses BigDecimal because floating-point types like double lose precision with money.
- Create a new Java class called TherapyProgram.java in the entity package and paste the following code:
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "therapy_programs")
public class TherapyProgram {
// Manually assigned ID (not auto-generated) in format MT1001
@Id
@Column(name = "program_id", length = 10)
private String programId;
@Column(name = "name", nullable = false, length = 100)
private String name;
// Duration in weeks
@Column(name = "duration", nullable = false)
private int duration;
// BigDecimal for precise money calculations
@Column(name = "fee", nullable = false, precision = 10, scale = 2)
private BigDecimal fee;
// @Lob stores large text in the database
@Lob
@Column(name = "description")
private String description;
public TherapyProgram() {}
public String getProgramId() { return programId; }
public void setProgramId(String programId) { this.programId = programId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getDuration() { return duration; }
public void setDuration(int duration) { this.duration = duration; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
What does this code do?
- Notice there is no @GeneratedValue annotation on the @Id field. You will set the program ID manually (e.g. MT1001) before saving.
- precision = 10, scale = 2 means the fee can be up to 10 digits total with 2 decimal places. This handles values like 99999999.99.
- @Lob tells Hibernate to use a large text column type (LONGTEXT in MySQL) for the description field, which can hold much more than the default 255 characters.
- Save TherapyProgram.java.
Next up is the Patient entity. This one introduces LocalDate for the registration date. Hibernate 6.6 natively supports Java 8 date/time types, so no extra annotation is needed beyond @Column.
- Create a new Java class called Patient.java in the entity package and paste the following code:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "patients")
public class Patient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long patientId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
// Hibernate maps LocalDate directly to a DATE column
@Column(name = "registration_date", nullable = false)
private LocalDate registrationDate;
// @Lob for storing lengthy medical history notes
@Lob
@Column(name = "medical_history")
private String medicalHistory;
public Patient() {}
public Long getPatientId() { return patientId; }
public void setPatientId(Long patientId) { this.patientId = patientId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDate getRegistrationDate() { return registrationDate; }
public void setRegistrationDate(LocalDate registrationDate) { this.registrationDate = registrationDate; }
public String getMedicalHistory() { return medicalHistory; }
public void setMedicalHistory(String medicalHistory) { this.medicalHistory = medicalHistory; }
}
What does this code do?
- LocalDate registrationDate stores only the date (no time component). Hibernate automatically maps this to a MySQL DATE column.
- @Lob on medicalHistory allows therapists to store detailed clinical notes that exceed normal column limits.
- The patient email is unique = true just like the therapist. The database will reject any attempt to register two patients with the same email.
- Save Patient.java.
Seeing red underlines on imports?
If IntelliJ shows errors on jakarta.persistence.* or java.time.LocalDate, your Maven dependencies may not have downloaded yet.
- Click the Maven tool window on the right side of IntelliJ, then click the refresh icon (circular arrows) to reload the project.
- Make sure your pom.xml has the hibernate-core dependency with group ID org.hibernate.orm (not the old org.hibernate).
Help me fix import errors in my Hibernate entity classes
Create the TherapySession and Payment entities with enum status fields
Sessions and payments both have a "status" field that can only hold a fixed set of values. A session is either scheduled, completed, or cancelled. A payment is either pending or completed. Java enums are perfect for this.
Why use @Enumerated(EnumType.STRING)?
Hibernate offers two ways to store enums in the database. EnumType.ORDINAL stores the position number (0, 1, 2). EnumType.STRING stores the actual name ("SCHEDULED", "COMPLETED").
Always use STRING. If you reorder your enum values later, ORDINAL breaks your existing data because the numbers no longer match the correct values.
- Create a new Java enum called SessionStatus.java in the entity package and paste this code:
package com.serenity.entity;
public enum SessionStatus {
SCHEDULED,
COMPLETED,
CANCELLED
}
- Create another enum called PaymentStatus.java in the same package:
package com.serenity.entity;
public enum PaymentStatus {
PENDING,
COMPLETED
}
What do these enums do?
These enums define the only valid values for session and payment status fields. When you use them with @Enumerated(EnumType.STRING), Hibernate stores the enum name as a string in the database column. This makes the data human-readable if you ever query the database directly.
- Now create TherapySession.java in the entity package with the following code:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalTime;
@Entity
@Table(name = "therapy_sessions")
public class TherapySession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long sessionId;
@Column(name = "session_date", nullable = false)
private LocalDate date;
@Column(name = "session_time", nullable = false)
private LocalTime time;
// Stores the enum name as a string (e.g. "SCHEDULED")
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private SessionStatus status = SessionStatus.SCHEDULED;
public TherapySession() {}
public Long getSessionId() { return sessionId; }
public void setSessionId(Long sessionId) { this.sessionId = sessionId; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public LocalTime getTime() { return time; }
public void setTime(LocalTime time) { this.time = time; }
public SessionStatus getStatus() { return status; }
public void setStatus(SessionStatus status) { this.status = status; }
}
What does this code do?
- LocalTime time stores just the time (e.g. 14:30) without a date component. Hibernate maps this to a MySQL TIME column.
- @Enumerated(EnumType.STRING) tells Hibernate to store the enum as its name ("SCHEDULED") instead of its ordinal position (0).
- The default value SessionStatus.SCHEDULED means new sessions start as scheduled unless you explicitly set a different status.
- Save TherapySession.java.
- Create Payment.java in the entity package with this code:
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long paymentId;
// BigDecimal for accurate financial calculations
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(name = "payment_date", nullable = false)
private LocalDate paymentDate;
// Enum status defaults to PENDING for new payments
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private PaymentStatus status = PaymentStatus.PENDING;
@Column(name = "payment_method", length = 50)
private String paymentMethod;
public Payment() {}
public Long getPaymentId() { return paymentId; }
public void setPaymentId(Long paymentId) { this.paymentId = paymentId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public LocalDate getPaymentDate() { return paymentDate; }
public void setPaymentDate(LocalDate paymentDate) { this.paymentDate = paymentDate; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
}
What does this code do?
- The amount field uses BigDecimal for the same reason as the program fee. Money must be stored precisely.
- PaymentStatus.PENDING is the default because a new payment record is created when a patient books. It only moves to COMPLETED after actual payment.
- paymentMethod is nullable (no nullable = false) because the payment method might not be known when the record is first created.
- Save Payment.java.
Getting a 'cannot find symbol' error on SessionStatus or PaymentStatus?
Make sure your enum files are in the exact same package as your entity classes. The package declaration at the top of each enum file must be:
package com.serenity.entity;
Help me fix 'cannot find symbol' errors with my enum classes
Register all entities and verify the tables in MySQL
Hibernate only manages entities it knows about. You need to register each new entity class in hibernate.cfg.xml so Hibernate includes them when creating tables.
- Open hibernate.cfg.xml in src/main/resources.
- Find the existing mapping line:
<mapping class="com.serenity.entity.User"/>
- Add the following five mapping lines directly below it:
<mapping class="com.serenity.entity.Therapist"/>
<mapping class="com.serenity.entity.TherapyProgram"/>
<mapping class="com.serenity.entity.Patient"/>
<mapping class="com.serenity.entity.TherapySession"/>
<mapping class="com.serenity.entity.Payment"/>
What does this do?
Each <mapping> line tells Hibernate to scan that class for JPA annotations and include it in the schema generation. Because you set hbm2ddl.auto=update, Hibernate will create any missing tables when the application starts.
- Save hibernate.cfg.xml.
- Run your application's main class. You can right-click HibernateUtil.java and add a temporary main method, or create a quick test class that calls HibernateUtil.getSessionFactory() to trigger Hibernate's startup.
In the IntelliJ console, you should see Hibernate's generated SQL statements creating each table. Look for lines like:
Hibernate: create table therapists (...)
Hibernate: create table therapy_programs (...)
Hibernate: create table patients (...)
Hibernate: create table therapy_sessions (...)
Hibernate: create table payments (...)
- Open your MySQL command line or MySQL Workbench to verify the tables exist by running:
USE serenity_db;
SHOW TABLES;
You should see all six tables listed: users, therapists, therapy_programs, patients, therapy_sessions, and payments.
Tables not appearing in MySQL?
- Check the IntelliJ console for errors. If you see Communications link failure, make sure MySQL is running.
- Verify your hibernate.cfg.xml password matches your actual MySQL root password.
- Make sure each <mapping> line uses the fully qualified class name (e.g. com.serenity.entity.Therapist) and that there are no typos.
Help me debug why Hibernate is not creating my tables
✔️ Awesome, I've got everything!
Great. Double check you have saved all your entity files and that your application ran without errors.
ⓧ I'd like to double check the full code
Here is the complete hibernate.cfg.xml with all entity mappings:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE hibernate-configuration PUBLIC
"-//Hibernate/Hibernate Configuration DTD 3.0//EN"
"http://www.hibernate.org/dtd/hibernate-configuration-3.0.dtd">
<hibernate-configuration>
<session-factory>
<property name="hibernate.connection.driver_class">com.mysql.cj.jdbc.Driver</property>
<property name="hibernate.connection.url">jdbc:mysql://localhost:3306/serenity_db</property>
<property name="hibernate.connection.username">root</property>
<property name="hibernate.connection.password">your_password</property>
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.show_sql">true</property>
<property name="hibernate.format_sql">true</property>
<mapping class="com.serenity.entity.User"/>
<mapping class="com.serenity.entity.Therapist"/>
<mapping class="com.serenity.entity.TherapyProgram"/>
<mapping class="com.serenity.entity.Patient"/>
<mapping class="com.serenity.entity.TherapySession"/>
<mapping class="com.serenity.entity.Payment"/>
</session-factory>
</hibernate-configuration>
Here is Therapist.java:
package com.serenity.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "therapists")
public class Therapist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long therapistId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "specialization", nullable = false, length = 100)
private String specialization;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "availability_status", nullable = false)
private boolean availabilityStatus = true;
public Therapist() {}
public Long getTherapistId() { return therapistId; }
public void setTherapistId(Long therapistId) { this.therapistId = therapistId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSpecialization() { return specialization; }
public void setSpecialization(String specialization) { this.specialization = specialization; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public boolean isAvailabilityStatus() { return availabilityStatus; }
public void setAvailabilityStatus(boolean availabilityStatus) { this.availabilityStatus = availabilityStatus; }
}
Here is TherapyProgram.java:
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "therapy_programs")
public class TherapyProgram {
@Id
@Column(name = "program_id", length = 10)
private String programId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "duration", nullable = false)
private int duration;
@Column(name = "fee", nullable = false, precision = 10, scale = 2)
private BigDecimal fee;
@Lob
@Column(name = "description")
private String description;
public TherapyProgram() {}
public String getProgramId() { return programId; }
public void setProgramId(String programId) { this.programId = programId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getDuration() { return duration; }
public void setDuration(int duration) { this.duration = duration; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
}
Here is Patient.java:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
@Entity
@Table(name = "patients")
public class Patient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long patientId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "registration_date", nullable = false)
private LocalDate registrationDate;
@Lob
@Column(name = "medical_history")
private String medicalHistory;
public Patient() {}
public Long getPatientId() { return patientId; }
public void setPatientId(Long patientId) { this.patientId = patientId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDate getRegistrationDate() { return registrationDate; }
public void setRegistrationDate(LocalDate registrationDate) { this.registrationDate = registrationDate; }
public String getMedicalHistory() { return medicalHistory; }
public void setMedicalHistory(String medicalHistory) { this.medicalHistory = medicalHistory; }
}
Here is SessionStatus.java:
package com.serenity.entity;
public enum SessionStatus {
SCHEDULED,
COMPLETED,
CANCELLED
}
Here is PaymentStatus.java:
package com.serenity.entity;
public enum PaymentStatus {
PENDING,
COMPLETED
}
Here is TherapySession.java:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalTime;
@Entity
@Table(name = "therapy_sessions")
public class TherapySession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long sessionId;
@Column(name = "session_date", nullable = false)
private LocalDate date;
@Column(name = "session_time", nullable = false)
private LocalTime time;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private SessionStatus status = SessionStatus.SCHEDULED;
public TherapySession() {}
public Long getSessionId() { return sessionId; }
public void setSessionId(Long sessionId) { this.sessionId = sessionId; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public LocalTime getTime() { return time; }
public void setTime(LocalTime time) { this.time = time; }
public SessionStatus getStatus() { return status; }
public void setStatus(SessionStatus status) { this.status = status; }
}
Here is Payment.java:
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long paymentId;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(name = "payment_date", nullable = false)
private LocalDate paymentDate;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private PaymentStatus status = PaymentStatus.PENDING;
@Column(name = "payment_method", length = 50)
private String paymentMethod;
public Payment() {}
public Long getPaymentId() { return paymentId; }
public void setPaymentId(Long paymentId) { this.paymentId = paymentId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public LocalDate getPaymentDate() { return paymentDate; }
public void setPaymentDate(LocalDate paymentDate) { this.paymentDate = paymentDate; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
}
You now have a complete data model for the therapy center. Hibernate created six tables from Java classes without you writing a single line of SQL. In the next step, you will connect these entities with relationships so a patient can be linked to sessions, programs, and payments.
Map Entity Relationships
Your entities exist as independent tables right now. In the real world, patients attend sessions, therapists conduct sessions, and patients enroll in programs. These connections between entities are called relationships, and Hibernate needs annotations to know how to represent them as foreign keys and junction tables in your database.
Think of it like a family. A Patient can have many TherapySessions. The patient does not carry a list of session IDs tattooed on their body. Instead, each session carries the patient's ID as a foreign key column. The session (child/many side) is the "owning side" because it physically holds the foreign key. This concept is the foundation of everything you will build in this step.
In this step, get ready to:
- Map One-to-Many relationships between Patient/Therapist and TherapySession with cascade types.
- Map Many-to-Many relationships between Patient/Therapist and TherapyProgram with junction tables and helper methods.
- Configure FetchType.LAZY, verify all foreign keys and junction tables in MySQL, and document the ER diagram.
Map One-to-Many relationships with cascade types
A One-to-Many relationship means one entity on the "one" side connects to multiple entities on the "many" side. In your system, one Patient has many TherapySessions. One Therapist also conducts many TherapySessions.
What is the owning side?
In every relationship, one side "owns" the foreign key column in the database. The owning side is always the @ManyToOne side (the child/many side). The other side uses mappedBy to say "I am NOT the owner, look at the other entity's field for the foreign key."
Think of it like a child carrying their parent's surname. The child (TherapySession) carries the parent's ID (patient_id column). The parent (Patient) just knows they have children through mappedBy.
The TherapySession entity is the "many" side of two relationships. It belongs to one Patient and one Therapist. You will add two @ManyToOne fields to TherapySession, each creating a foreign key column.
- Open TherapySession.java in the entity package.
- Find the existing field declarations (after the status field) and add the following two relationship fields:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "patient_id", nullable = false)
private Patient patient;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "therapist_id", nullable = false)
private Therapist therapist;
What do these annotations do?
- @ManyToOne tells Hibernate that many TherapySessions can belong to one Patient (or one Therapist).
- @JoinColumn(name = "patient_id") creates a foreign key column called patient_id in the therapy_sessions table.
- nullable = false means every session MUST belong to a patient and a therapist.
- Add getter and setter methods for both new fields at the bottom of the class, after the existing getters and setters:
public Patient getPatient() { return patient; }
public void setPatient(Patient patient) { this.patient = patient; }
public Therapist getTherapist() { return therapist; }
public void setTherapist(Therapist therapist) { this.therapist = therapist; }
Now you need to add the inverse side on the Patient and Therapist entities. This is the @OneToMany mapping that points back to the TherapySession fields you just created.
What are cascade types?
Cascade means "if I do something to the parent, automatically do it to the children too." Each cascade type controls one operation:
- PERSIST: Save the parent, children auto-save too.
- MERGE: Update the parent, children auto-update too.
- REMOVE: Delete the parent, children auto-delete too.
- ALL: All of the above plus REFRESH and DETACH.
You will use CascadeType.ALL on Patient's sessions (deleting a patient should delete their sessions). But on Therapist's sessions, you will only use PERSIST and MERGE. You do NOT want deleting a therapist to delete all the sessions they conducted. Those sessions still belong to patients.
- Open Patient.java in the entity package.
- Update the import section at the top of the file to include ArrayList and List:
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
- Add the following sessions list field after the medicalHistory field:
// One patient has many therapy sessions
@OneToMany(mappedBy = "patient", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<TherapySession> sessions = new ArrayList<>();
What does mappedBy mean here?
mappedBy = "patient" tells Hibernate: "I am NOT the owning side. The foreign key lives in TherapySession's field called patient." This is the field name in your TherapySession class, not the database column name.
- Add the getter and setter for sessions at the bottom of the Patient class:
public List<TherapySession> getSessions() { return sessions; }
public void setSessions(List<TherapySession> sessions) { this.sessions = sessions; }
- Now open Therapist.java in the entity package.
- Update the imports to include ArrayList and List:
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
- Add the sessions list field after the availabilityStatus field:
// One therapist conducts many sessions - only cascade PERSIST and MERGE
@OneToMany(mappedBy = "therapist", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
private List<TherapySession> sessions = new ArrayList<>();
Why not CascadeType.ALL on Therapist?
If you used CascadeType.ALL (which includes REMOVE), deleting a therapist would also delete all their therapy sessions. Those sessions still belong to patients and contain important medical history. You only want saves and updates to cascade, not deletions.
- Add the getter and setter for sessions at the bottom of the Therapist class:
public List<TherapySession> getSessions() { return sessions; }
public void setSessions(List<TherapySession> sessions) { this.sessions = sessions; }
- Save all three files: TherapySession.java, Patient.java, and Therapist.java.
Seeing red underlines on the imports?
Make sure your import for Patient.java uses java.util.ArrayList and java.util.List (not java.awt.List). IntelliJ sometimes auto-imports the wrong List class. Also check that the field types reference the correct entity class names.
Help me fix import errors in my Hibernate entity relationships.
Map Many-to-Many relationships with junction tables
A Many-to-Many relationship means entities on both sides can have multiple connections. Patients can enroll in multiple TherapyPrograms, and each program can have multiple patients. The database cannot represent this with a single foreign key column.
What is a junction table?
When two entities have a Many-to-Many relationship, the database creates a third table (called a junction table) with two foreign key columns. For Patient and TherapyProgram, the junction table patient_programs will have a patient_id column and a program_id column. Each row represents one enrollment.
One side must "own" the relationship by having the @JoinTable annotation. The other side uses mappedBy to point back.
You will make Patient the owning side of the Patient-Program relationship, and Therapist the owning side of the Therapist-Program relationship.
- In Patient.java, add the following Many-to-Many field after the sessions list you just created:
// Many-to-Many: patients enroll in multiple programs
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@JoinTable(
name = "patient_programs",
joinColumns = @JoinColumn(name = "patient_id"),
inverseJoinColumns = @JoinColumn(name = "program_id")
)
private List<TherapyProgram> enrolledPrograms = new ArrayList<>();
What does @JoinTable do?
- name = "patient_programs" creates a junction table called patient_programs.
- joinColumns = @JoinColumn(name = "patient_id") names the FK column for the owning entity (Patient).
- inverseJoinColumns = @JoinColumn(name = "program_id") names the FK column for the other entity (TherapyProgram).
In a Many-to-Many relationship, both sides need to stay in sync. If you add a program to a patient, the program also needs to know about that patient. Helper methods handle this for you.
- In Patient.java, add these helper methods right after the constructor (before the getters and setters):
public void addProgram(TherapyProgram program) {
enrolledPrograms.add(program);
program.getEnrolledPatients().add(this);
}
public void removeProgram(TherapyProgram program) {
enrolledPrograms.remove(program);
program.getEnrolledPatients().remove(this);
}
Why do helper methods update both sides?
Hibernate only reads the owning side (Patient) when saving to the database. But in your Java code, both sides should be consistent. If you add a program to a patient's enrolledPrograms list, the program's enrolledPatients list should also include that patient. The helper methods ensure both sides stay in sync.
- Add the getter and setter for enrolledPrograms at the bottom of Patient class (after the existing getters):
public List<TherapyProgram> getEnrolledPrograms() { return enrolledPrograms; }
public void setEnrolledPrograms(List<TherapyProgram> enrolledPrograms) { this.enrolledPrograms = enrolledPrograms; }
- Save Patient.java.
Now you will do the same for the Therapist-Program relationship. Therapists are assigned to multiple programs, and programs have multiple therapists.
- In Therapist.java, add the following Many-to-Many field and helper methods after the sessions list:
// Many-to-Many: therapists are assigned to multiple programs
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@JoinTable(
name = "therapist_programs",
joinColumns = @JoinColumn(name = "therapist_id"),
inverseJoinColumns = @JoinColumn(name = "program_id")
)
private List<TherapyProgram> assignedPrograms = new ArrayList<>();
- Add the helper methods after the Therapist constructor:
public void addProgram(TherapyProgram program) {
assignedPrograms.add(program);
program.getAssignedTherapists().add(this);
}
public void removeProgram(TherapyProgram program) {
assignedPrograms.remove(program);
program.getAssignedTherapists().remove(this);
}
- Add the getter and setter for assignedPrograms at the bottom of the Therapist class:
public List<TherapyProgram> getAssignedPrograms() { return assignedPrograms; }
public void setAssignedPrograms(List<TherapyProgram> assignedPrograms) { this.assignedPrograms = assignedPrograms; }
- Save Therapist.java.
TherapyProgram is the inverse side of both Many-to-Many relationships. It uses mappedBy to point back to the owning side's field name.
- Open TherapyProgram.java and update the imports:
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
- Add the two inverse Many-to-Many fields after the description field:
// Inverse side of Patient-Program relationship
@ManyToMany(mappedBy = "enrolledPrograms", fetch = FetchType.LAZY)
private List<Patient> enrolledPatients = new ArrayList<>();
// Inverse side of Therapist-Program relationship
@ManyToMany(mappedBy = "assignedPrograms", fetch = FetchType.LAZY)
private List<Therapist> assignedTherapists = new ArrayList<>();
What does mappedBy point to?
mappedBy = "enrolledPrograms" refers to the Java field name in the Patient class. Hibernate looks at Patient's enrolledPrograms field to find the @JoinTable configuration. Similarly, mappedBy = "assignedPrograms" refers to the Java field in the Therapist class.
- Add getters and setters for both lists at the bottom of TherapyProgram:
public List<Patient> getEnrolledPatients() { return enrolledPatients; }
public void setEnrolledPatients(List<Patient> enrolledPatients) { this.enrolledPatients = enrolledPatients; }
public List<Therapist> getAssignedTherapists() { return assignedTherapists; }
public void setAssignedTherapists(List<Therapist> assignedTherapists) { this.assignedTherapists = assignedTherapists; }
- Save TherapyProgram.java.
Getting a compile error on getEnrolledPatients()?
The helper methods in Patient.java call program.getEnrolledPatients(). If TherapyProgram does not have that getter yet, your code will not compile. Make sure you save TherapyProgram.java with the new getters before building.
Help me fix compile errors in my Many-to-Many relationship mapping.
Complete the remaining relationships and verify in MySQL
You have two more relationships to map. TherapySession belongs to a TherapyProgram (Many-to-One). Payment belongs to a Patient (Many-to-One with the inverse One-to-Many on Patient).
- In TherapySession.java, add the program relationship field after the therapist field:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "program_id", nullable = false)
private TherapyProgram program;
- Add the getter and setter for program:
public TherapyProgram getProgram() { return program; }
public void setProgram(TherapyProgram program) { this.program = program; }
- Save TherapySession.java.
- Open Payment.java and add the patient relationship field after the paymentMethod field:
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "patient_id", nullable = false)
private Patient patient;
- Add the getter and setter for patient:
public Patient getPatient() { return patient; }
public void setPatient(Patient patient) { this.patient = patient; }
- Save Payment.java.
Finally, Patient also needs the inverse @OneToMany for payments, just like you did for sessions.
- In Patient.java, add the payments list field after the sessions field (before the enrolledPrograms field):
// One patient has many payments
@OneToMany(mappedBy = "patient", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Payment> payments = new ArrayList<>();
- Add the getter and setter for payments:
public List<Payment> getPayments() { return payments; }
public void setPayments(List<Payment> payments) { this.payments = payments; }
- Save Patient.java.
What is FetchType.LAZY?
FetchType.LAZY means Hibernate only loads related entities when you actually access them. If you load a Patient, their sessions and payments are NOT loaded from the database until you call getSessions() or getPayments().
FetchType.EAGER is the opposite. It loads everything immediately. LAZY is almost always better for performance because you might never need those related entities. Loading them all upfront wastes memory and database queries.
Now run your application to let Hibernate auto-create the foreign key columns and junction tables.
- Run your application's main class (or create a simple main method in HibernateUtil.java that calls getSessionFactory() to trigger Hibernate startup).
- Watch the console output. Hibernate will print SQL ALTER TABLE and CREATE TABLE statements as it updates your schema.
- Open your MySQL command line or MySQL Workbench.
- Verify the foreign key columns by running:
DESCRIBE therapy_sessions;
- You should see patient_id, therapist_id, and program_id columns in the output.
- Verify the junction tables exist by running:
SHOW TABLES;
- You should see patient_programs and therapist_programs in the table list alongside your entity tables.
- Verify the junction table structure by running:
DESCRIBE patient_programs;
- You should see two columns: patient_id and program_id.
Tables not being created?
Check that hibernate.hbm2ddl.auto is set to update in your hibernate.cfg.xml. Also verify the console shows Hibernate SQL output. If you see an error about a mapping, check that the mappedBy value exactly matches the field name in the owning entity (it is case-sensitive).
Help me troubleshoot why Hibernate is not creating my junction tables.
✔️ Awesome, I've got everything!
Great. Double check you have saved all your entity files before moving on.
ⓧ I'd like to double check the full code
Here are the complete files for all entities with relationships:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "patients")
public class Patient {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long patientId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "registration_date", nullable = false)
private LocalDate registrationDate;
@Lob
@Column(name = "medical_history")
private String medicalHistory;
@OneToMany(mappedBy = "patient", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<TherapySession> sessions = new ArrayList<>();
@OneToMany(mappedBy = "patient", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<Payment> payments = new ArrayList<>();
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@JoinTable(
name = "patient_programs",
joinColumns = @JoinColumn(name = "patient_id"),
inverseJoinColumns = @JoinColumn(name = "program_id")
)
private List<TherapyProgram> enrolledPrograms = new ArrayList<>();
public Patient() {}
public void addProgram(TherapyProgram program) {
enrolledPrograms.add(program);
program.getEnrolledPatients().add(this);
}
public void removeProgram(TherapyProgram program) {
enrolledPrograms.remove(program);
program.getEnrolledPatients().remove(this);
}
public Long getPatientId() { return patientId; }
public void setPatientId(Long patientId) { this.patientId = patientId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public LocalDate getRegistrationDate() { return registrationDate; }
public void setRegistrationDate(LocalDate registrationDate) { this.registrationDate = registrationDate; }
public String getMedicalHistory() { return medicalHistory; }
public void setMedicalHistory(String medicalHistory) { this.medicalHistory = medicalHistory; }
public List<TherapySession> getSessions() { return sessions; }
public void setSessions(List<TherapySession> sessions) { this.sessions = sessions; }
public List<Payment> getPayments() { return payments; }
public void setPayments(List<Payment> payments) { this.payments = payments; }
public List<TherapyProgram> getEnrolledPrograms() { return enrolledPrograms; }
public void setEnrolledPrograms(List<TherapyProgram> enrolledPrograms) { this.enrolledPrograms = enrolledPrograms; }
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "therapists")
public class Therapist {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long therapistId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "specialization", nullable = false, length = 100)
private String specialization;
@Column(name = "phone", nullable = false, length = 20)
private String phone;
@Column(name = "email", nullable = false, unique = true, length = 100)
private String email;
@Column(name = "availability_status", nullable = false)
private boolean availabilityStatus = true;
@OneToMany(mappedBy = "therapist", cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
private List<TherapySession> sessions = new ArrayList<>();
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE}, fetch = FetchType.LAZY)
@JoinTable(
name = "therapist_programs",
joinColumns = @JoinColumn(name = "therapist_id"),
inverseJoinColumns = @JoinColumn(name = "program_id")
)
private List<TherapyProgram> assignedPrograms = new ArrayList<>();
public Therapist() {}
public void addProgram(TherapyProgram program) {
assignedPrograms.add(program);
program.getAssignedTherapists().add(this);
}
public void removeProgram(TherapyProgram program) {
assignedPrograms.remove(program);
program.getAssignedTherapists().remove(this);
}
public Long getTherapistId() { return therapistId; }
public void setTherapistId(Long therapistId) { this.therapistId = therapistId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getSpecialization() { return specialization; }
public void setSpecialization(String specialization) { this.specialization = specialization; }
public String getPhone() { return phone; }
public void setPhone(String phone) { this.phone = phone; }
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
public boolean isAvailabilityStatus() { return availabilityStatus; }
public void setAvailabilityStatus(boolean availabilityStatus) { this.availabilityStatus = availabilityStatus; }
public List<TherapySession> getSessions() { return sessions; }
public void setSessions(List<TherapySession> sessions) { this.sessions = sessions; }
public List<TherapyProgram> getAssignedPrograms() { return assignedPrograms; }
public void setAssignedPrograms(List<TherapyProgram> assignedPrograms) { this.assignedPrograms = assignedPrograms; }
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "therapy_programs")
public class TherapyProgram {
@Id
@Column(name = "program_id", length = 10)
private String programId;
@Column(name = "name", nullable = false, length = 100)
private String name;
@Column(name = "duration", nullable = false)
private int duration;
@Column(name = "fee", nullable = false, precision = 10, scale = 2)
private BigDecimal fee;
@Lob
@Column(name = "description")
private String description;
@ManyToMany(mappedBy = "enrolledPrograms", fetch = FetchType.LAZY)
private List<Patient> enrolledPatients = new ArrayList<>();
@ManyToMany(mappedBy = "assignedPrograms", fetch = FetchType.LAZY)
private List<Therapist> assignedTherapists = new ArrayList<>();
public TherapyProgram() {}
public String getProgramId() { return programId; }
public void setProgramId(String programId) { this.programId = programId; }
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public int getDuration() { return duration; }
public void setDuration(int duration) { this.duration = duration; }
public BigDecimal getFee() { return fee; }
public void setFee(BigDecimal fee) { this.fee = fee; }
public String getDescription() { return description; }
public void setDescription(String description) { this.description = description; }
public List<Patient> getEnrolledPatients() { return enrolledPatients; }
public void setEnrolledPatients(List<Patient> enrolledPatients) { this.enrolledPatients = enrolledPatients; }
public List<Therapist> getAssignedTherapists() { return assignedTherapists; }
public void setAssignedTherapists(List<Therapist> assignedTherapists) { this.assignedTherapists = assignedTherapists; }
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDate;
import java.time.LocalTime;
@Entity
@Table(name = "therapy_sessions")
public class TherapySession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long sessionId;
@Column(name = "session_date", nullable = false)
private LocalDate date;
@Column(name = "session_time", nullable = false)
private LocalTime time;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private SessionStatus status = SessionStatus.SCHEDULED;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "patient_id", nullable = false)
private Patient patient;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "therapist_id", nullable = false)
private Therapist therapist;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "program_id", nullable = false)
private TherapyProgram program;
public TherapySession() {}
public Long getSessionId() { return sessionId; }
public void setSessionId(Long sessionId) { this.sessionId = sessionId; }
public LocalDate getDate() { return date; }
public void setDate(LocalDate date) { this.date = date; }
public LocalTime getTime() { return time; }
public void setTime(LocalTime time) { this.time = time; }
public SessionStatus getStatus() { return status; }
public void setStatus(SessionStatus status) { this.status = status; }
public Patient getPatient() { return patient; }
public void setPatient(Patient patient) { this.patient = patient; }
public Therapist getTherapist() { return therapist; }
public void setTherapist(Therapist therapist) { this.therapist = therapist; }
public TherapyProgram getProgram() { return program; }
public void setProgram(TherapyProgram program) { this.program = program; }
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDate;
@Entity
@Table(name = "payments")
public class Payment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long paymentId;
@Column(name = "amount", nullable = false, precision = 10, scale = 2)
private BigDecimal amount;
@Column(name = "payment_date", nullable = false)
private LocalDate paymentDate;
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private PaymentStatus status = PaymentStatus.PENDING;
@Column(name = "payment_method", length = 50)
private String paymentMethod;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "patient_id", nullable = false)
private Patient patient;
public Payment() {}
public Long getPaymentId() { return paymentId; }
public void setPaymentId(Long paymentId) { this.paymentId = paymentId; }
public BigDecimal getAmount() { return amount; }
public void setAmount(BigDecimal amount) { this.amount = amount; }
public LocalDate getPaymentDate() { return paymentDate; }
public void setPaymentDate(LocalDate paymentDate) { this.paymentDate = paymentDate; }
public PaymentStatus getStatus() { return status; }
public void setStatus(PaymentStatus status) { this.status = status; }
public String getPaymentMethod() { return paymentMethod; }
public void setPaymentMethod(String paymentMethod) { this.paymentMethod = paymentMethod; }
public Patient getPatient() { return patient; }
public void setPatient(Patient patient) { this.patient = patient; }
}
Your complete ER diagram
Document these relationships in your notes or an ER diagram tool. This is a common VIVA question. Here is the summary of all relationships in your system:
- Patient (1) --- (*) TherapySession (OneToMany, CascadeType.ALL).
- Therapist (1) --- (*) TherapySession (OneToMany, CascadeType.PERSIST + MERGE).
- TherapyProgram (1) --- (*) TherapySession (OneToMany via @ManyToOne on session).
- Patient (*) --- (*) TherapyProgram (ManyToMany via patient_programs junction table).
- Therapist (*) --- (*) TherapyProgram (ManyToMany via therapist_programs junction table).
- Patient (1) --- (*) Payment (OneToMany, CascadeType.ALL).
Your entities are now fully connected. Hibernate knows exactly how every table relates to every other table through foreign keys and junction tables. Next up, you will build the DAO layer to save, update, delete, and query these entities using Hibernate Sessions and Transactions.
Build the DAO Layer
Your entities are mapped and your database tables exist. Now you need a clean way for your Java code to actually talk to the database.
Right now, if you wanted to save a Therapist, you would have to open a Session, begin a transaction, call persist, commit, handle errors, and close the session. That is a lot of boilerplate to repeat every single time you touch the database. The DAO (Data Access Object) pattern solves this by giving each entity its own dedicated class whose only job is handling database operations.
In this step, get ready to:
- Create a generic DAO interface and implement it for the Therapist entity.
- Implement DAO classes for all remaining entities with proper error handling.
- Build a DAOFactory and test the full DAO layer against your MySQL database.
Create the DAO interface
Think of a DAO class like a personal assistant for one entity type. The Therapist DAO handles only therapist database operations. The Patient DAO handles only patient operations. This keeps your code organized and testable.
Before creating individual DAOs, you will define a Java interface that declares what every DAO must be able to do. This guarantees consistency across all your DAO classes.
- In IntelliJ, right-click the com.serenity package in the Project panel on the left.
- Select New, then Package.
- Name the package com.serenity.dao and press Enter.
- Right-click the new dao package, select New then Java Class.
- Change the kind dropdown to Interface, name it DAOInterface, and press Enter.
- Replace the file contents with the following code:
package com.serenity.dao;
import java.io.Serializable;
import java.util.List;
// Generic interface that all DAO classes must implement
// T is a type parameter - it will be replaced by a specific entity (Therapist, Patient, etc.)
public interface DAOInterface<T> {
void save(T entity);
void update(T entity);
void delete(T entity);
T getById(Class<T> clazz, Serializable id);
List<T> getAll(Class<T> clazz);
}
What does this code do?
This interface uses Java generics (<T>) so that you can reuse the same contract for every entity type. Instead of writing separate interfaces for Therapist, Patient, and Payment, one generic interface covers them all.
- save(T entity) inserts a new record into the database.
- update(T entity) modifies an existing record.
- delete(T entity) removes a record.
- getById() retrieves one record by its primary key.
- getAll() retrieves every record of that entity type.
Why does getById need a Class parameter?
Hibernate's session.get() method needs to know which entity class to look up. Java generics are erased at runtime (type erasure), so the method cannot automatically determine the class from T alone. Passing the class explicitly solves this.
- Save DAOInterface.java.
Now you need to create the first concrete implementation of this interface. The TherapistDAO will show you the full pattern that every other DAO follows.
- Right-click the dao package, select New then Java Class.
- Name it TherapistDAO and press Enter.
- Replace the file contents with the following code:
package com.serenity.dao;
import com.serenity.entity.Therapist;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class TherapistDAO implements DAOInterface<Therapist> {
@Override
public void save(Therapist entity) {
Transaction transaction = null;
// try-with-resources automatically closes the session when done
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity); // INSERT into therapists table
transaction.commit();
} catch (Exception e) {
// If anything fails, undo all changes in this transaction
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Therapist entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity); // UPDATE the therapists table
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Therapist entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
// Must re-fetch to get a managed entity before removing
Therapist managed = session.get(Therapist.class, entity.getTherapistId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Therapist getById(Class<Therapist> clazz, Serializable id) {
// Read-only operations don't need a transaction
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Therapist> getAll(Class<Therapist> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Therapist", Therapist.class).list();
}
}
// Custom query: find therapists by their specialization
public List<Therapist> findBySpecialization(String specialization) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Therapist WHERE specialization = :spec", Therapist.class)
.setParameter("spec", specialization)
.list();
}
}
}
What does this code do?
Each method follows a consistent pattern. Open a Session (like making a phone call to the database), begin a Transaction (a safety net that ensures everything succeeds or everything is rolled back), perform the operation, and commit.
- session.persist() is for brand new objects that have never been saved. It runs an INSERT.
- session.merge() is for detached objects that already exist in the database. It runs an UPDATE.
- session.remove() deletes a managed entity. You must first re-fetch it with session.get() because you cannot remove a detached object.
- session.get() retrieves an entity by primary key. It returns null if the record does not exist.
The try-with-resources syntax (try (Session session = ...)) automatically calls session.close() when the block finishes, even if an exception is thrown. This replaces the need for an explicit finally block.
What is a Transaction?
A Transaction groups database operations into an all-or-nothing unit. Imagine you are transferring money between two bank accounts. You debit one account and credit the other. If the credit fails halfway through, the debit must be undone. Without a transaction, you would lose money into the void.
Calling transaction.rollback() in the catch block undoes any partial changes if something goes wrong. This prevents your database from ending up in a half-saved, corrupted state.
- Save TherapistDAO.java.
- Confirm that IntelliJ shows no red underlines in the file. All imports should resolve cleanly.
Seeing red underlines on imports?
- Make sure your import says org.hibernate.Session (not jakarta.persistence.Session or java.net.http.HttpSession).
- Verify that org.hibernate.orm:hibernate-core is in your pom.xml and that you have reloaded Maven (click the reload icon in the Maven panel).
- Check that HibernateUtil is imported from com.serenity.util and that the class exists there.
Still stuck? Help me resolve import errors in my TherapistDAO class.
What is the first-level cache?
During a single Session, Hibernate remembers every entity it has already loaded. If you call session.get(Therapist.class, 1L) twice in the same session, Hibernate hits the database only once. The second call returns the cached copy from memory.
This is called the first-level cache and it is automatic. You do not need to configure anything. Once the session closes, the cache is gone.
Implement the remaining DAO classes
Every remaining entity needs its own DAO class. They all follow the exact same pattern as TherapistDAO. The only differences are the entity type, the custom query methods at the bottom, and the primary key field used in the delete method.
- Create a new Java class called PatientDAO in the com.serenity.dao package with the following code:
package com.serenity.dao;
import com.serenity.entity.Patient;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class PatientDAO implements DAOInterface<Patient> {
@Override
public void save(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
Patient managed = session.get(Patient.class, entity.getPatientId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Patient getById(Class<Patient> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Patient> getAll(Class<Patient> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient", Patient.class).list();
}
}
// Search patients by partial name match using LIKE
public List<Patient> findByName(String name) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient WHERE name LIKE :name", Patient.class)
.setParameter("name", "%" + name + "%")
.list();
}
}
// Find a single patient by their unique email
public Patient findByEmail(String email) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient WHERE email = :email", Patient.class)
.setParameter("email", email)
.uniqueResult();
}
}
}
What do the custom query methods do?
- findByName() uses the HQL LIKE operator with % wildcards to match partial names. Searching for "Sam" would match "Samantha" and "Samuel".
- findByEmail() uses uniqueResult() instead of list() because emails are unique. It returns a single Patient or null.
Named parameters like :name and :email are placeholders that Hibernate fills in safely. This prevents SQL injection attacks because Hibernate escapes any special characters in the user input.
- Save PatientDAO.java.
Now create the remaining four DAO classes. Each one follows the same structure. The only things that change are the entity class name and the custom query methods at the bottom.
- Create a new Java class called TherapyProgramDAO in the dao package with the following code:
package com.serenity.dao;
import com.serenity.entity.TherapyProgram;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
public class TherapyProgramDAO implements DAOInterface<TherapyProgram> {
@Override
public void save(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
TherapyProgram managed = session.get(TherapyProgram.class, entity.getProgramId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public TherapyProgram getById(Class<TherapyProgram> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<TherapyProgram> getAll(Class<TherapyProgram> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapyProgram", TherapyProgram.class).list();
}
}
// Find programs within a fee range using BETWEEN
public List<TherapyProgram> findByFeeRange(BigDecimal min, BigDecimal max) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapyProgram WHERE fee BETWEEN :min AND :max", TherapyProgram.class)
.setParameter("min", min)
.setParameter("max", max)
.list();
}
}
}
What does findByFeeRange do?
The BETWEEN keyword in HQL filters records where the fee falls within a minimum and maximum value. This is useful for letting users search for programs they can afford.
- Save TherapyProgramDAO.java.
- Create a new Java class called TherapySessionDAO in the dao package with the following code:
package com.serenity.dao;
import com.serenity.entity.TherapySession;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.List;
public class TherapySessionDAO implements DAOInterface<TherapySession> {
@Override
public void save(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
TherapySession managed = session.get(TherapySession.class, entity.getSessionId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public TherapySession getById(Class<TherapySession> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<TherapySession> getAll(Class<TherapySession> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession", TherapySession.class).list();
}
}
// Find sessions within a date range
public List<TherapySession> findByDateRange(LocalDate start, LocalDate end) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession WHERE date BETWEEN :start AND :end", TherapySession.class)
.setParameter("start", start)
.setParameter("end", end)
.list();
}
}
// Find all sessions for a patient, eagerly loading therapist and program details
public List<TherapySession> findByPatient(Long patientId) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession ts JOIN FETCH ts.therapist JOIN FETCH ts.program WHERE ts.patient.patientId = :pid", TherapySession.class)
.setParameter("pid", patientId)
.list();
}
}
}
What is JOIN FETCH?
The findByPatient() method uses JOIN FETCH to load the therapist and program data in a single database query. Without it, Hibernate would use lazy loading and make separate queries each time you access session.getTherapist() or session.getProgram(). This is called the N+1 problem. It is a very common VIVA question.
- Save TherapySessionDAO.java.
- Create a new Java class called PaymentDAO in the dao package with the following code:
package com.serenity.dao;
import com.serenity.entity.Payment;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class PaymentDAO implements DAOInterface<Payment> {
@Override
public void save(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
Payment managed = session.get(Payment.class, entity.getPaymentId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Payment getById(Class<Payment> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Payment> getAll(Class<Payment> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Payment", Payment.class).list();
}
}
// Find all payments made by a specific patient
public List<Payment> findByPatient(Long patientId) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Payment WHERE patient.patientId = :pid", Payment.class)
.setParameter("pid", patientId)
.list();
}
}
}
What does this code do?
The findByPatient() query uses dot notation (patient.patientId) to navigate through the relationship. In HQL, you reference Java field names, not database column names. Hibernate translates this to a SQL JOIN behind the scenes.
- Save PaymentDAO.java.
- Create a new Java class called UserDAO in the dao package with the following code:
package com.serenity.dao;
import com.serenity.entity.User;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class UserDAO implements DAOInterface<User> {
@Override
public void save(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
User managed = session.get(User.class, entity.getUserId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public User getById(Class<User> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<User> getAll(Class<User> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM User", User.class).list();
}
}
// Find a user by their unique username (used for login)
public User findByUsername(String username) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM User WHERE username = :username", User.class)
.setParameter("username", username)
.uniqueResult();
}
}
}
What does this code do?
UserDAO is identical to the other DAOs except for its findByUsername() method. This will be essential for the login system later. When a user tries to log in, you look up their account by username, then verify their password against the stored hash.
- Save UserDAO.java.
- Verify that your dao package now contains 7 files: DAOInterface, TherapistDAO, PatientDAO, TherapyProgramDAO, TherapySessionDAO, PaymentDAO, and UserDAO.
Seeing compile errors across multiple files?
- Double check each file's package declaration says package com.serenity.dao; at the top.
- Make sure each class imports from com.serenity.entity (not a different package). If IntelliJ auto-imported the wrong class, delete the import line and press Alt+Enter on the red class name to pick the correct one.
- Confirm the entity getter methods match (e.g. entity.getTherapistId() in TherapistDAO, entity.getPatientId() in PatientDAO).
Still stuck? Help me fix compile errors in my DAO layer.
Why use session.get() instead of session.load()?
Both retrieve an entity by primary key, but they behave differently when the record does not exist.
- session.get() hits the database immediately and returns null if the record is not found. Safe and predictable.
- session.load() returns a proxy object without hitting the database. If you try to access properties on a non-existent record, it throws an ObjectNotFoundException later.
For this project, always use get(). It is simpler and gives you a clear null check instead of a delayed exception.
Create the DAOFactory and test your DAO layer
The Factory pattern is a design pattern where one class is responsible for creating objects on behalf of other classes. Instead of your application code knowing exactly which DAO class to instantiate, it asks the factory. This centralizes object creation in one place.
- Create a new Java class called DAOFactory in the com.serenity.dao package with the following code:
package com.serenity.dao;
// Factory pattern: centralizes DAO creation so the rest of the app
// never uses "new TherapistDAO()" directly
public class DAOFactory {
public static TherapistDAO getTherapistDAO() { return new TherapistDAO(); }
public static PatientDAO getPatientDAO() { return new PatientDAO(); }
public static TherapyProgramDAO getTherapyProgramDAO() { return new TherapyProgramDAO(); }
public static TherapySessionDAO getTherapySessionDAO() { return new TherapySessionDAO(); }
public static PaymentDAO getPaymentDAO() { return new PaymentDAO(); }
public static UserDAO getUserDAO() { return new UserDAO(); }
}
Why use a Factory instead of calling new directly?
Right now each method just returns new TherapistDAO(), which seems pointless. The value emerges as your project grows. If you later want to add caching, logging, or swap in a mock DAO for testing, you change ONE place (the factory) instead of hunting through every class that creates DAOs.
The static methods mean you call DAOFactory.getTherapistDAO() without needing to create a DAOFactory instance first.
- Save DAOFactory.java.
Time to verify that the entire DAO layer actually works against your MySQL database. You will write a quick test in a main method that creates, retrieves, updates, and deletes a sample record.
- Open the existing Main.java file in your project (or create one in com.serenity if it does not exist).
- Replace its contents with the following test code:
package com.serenity;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.TherapistDAO;
import com.serenity.entity.Therapist;
import com.serenity.util.HibernateUtil;
import java.util.List;
public class Main {
public static void main(String[] args) {
// Get the DAO from the factory
TherapistDAO therapistDAO = DAOFactory.getTherapistDAO();
// CREATE: save a new therapist
Therapist therapist = new Therapist();
therapist.setName("Dr. Silva");
therapist.setSpecialization("CBT");
therapist.setPhone("0771234567");
therapist.setEmail("silva@serenity.lk");
therapist.setAvailabilityStatus(true);
therapistDAO.save(therapist);
System.out.println("Saved therapist with ID: " + therapist.getTherapistId());
// READ: retrieve all therapists
List<Therapist> allTherapists = therapistDAO.getAll(Therapist.class);
System.out.println("Total therapists: " + allTherapists.size());
// UPDATE: change the therapist's specialization
therapist.setSpecialization("Cognitive Behavioral Therapy");
therapistDAO.update(therapist);
System.out.println("Updated specialization.");
// READ by ID: verify the update
Therapist fetched = therapistDAO.getById(Therapist.class, therapist.getTherapistId());
System.out.println("Fetched specialization: " + fetched.getSpecialization());
// DELETE: remove the test record
therapistDAO.delete(therapist);
System.out.println("Deleted therapist.");
// Verify deletion
Therapist deleted = therapistDAO.getById(Therapist.class, therapist.getTherapistId());
System.out.println("After delete, fetch result: " + deleted);
// Clean up Hibernate resources
HibernateUtil.shutdown();
System.out.println("DAO layer test complete!");
}
}
What does this test do?
This exercises the full CRUD cycle: Create a therapist (INSERT), Read it back (SELECT), Update its specialization (UPDATE), and Delete it (DELETE). Each operation prints a confirmation so you can verify it worked.
The final getById() after deletion should print null, confirming the record was removed from the database.
- Save Main.java.
- Right-click inside the main method and select Run 'Main.main()'.
In the IntelliJ console, you should see Hibernate's SQL statements (because show_sql=true) followed by your print statements confirming each operation succeeded. The final line should read DAO layer test complete!.
Seeing a connection error or exception?
- If you see Communications link failure, make sure your MySQL server is running. On macOS, check System Preferences > MySQL. On Windows, open Services and verify MySQL is started.
- If you see Access denied, verify the username and password in your hibernate.cfg.xml match your MySQL credentials.
- If you see ConstraintViolationException on the email field, it means a therapist with that email already exists from a previous test run. Change the email value in the test code and run again.
Need more help? Help me debug my DAO test run.
- Open MySQL in a terminal or MySQL Workbench and run:
mysql -u root -p -e "SELECT * FROM serenity_db.therapists;"
The table should be empty (since the test deleted the record), confirming that the full CRUD cycle worked end-to-end.
✔️ Awesome, I've got everything!
Great. Double check you have saved all 8 files in the dao package (including DAOFactory.java).
ⓧ I'd like to double check the full code
Here is the complete code for each DAO file. Compare against your own files to spot any differences.
package com.serenity.dao;
import java.io.Serializable;
import java.util.List;
public interface DAOInterface<T> {
void save(T entity);
void update(T entity);
void delete(T entity);
T getById(Class<T> clazz, Serializable id);
List<T> getAll(Class<T> clazz);
}
package com.serenity.dao;
import com.serenity.entity.Therapist;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class TherapistDAO implements DAOInterface<Therapist> {
@Override
public void save(Therapist entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Therapist entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Therapist entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
Therapist managed = session.get(Therapist.class, entity.getTherapistId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Therapist getById(Class<Therapist> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Therapist> getAll(Class<Therapist> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Therapist", Therapist.class).list();
}
}
public List<Therapist> findBySpecialization(String specialization) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Therapist WHERE specialization = :spec", Therapist.class)
.setParameter("spec", specialization)
.list();
}
}
}
package com.serenity.dao;
import com.serenity.entity.Patient;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class PatientDAO implements DAOInterface<Patient> {
@Override
public void save(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Patient entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
Patient managed = session.get(Patient.class, entity.getPatientId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Patient getById(Class<Patient> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Patient> getAll(Class<Patient> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient", Patient.class).list();
}
}
public List<Patient> findByName(String name) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient WHERE name LIKE :name", Patient.class)
.setParameter("name", "%" + name + "%")
.list();
}
}
public Patient findByEmail(String email) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Patient WHERE email = :email", Patient.class)
.setParameter("email", email)
.uniqueResult();
}
}
}
package com.serenity.dao;
import com.serenity.entity.TherapyProgram;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.List;
public class TherapyProgramDAO implements DAOInterface<TherapyProgram> {
@Override
public void save(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(TherapyProgram entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
TherapyProgram managed = session.get(TherapyProgram.class, entity.getProgramId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public TherapyProgram getById(Class<TherapyProgram> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<TherapyProgram> getAll(Class<TherapyProgram> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapyProgram", TherapyProgram.class).list();
}
}
public List<TherapyProgram> findByFeeRange(BigDecimal min, BigDecimal max) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapyProgram WHERE fee BETWEEN :min AND :max", TherapyProgram.class)
.setParameter("min", min)
.setParameter("max", max)
.list();
}
}
}
package com.serenity.dao;
import com.serenity.entity.TherapySession;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.time.LocalDate;
import java.util.List;
public class TherapySessionDAO implements DAOInterface<TherapySession> {
@Override
public void save(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(TherapySession entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
TherapySession managed = session.get(TherapySession.class, entity.getSessionId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public TherapySession getById(Class<TherapySession> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<TherapySession> getAll(Class<TherapySession> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession", TherapySession.class).list();
}
}
public List<TherapySession> findByDateRange(LocalDate start, LocalDate end) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession WHERE date BETWEEN :start AND :end", TherapySession.class)
.setParameter("start", start)
.setParameter("end", end)
.list();
}
}
public List<TherapySession> findByPatient(Long patientId) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM TherapySession ts JOIN FETCH ts.therapist JOIN FETCH ts.program WHERE ts.patient.patientId = :pid", TherapySession.class)
.setParameter("pid", patientId)
.list();
}
}
}
package com.serenity.dao;
import com.serenity.entity.Payment;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class PaymentDAO implements DAOInterface<Payment> {
@Override
public void save(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(Payment entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
Payment managed = session.get(Payment.class, entity.getPaymentId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public Payment getById(Class<Payment> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<Payment> getAll(Class<Payment> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Payment", Payment.class).list();
}
}
public List<Payment> findByPatient(Long patientId) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM Payment WHERE patient.patientId = :pid", Payment.class)
.setParameter("pid", patientId)
.list();
}
}
}
package com.serenity.dao;
import com.serenity.entity.User;
import com.serenity.util.HibernateUtil;
import org.hibernate.Session;
import org.hibernate.Transaction;
import java.io.Serializable;
import java.util.List;
public class UserDAO implements DAOInterface<User> {
@Override
public void save(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.persist(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void update(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
session.merge(entity);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public void delete(User entity) {
Transaction transaction = null;
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
transaction = session.beginTransaction();
User managed = session.get(User.class, entity.getUserId());
if (managed != null) session.remove(managed);
transaction.commit();
} catch (Exception e) {
if (transaction != null) transaction.rollback();
throw e;
}
}
@Override
public User getById(Class<User> clazz, Serializable id) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.get(clazz, id);
}
}
@Override
public List<User> getAll(Class<User> clazz) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM User", User.class).list();
}
}
public User findByUsername(String username) {
try (Session session = HibernateUtil.getSessionFactory().openSession()) {
return session.createQuery("FROM User WHERE username = :username", User.class)
.setParameter("username", username)
.uniqueResult();
}
}
}
package com.serenity.dao;
public class DAOFactory {
public static TherapistDAO getTherapistDAO() { return new TherapistDAO(); }
public static PatientDAO getPatientDAO() { return new PatientDAO(); }
public static TherapyProgramDAO getTherapyProgramDAO() { return new TherapyProgramDAO(); }
public static TherapySessionDAO getTherapySessionDAO() { return new TherapySessionDAO(); }
public static PaymentDAO getPaymentDAO() { return new PaymentDAO(); }
public static UserDAO getUserDAO() { return new UserDAO(); }
}
Your entire DAO layer is working. Every entity can be created, read, updated, and deleted through a clean, consistent interface. Next, you will build the Service layer on top of these DAOs to add business logic like search queries, booking validation, and multi-DAO coordination.
Build the Service Layer with HQL
Your DAOs know how to talk to the database, but right now any part of your code can call any DAO directly. In a real enterprise application, you need a layer that coordinates business logic across multiple DAOs and presents a single, simplified interface to the rest of your application.
In this step, you will learn how HQL works under the hood in your existing DAOs, then build a Service layer that uses the Facade pattern to coordinate between DAOs. Think of a hotel reception desk: you make one request to the receptionist, and behind the scenes they coordinate housekeeping, kitchen, and maintenance. You just talk to one person.
In this step, get ready to:
- Understand HQL queries, JOINs, and aggregate functions.
- Build four Service classes that coordinate business logic across DAOs.
- Create a ServiceFactory to provide service instances using the Factory pattern.
Understand HQL and how it differs from SQL
SQL talks about tables and columns. HQL talks about Java classes and fields. Hibernate translates your HQL into the correct SQL for whatever database you are using.
Your DAOs already use HQL. Open TherapistDAO.java and look at the findBySpecialization method:
// HQL: references Java class name 'Therapist' and field name 'specialization'
// NOT the database table name or column name
return session.createQuery("FROM Therapist WHERE specialization = :spec", Therapist.class)
.setParameter("spec", specialization)
.list();
HQL vs SQL side by side
In SQL, you would write: SELECT * FROM therapists WHERE specialization = 'CBT'. In HQL, you write: FROM Therapist WHERE specialization = :spec.
Named parameters like :spec are safer than string concatenation because Hibernate handles escaping for you. This prevents SQL injection attacks where a malicious user passes code through an input field.
Now open PatientDAO.java and look at the findByName method. This uses the LIKE keyword for partial matching:
// LIKE with % wildcards matches partial names
// Searching for "Sam" matches "Samantha", "Sam Jones", etc.
return session.createQuery("FROM Patient WHERE name LIKE :name", Patient.class)
.setParameter("name", "%" + name + "%")
.list();
What does the % wildcard do?
The % character matches any number of characters. Wrapping the search term with % on both sides means "find this text anywhere in the name field." This is how partial-match search works in HQL.
Explore JOIN queries and aggregate functions
When you need data from multiple related entities in a single query, HQL supports JOIN operations. Open TherapySessionDAO.java and look at the findByPatient method:
// JOIN FETCH loads related entities in a single query
// Without FETCH, accessing ts.getTherapist() later would hit the DB again
return session.createQuery(
"FROM TherapySession ts JOIN FETCH ts.therapist JOIN FETCH ts.program WHERE ts.patient.patientId = :pid",
TherapySession.class)
.setParameter("pid", patientId)
.list();
What is JOIN FETCH?
This query JOINs across three entities: TherapySession, Therapist, and TherapyProgram. The FETCH keyword tells Hibernate to load the related data immediately instead of lazily.
Without FETCH, calling session.getTherapist().getName() after closing the Hibernate session would throw a LazyInitializationException. This is the most common Hibernate error you will encounter.
HQL also supports aggregate functions for reporting. Here are the key functions you will use later for your dashboard and VIVA:
// COUNT: How many sessions does each therapist have?
// SELECT t.name, COUNT(ts) FROM TherapySession ts JOIN ts.therapist t GROUP BY t.name
// SUM: Total revenue per program
// SELECT p.name, SUM(pay.amount) FROM Payment pay JOIN pay.patient pat GROUP BY p.name
// AVG, MIN, MAX work the same way
// These aggregate queries return Object[] arrays, not entity objects
Why do aggregate queries return Object[]?
When you SELECT specific columns with COUNT or SUM, the result is no longer a full entity object. Hibernate returns each row as an Object[] array where index 0 is the first selected value, index 1 is the second, and so on.
GROUP BY works exactly like SQL. It groups rows by a column and lets you apply aggregate functions to each group. This is how you will build revenue reports in the Secret Mission.
INNER JOIN vs LEFT JOIN
INNER JOIN returns only rows where both sides have matching data. LEFT JOIN returns all rows from the left entity, even if the right side has no match (those fields come back as null).
Use INNER JOIN when you need complete data (sessions that definitely have a therapist assigned). Use LEFT JOIN when you want to include entities even if the related data is missing (all patients, even those with zero sessions).
Create the Service layer using the Facade pattern
Your DAOs handle individual database operations, but business logic often spans multiple DAOs. The Facade pattern gives the rest of your application a single, simplified entry point that hides the complexity of coordinating between DAOs.
Each Service class will use DAOFactory to obtain the DAOs it needs. Your UI controllers will talk only to Services, never directly to DAOs.
Why can't the UI talk directly to DAOs?
Separation of concerns. DAOs only know how to save, update, delete, and query. Business rules (like checking for scheduling conflicts before booking a session) do not belong in DAOs.
If your UI called DAOs directly, business logic would leak into your controllers. That makes the logic impossible to reuse and difficult to test. The Service layer keeps business rules in one place.
- In IntelliJ, right-click the com.serenity package in the Project sidebar.
- Select New, then Package.
- Name the package service and press Enter.
- Create a new Java class called TherapistService inside the com.serenity.service package by pasting the following code:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.TherapistDAO;
import com.serenity.entity.Therapist;
import java.util.List;
public class TherapistService {
// Obtain the DAO through the factory, not by creating it directly
private final TherapistDAO therapistDAO = DAOFactory.getTherapistDAO();
public void addTherapist(Therapist therapist) {
therapistDAO.save(therapist);
}
public void updateTherapist(Therapist therapist) {
therapistDAO.update(therapist);
}
public void deleteTherapist(Therapist therapist) {
therapistDAO.delete(therapist);
}
public Therapist getTherapistById(Long id) {
return therapistDAO.getById(Therapist.class, id);
}
public List<Therapist> getAllTherapists() {
return therapistDAO.getAll(Therapist.class);
}
public List<Therapist> findBySpecialization(String specialization) {
return therapistDAO.findBySpecialization(specialization);
}
}
What does this code do?
TherapistService wraps TherapistDAO with business-friendly method names like addTherapist() instead of generic save(). For now it delegates directly, but later you will add validation checks before each DAO call.
The DAO is obtained from DAOFactory rather than created with new TherapistDAO(). This lets you swap the DAO implementation later without changing the service.
- Save TherapistService.java.
- Create a new Java class called PatientService in the same com.serenity.service package with this code:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.PatientDAO;
import com.serenity.entity.Patient;
import java.util.List;
public class PatientService {
private final PatientDAO patientDAO = DAOFactory.getPatientDAO();
public void registerPatient(Patient patient) {
patientDAO.save(patient);
}
public void updatePatient(Patient patient) {
patientDAO.update(patient);
}
public void deletePatient(Patient patient) {
patientDAO.delete(patient);
}
public Patient getPatientById(Long id) {
return patientDAO.getById(Patient.class, id);
}
public List<Patient> getAllPatients() {
return patientDAO.getAll(Patient.class);
}
// Delegates to the LIKE-based HQL query in PatientDAO
public List<Patient> searchByName(String name) {
return patientDAO.findByName(name);
}
public Patient findByEmail(String email) {
return patientDAO.findByEmail(email);
}
}
What does this code do?
PatientService follows the same pattern as TherapistService. It exposes searchByName() which delegates to the HQL LIKE query you reviewed earlier. The UI will call this for real-time patient search.
- Save PatientService.java.
- Create a new Java class called BookingService in com.serenity.service with this code:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.TherapistDAO;
import com.serenity.dao.TherapySessionDAO;
import com.serenity.entity.Patient;
import com.serenity.entity.Therapist;
import com.serenity.entity.TherapyProgram;
import com.serenity.entity.TherapySession;
import java.util.List;
public class BookingService {
// This service coordinates TWO DAOs - the Facade pattern in action
private final TherapySessionDAO sessionDAO = DAOFactory.getTherapySessionDAO();
private final TherapistDAO therapistDAO = DAOFactory.getTherapistDAO();
// Coordinates setting up all relationships before saving
public void bookSession(Patient patient, Therapist therapist, TherapyProgram program, TherapySession session) {
session.setPatient(patient);
session.setTherapist(therapist);
session.setProgram(program);
sessionDAO.save(session);
}
public void updateSession(TherapySession session) {
sessionDAO.update(session);
}
public void cancelSession(TherapySession session) {
sessionDAO.delete(session);
}
public List<TherapySession> getAllSessions() {
return sessionDAO.getAll(TherapySession.class);
}
// Uses the JOIN FETCH query to load therapist and program details in one call
public List<TherapySession> getSessionsForPatient(Long patientId) {
return sessionDAO.findByPatient(patientId);
}
}
What does this code do?
BookingService is the best example of the Facade pattern in this project. The bookSession() method coordinates multiple entities (Patient, Therapist, TherapyProgram, TherapySession) and sets up all their relationships before persisting.
The caller does not need to know which DAOs are involved, how relationships are wired, or what order things happen in. They just say "book this session" and the Facade handles the rest.
- Save BookingService.java.
- Create a new Java class called PaymentService in com.serenity.service with this code:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.PaymentDAO;
import com.serenity.entity.Patient;
import com.serenity.entity.Payment;
import java.util.List;
public class PaymentService {
private final PaymentDAO paymentDAO = DAOFactory.getPaymentDAO();
public void recordPayment(Payment payment) {
paymentDAO.save(payment);
}
public void updatePayment(Payment payment) {
paymentDAO.update(payment);
}
public List<Payment> getAllPayments() {
return paymentDAO.getAll(Payment.class);
}
// Retrieves all payments linked to a specific patient
public List<Payment> getPaymentsForPatient(Long patientId) {
return paymentDAO.findByPatient(patientId);
}
}
What does this code do?
PaymentService handles recording and retrieving payments. The getPaymentsForPatient() method will be used in the UI to display a patient's payment history.
- Save PaymentService.java.
- Create one more class called ServiceFactory in com.serenity.service with this code:
package com.serenity.service;
// Factory pattern: provides service instances without exposing creation logic
public class ServiceFactory {
public static TherapistService getTherapistService() { return new TherapistService(); }
public static PatientService getPatientService() { return new PatientService(); }
public static BookingService getBookingService() { return new BookingService(); }
public static PaymentService getPaymentService() { return new PaymentService(); }
}
What does this code do?
ServiceFactory follows the same Factory pattern as DAOFactory. Your UI controllers will call ServiceFactory.getBookingService() instead of creating services manually. This gives you a single point to change if you ever need to swap service implementations.
- Save ServiceFactory.java.
- Check IntelliJ's Project sidebar. You should see five new files under com.serenity.service: TherapistService, PatientService, BookingService, PaymentService, and ServiceFactory.
Seeing red underlines in the service classes?
Check that your import statements reference the correct package. The DAOs should be in com.serenity.dao and entities in com.serenity.entity. If IntelliJ cannot resolve a class, press Alt+Enter (Windows) or Option+Enter (macOS) on the red text and select the import suggestion.
help me fix import errors in my service layer classes
To verify the service layer works, test it by calling the services from a main method.
- Open any class with a main method (or create a temporary test class) and add the following test code:
// Quick test to verify services work through the Factory
import com.serenity.service.ServiceFactory;
import com.serenity.service.TherapistService;
import com.serenity.entity.Therapist;
public class TestServices {
public static void main(String[] args) {
TherapistService therapistService = ServiceFactory.getTherapistService();
// Retrieve all therapists through the service layer
System.out.println("All therapists: " + therapistService.getAllTherapists().size());
// Search by specialization through the service layer
System.out.println("CBT therapists: " + therapistService.findBySpecialization("CBT").size());
System.out.println("Service layer is working!");
}
}
- Click the green run arrow next to the main method, or press Shift+F10 (Windows) or Ctrl+R (macOS).
- You should see Hibernate SQL queries printed in the console followed by your count outputs and the message Service layer is working!.
Getting a NullPointerException?
Make sure your MySQL server is running and that serenity_db exists. Also verify that hibernate.cfg.xml has the correct password for your MySQL root user. The HibernateUtil.getSessionFactory() call will fail silently if the connection details are wrong.
help me debug why my service layer test throws a NullPointerException
✔️ Awesome, I've got everything!
Great. Double check you have saved all five service files before moving on.
ⓧ I'd like to double check the full code
Here are all five service layer files for comparison:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.TherapistDAO;
import com.serenity.entity.Therapist;
import java.util.List;
public class TherapistService {
private final TherapistDAO therapistDAO = DAOFactory.getTherapistDAO();
public void addTherapist(Therapist therapist) {
therapistDAO.save(therapist);
}
public void updateTherapist(Therapist therapist) {
therapistDAO.update(therapist);
}
public void deleteTherapist(Therapist therapist) {
therapistDAO.delete(therapist);
}
public Therapist getTherapistById(Long id) {
return therapistDAO.getById(Therapist.class, id);
}
public List<Therapist> getAllTherapists() {
return therapistDAO.getAll(Therapist.class);
}
public List<Therapist> findBySpecialization(String specialization) {
return therapistDAO.findBySpecialization(specialization);
}
}
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.PatientDAO;
import com.serenity.entity.Patient;
import java.util.List;
public class PatientService {
private final PatientDAO patientDAO = DAOFactory.getPatientDAO();
public void registerPatient(Patient patient) {
patientDAO.save(patient);
}
public void updatePatient(Patient patient) {
patientDAO.update(patient);
}
public void deletePatient(Patient patient) {
patientDAO.delete(patient);
}
public Patient getPatientById(Long id) {
return patientDAO.getById(Patient.class, id);
}
public List<Patient> getAllPatients() {
return patientDAO.getAll(Patient.class);
}
public List<Patient> searchByName(String name) {
return patientDAO.findByName(name);
}
public Patient findByEmail(String email) {
return patientDAO.findByEmail(email);
}
}
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.TherapistDAO;
import com.serenity.dao.TherapySessionDAO;
import com.serenity.entity.Patient;
import com.serenity.entity.Therapist;
import com.serenity.entity.TherapyProgram;
import com.serenity.entity.TherapySession;
import java.util.List;
public class BookingService {
private final TherapySessionDAO sessionDAO = DAOFactory.getTherapySessionDAO();
private final TherapistDAO therapistDAO = DAOFactory.getTherapistDAO();
public void bookSession(Patient patient, Therapist therapist, TherapyProgram program, TherapySession session) {
session.setPatient(patient);
session.setTherapist(therapist);
session.setProgram(program);
sessionDAO.save(session);
}
public void updateSession(TherapySession session) {
sessionDAO.update(session);
}
public void cancelSession(TherapySession session) {
sessionDAO.delete(session);
}
public List<TherapySession> getAllSessions() {
return sessionDAO.getAll(TherapySession.class);
}
public List<TherapySession> getSessionsForPatient(Long patientId) {
return sessionDAO.findByPatient(patientId);
}
}
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.PaymentDAO;
import com.serenity.entity.Patient;
import com.serenity.entity.Payment;
import java.util.List;
public class PaymentService {
private final PaymentDAO paymentDAO = DAOFactory.getPaymentDAO();
public void recordPayment(Payment payment) {
paymentDAO.save(payment);
}
public void updatePayment(Payment payment) {
paymentDAO.update(payment);
}
public List<Payment> getAllPayments() {
return paymentDAO.getAll(Payment.class);
}
public List<Payment> getPaymentsForPatient(Long patientId) {
return paymentDAO.findByPatient(patientId);
}
}
package com.serenity.service;
public class ServiceFactory {
public static TherapistService getTherapistService() { return new TherapistService(); }
public static PatientService getPatientService() { return new PatientService(); }
public static BookingService getBookingService() { return new BookingService(); }
public static PaymentService getPaymentService() { return new PaymentService(); }
}
Your service layer is complete. Every piece of business logic now lives in one clearly defined place, and your future UI controllers will only ever talk to ServiceFactory. Next up, you will add BCrypt password security to protect user authentication.
Implement BCrypt Security and Authentication
Your service layer is complete. Therapists, patients, programs, sessions, and payments can all flow through a clean Facade pattern into your DAOs. But right now, anyone can access any operation without proving who they are.
In this step, you will build a complete authentication system using BCrypt password hashing. You will also create a session management singleton that tracks who is logged in and what they are allowed to do.
In this step, get ready to:
- Create a PasswordUtil class that hashes and verifies passwords using BCrypt.
- Enhance the User entity with roles and timestamps, then build an AuthenticationService.
- Implement a SessionContext singleton for tracking the logged-in user and their permissions.
Create the PasswordUtil class
Imagine a hacker steals your database. If passwords are stored in plaintext, every user account is instantly compromised. Even simple hashing algorithms like MD5 can be cracked in seconds using precomputed rainbow tables.
BCrypt solves both problems. It adds a random salt to each password (so identical passwords produce different hashes) and uses a work factor that makes each hashing operation deliberately slow. A work factor of 12 means each guess takes roughly 250ms instead of nanoseconds.
What does a BCrypt hash look like?
A BCrypt hash string looks like: $2a$12$LJ3m4lM3MkRFwB/ThGnCOeArGbYsCxXRnWzrQFMKH/.GBbi3KMXXC. The $2a$ identifies the algorithm. The 12 is the work factor (cost). The remaining characters contain both the salt and the hash.
Hashing the same password twice produces different results because of the random salt. But BCrypt.checkpw() extracts the salt from the stored hash and can still verify the original password.
- Create a new Java class called PasswordUtil in the com.serenity.util package by right-clicking the util package in IntelliJ, then selecting New > Java Class.
- Replace the generated content with the following code:
package com.serenity.util;
import org.mindrot.jbcrypt.BCrypt;
public class PasswordUtil {
// Hash a plaintext password with BCrypt using work factor 12
public static String hashPassword(String plaintext) {
return BCrypt.hashpw(plaintext, BCrypt.gensalt(12));
}
// Verify a candidate password against a stored BCrypt hash
public static boolean verifyPassword(String candidate, String hashed) {
return BCrypt.checkpw(candidate, hashed);
}
}
What does this code do?
- hashPassword() takes a plaintext password and returns a BCrypt hash string. BCrypt.gensalt(12) generates a random salt with work factor 12, making brute force attacks computationally expensive.
- verifyPassword() takes a candidate password and a stored hash, then returns true if they match. It extracts the salt from the hash automatically.
- Save PasswordUtil.java.
Why work factor 12?
The default work factor in jBCrypt is 10. Each increment doubles the computation time. A factor of 12 means 2^12 = 4096 iterations of the hashing algorithm. This provides a good balance between security and login speed for a desktop application.
Seeing an import error on BCrypt?
Make sure the jBCrypt dependency in your pom.xml uses group ID org.mindrot and artifact ID jbcrypt with version 0.4. Click the Maven reload button (the circular arrows icon in the Maven tool window) to re-download dependencies.
Help me fix my jBCrypt import error in IntelliJ
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
package com.serenity.util;
import org.mindrot.jbcrypt.BCrypt;
public class PasswordUtil {
public static String hashPassword(String plaintext) {
return BCrypt.hashpw(plaintext, BCrypt.gensalt(12));
}
public static boolean verifyPassword(String candidate, String hashed) {
return BCrypt.checkpw(candidate, hashed);
}
}
Enhance the User entity and create AuthenticationService
Your existing User entity only has a username and password hash. For a real authentication system, you need to know each user's role (what are they allowed to do?) and when they last logged in.
You will add a UserRole enum that defines two access levels: ADMIN and RECEPTIONIST. Then you will update the User entity and build an AuthenticationService that handles login, registration, and password changes.
- Create a new Java class in the com.serenity.entity package. Select Enum as the kind, and name it UserRole.
- Replace the generated content with this code:
package com.serenity.entity;
public enum UserRole {
ADMIN,
RECEPTIONIST
}
- Save UserRole.java.
- Now open your existing User.java file in com.serenity.entity.
- Replace the entire contents of User.java with the enhanced version below:
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
// Store enum as a String column so reordering enum values won't break data
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private UserRole role;
// Track when this user account was created
@Column(name = "created_at")
private LocalDateTime createdAt;
// Track the most recent successful login
@Column(name = "last_login")
private LocalDateTime lastLogin;
public User() {}
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getLastLogin() { return lastLogin; }
public void setLastLogin(LocalDateTime lastLogin) { this.lastLogin = lastLogin; }
}
What did we add to User?
- @Enumerated(EnumType.STRING) stores the role as a readable string ("ADMIN" or "RECEPTIONIST") in the database instead of an integer index. This is safer because reordering your enum constants in code will not corrupt existing data.
- createdAt records when the user account was created. lastLogin records the most recent successful authentication. Both use LocalDateTime which Hibernate maps to a DATETIME column automatically.
- Save User.java.
- Make sure User is registered as a mapped entity in your hibernate.cfg.xml. If you have not added it yet, add <mapping class="com.serenity.entity.User"/> alongside your other entity mappings.
Now you need a service class that coordinates login, registration, and password changes. The AuthenticationService will use PasswordUtil for all hashing operations. It will never store or compare plaintext passwords directly.
- Create a new Java class called AuthenticationService in the com.serenity.service package.
- Paste the following code:
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.UserDAO;
import com.serenity.entity.User;
import com.serenity.entity.UserRole;
import com.serenity.util.PasswordUtil;
import com.serenity.util.SessionContext;
import java.time.LocalDateTime;
public class AuthenticationService {
private final UserDAO userDAO = DAOFactory.getUserDAO();
// Authenticate a user by username and password
public User login(String username, String password) {
User user = userDAO.findByUsername(username);
if (user == null) return null;
if (!PasswordUtil.verifyPassword(password, user.getPasswordHash())) return null;
// Update last login timestamp and store user in session
user.setLastLogin(LocalDateTime.now());
userDAO.update(user);
SessionContext.getInstance().setCurrentUser(user);
return user;
}
// Register a new user with a hashed password
public void register(String username, String password, UserRole role) {
User user = new User();
user.setUsername(username);
user.setPasswordHash(PasswordUtil.hashPassword(password));
user.setRole(role);
user.setCreatedAt(LocalDateTime.now());
userDAO.save(user);
}
// Change password only if the old password is verified first
public boolean changePassword(Long userId, String oldPassword, String newPassword) {
User user = userDAO.getById(User.class, userId);
if (user == null) return false;
if (!PasswordUtil.verifyPassword(oldPassword, user.getPasswordHash())) return false;
user.setPasswordHash(PasswordUtil.hashPassword(newPassword));
userDAO.update(user);
return true;
}
// Seed a default admin user on first run if no users exist
public void seedDefaultAdmin() {
if (userDAO.getAll(User.class).isEmpty()) {
register("admin", "Admin@1234", UserRole.ADMIN);
}
}
}
What does this code do?
- login() retrieves the user by username, verifies the password using BCrypt (never with .equals()), updates the last login timestamp, and stores the user in SessionContext.
- register() hashes the password before persisting. The plaintext password is never stored anywhere.
- changePassword() requires verifying the old password before accepting the new one. This prevents unauthorized changes even if someone has access to the user's session.
- seedDefaultAdmin() creates an admin user (username: admin, password: Admin@1234) only if the users table is empty. This runs once on first startup.
- Save AuthenticationService.java.
Seeing red on DAOFactory.getUserDAO()?
Your DAOFactory class needs a getUserDAO() method that returns your existing UserDAO instance. If you have not added it yet, open DAOFactory.java and add a static method that returns new UserDAO().
Help me wire up UserDAO in my DAOFactory
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
package com.serenity.entity;
public enum UserRole {
ADMIN,
RECEPTIONIST
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private UserRole role;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "last_login")
private LocalDateTime lastLogin;
public User() {}
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getLastLogin() { return lastLogin; }
public void setLastLogin(LocalDateTime lastLogin) { this.lastLogin = lastLogin; }
}
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.UserDAO;
import com.serenity.entity.User;
import com.serenity.entity.UserRole;
import com.serenity.util.PasswordUtil;
import com.serenity.util.SessionContext;
import java.time.LocalDateTime;
public class AuthenticationService {
private final UserDAO userDAO = DAOFactory.getUserDAO();
public User login(String username, String password) {
User user = userDAO.findByUsername(username);
if (user == null) return null;
if (!PasswordUtil.verifyPassword(password, user.getPasswordHash())) return null;
user.setLastLogin(LocalDateTime.now());
userDAO.update(user);
SessionContext.getInstance().setCurrentUser(user);
return user;
}
public void register(String username, String password, UserRole role) {
User user = new User();
user.setUsername(username);
user.setPasswordHash(PasswordUtil.hashPassword(password));
user.setRole(role);
user.setCreatedAt(LocalDateTime.now());
userDAO.save(user);
}
public boolean changePassword(Long userId, String oldPassword, String newPassword) {
User user = userDAO.getById(User.class, userId);
if (user == null) return false;
if (!PasswordUtil.verifyPassword(oldPassword, user.getPasswordHash())) return false;
user.setPasswordHash(PasswordUtil.hashPassword(newPassword));
userDAO.update(user);
return true;
}
public void seedDefaultAdmin() {
if (userDAO.getAll(User.class).isEmpty()) {
register("admin", "Admin@1234", UserRole.ADMIN);
}
}
}
Create the SessionContext singleton
When a user logs in, the rest of your application needs to know who they are. The Singleton pattern gives you exactly one instance of a class that any part of your code can access. Your SessionContext will hold the currently logged-in User object.
What is the Singleton pattern?
A Singleton ensures that only one instance of a class exists in the entire application. It has a private constructor (so nobody can create a second instance) and a static getInstance() method that always returns the same object.
This is different from the Hibernate Session. The SessionContext tracks your application-level login state, not a database connection.
- Create a new Java class called SessionContext in the com.serenity.util package.
- Paste the following code:
package com.serenity.util;
import com.serenity.entity.User;
import com.serenity.entity.UserRole;
public class SessionContext {
// The single instance shared across the entire application
private static SessionContext instance;
private User currentUser;
// Private constructor prevents external instantiation
private SessionContext() {}
// Return the single instance, creating it on first call
public static SessionContext getInstance() {
if (instance == null) {
instance = new SessionContext();
}
return instance;
}
public User getCurrentUser() { return currentUser; }
public void setCurrentUser(User user) { this.currentUser = user; }
// Clear the current user on logout
public void logout() { this.currentUser = null; }
// Role-checking helpers for access control
public boolean isAdmin() { return currentUser != null && currentUser.getRole() == UserRole.ADMIN; }
public boolean isReceptionist() { return currentUser != null && currentUser.getRole() == UserRole.RECEPTIONIST; }
}
What does this code do?
- getInstance() creates the SessionContext the first time it is called, then returns the same instance on every subsequent call. This is lazy initialization.
- isAdmin() and isReceptionist() let your controllers check permissions before allowing an action. For example, only admins can manage therapists and programs.
- logout() sets the current user to null, effectively ending their session.
- Save SessionContext.java.
Now verify that everything works end-to-end. You will call seedDefaultAdmin() in your application's main method to create the default admin user, then check that the user appears in MySQL.
- In your application's main method (or a test class), add these lines to seed and test login:
// Seed the default admin user (only runs if users table is empty)
AuthenticationService authService = new AuthenticationService();
authService.seedDefaultAdmin();
// Test login with the seeded admin credentials
User admin = authService.login("admin", "Admin@1234");
if (admin != null) {
System.out.println("Login successful! Role: " + admin.getRole());
System.out.println("Is admin: " + SessionContext.getInstance().isAdmin());
} else {
System.out.println("Login failed.");
}
- Run your application.
- You should see Login successful! Role: ADMIN and Is admin: true in the console output.
- Open MySQL in your terminal and run this query to verify the admin user was persisted:
mysql -u root -p -e "SELECT user_id, username, role, created_at FROM serenity_db.users;"
- You should see one row with username admin, role ADMIN, and a created_at timestamp.
Why is the password_hash column so long?
If you run SELECT password_hash FROM serenity_db.users; you will see a 60-character BCrypt string starting with $2a$12$. This contains the algorithm identifier, work factor, salt, and hash all in one string. The original password Admin@1234 is impossible to recover from this hash.
Seeing "Login failed" in the console?
Check that your UserDAO.findByUsername() method queries the username field (the Java field name, not the column name). Also confirm that User is mapped in hibernate.cfg.xml with <mapping class="com.serenity.entity.User"/>.
If the users table existed before you added the new columns, Hibernate's hbm2ddl.auto=update should add the new columns. If it does not, try dropping the users table manually and rerunning.
Help me debug my login failure
✔️ Awesome, I've got everything!
Great. Make sure all files are saved and you can see the admin user in MySQL.
ⓧ I'd like to double check the full code
package com.serenity.util;
import org.mindrot.jbcrypt.BCrypt;
public class PasswordUtil {
public static String hashPassword(String plaintext) {
return BCrypt.hashpw(plaintext, BCrypt.gensalt(12));
}
public static boolean verifyPassword(String candidate, String hashed) {
return BCrypt.checkpw(candidate, hashed);
}
}
package com.serenity.util;
import com.serenity.entity.User;
import com.serenity.entity.UserRole;
public class SessionContext {
private static SessionContext instance;
private User currentUser;
private SessionContext() {}
public static SessionContext getInstance() {
if (instance == null) {
instance = new SessionContext();
}
return instance;
}
public User getCurrentUser() { return currentUser; }
public void setCurrentUser(User user) { this.currentUser = user; }
public void logout() { this.currentUser = null; }
public boolean isAdmin() { return currentUser != null && currentUser.getRole() == UserRole.ADMIN; }
public boolean isReceptionist() { return currentUser != null && currentUser.getRole() == UserRole.RECEPTIONIST; }
}
package com.serenity.entity;
public enum UserRole {
ADMIN,
RECEPTIONIST
}
package com.serenity.entity;
import jakarta.persistence.*;
import java.time.LocalDateTime;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long userId;
@Column(name = "username", nullable = false, unique = true, length = 50)
private String username;
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private UserRole role;
@Column(name = "created_at")
private LocalDateTime createdAt;
@Column(name = "last_login")
private LocalDateTime lastLogin;
public User() {}
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public UserRole getRole() { return role; }
public void setRole(UserRole role) { this.role = role; }
public LocalDateTime getCreatedAt() { return createdAt; }
public void setCreatedAt(LocalDateTime createdAt) { this.createdAt = createdAt; }
public LocalDateTime getLastLogin() { return lastLogin; }
public void setLastLogin(LocalDateTime lastLogin) { this.lastLogin = lastLogin; }
}
package com.serenity.service;
import com.serenity.dao.DAOFactory;
import com.serenity.dao.UserDAO;
import com.serenity.entity.User;
import com.serenity.entity.UserRole;
import com.serenity.util.PasswordUtil;
import com.serenity.util.SessionContext;
import java.time.LocalDateTime;
public class AuthenticationService {
private final UserDAO userDAO = DAOFactory.getUserDAO();
public User login(String username, String password) {
User user = userDAO.findByUsername(username);
if (user == null) return null;
if (!PasswordUtil.verifyPassword(password, user.getPasswordHash())) return null;
user.setLastLogin(LocalDateTime.now());
userDAO.update(user);
SessionContext.getInstance().setCurrentUser(user);
return user;
}
public void register(String username, String password, UserRole role) {
User user = new User();
user.setUsername(username);
user.setPasswordHash(PasswordUtil.hashPassword(password));
user.setRole(role);
user.setCreatedAt(LocalDateTime.now());
userDAO.save(user);
}
public boolean changePassword(Long userId, String oldPassword, String newPassword) {
User user = userDAO.getById(User.class, userId);
if (user == null) return false;
if (!PasswordUtil.verifyPassword(oldPassword, user.getPasswordHash())) return false;
user.setPasswordHash(PasswordUtil.hashPassword(newPassword));
userDAO.update(user);
return true;
}
public void seedDefaultAdmin() {
if (userDAO.getAll(User.class).isEmpty()) {
register("admin", "Admin@1234", UserRole.ADMIN);
}
}
}
Your authentication layer is locked in. Passwords are securely hashed, login is verified through BCrypt, and your SessionContext tracks exactly who is using the system. Next up, you will build custom exceptions and input validation to protect your application from bad data.
Add Custom Exceptions and Input Validation
Your service layer currently works, but when something goes wrong, it returns null or false with no explanation of what actually failed.
In this step, you will build a custom exception hierarchy that tells you exactly what went wrong. You will also create a validation utility class that uses RegEx patterns to catch invalid input before it ever reaches your database.
In this step, get ready to:
- Create a custom exception hierarchy with specific exception types for authentication, validation, duplicates, missing entities, and booking conflicts.
- Build a ValidationUtil class with RegEx patterns for Sri Lankan phone numbers, emails, names, passwords, and program IDs.
- Integrate validation into your service layer so invalid data is caught before it reaches the DAO.
Create the custom exception hierarchy
Generic exceptions like RuntimeException are like getting a package labeled "something went wrong." Custom exceptions give you specific labels: "authentication failed," "duplicate patient," or "booking conflict."
They let you handle each failure case differently in your code.
What is the Java exception hierarchy?
Java's exception tree starts at Throwable, which splits into Exception (checked, must be declared or caught) and RuntimeException (unchecked, no requirement to declare).
For validation failures and programming errors, unchecked exceptions are the standard choice. That is why SerenityException extends RuntimeException. Every specific exception in your hierarchy then extends SerenityException, so you can catch all project exceptions with one catch block or target a specific one.
- Create a new package called com.serenity.exception in your project.
- Right-click the com.serenity package in IntelliJ, select New, then Package, and type exception.
- Inside this new package, create a class called SerenityException.java with the following code:
package com.serenity.exception;
// Base exception for the entire Serenity application
public class SerenityException extends RuntimeException {
public SerenityException(String message) {
super(message);
}
}
What does this code do?
SerenityException is the base class for all your project's exceptions. It extends RuntimeException, which means callers are not forced to catch it (unchecked).
The constructor accepts a String message describing what went wrong and passes it to the parent. Catching SerenityException will catch all child exceptions too, giving you a convenient safety net.
- Now create five child exception classes in the same com.serenity.exception package. Each one extends SerenityException.
- Create AuthenticationException.java:
package com.serenity.exception;
// Thrown when login credentials are wrong or a user is not found
public class AuthenticationException extends SerenityException {
public AuthenticationException(String message) {
super(message);
}
}
- Create ValidationException.java:
package com.serenity.exception;
// Thrown when user input fails a RegEx or constraint check
public class ValidationException extends SerenityException {
public ValidationException(String message) {
super(message);
}
}
- Create DuplicateEntityException.java:
package com.serenity.exception;
// Thrown when trying to create a record that already exists (e.g., duplicate email)
public class DuplicateEntityException extends SerenityException {
public DuplicateEntityException(String message) {
super(message);
}
}
- Create EntityNotFoundException.java:
package com.serenity.exception;
// Thrown when a requested entity does not exist in the database
public class EntityNotFoundException extends SerenityException {
public EntityNotFoundException(String message) {
super(message);
}
}
- Create BookingConflictException.java:
package com.serenity.exception;
// Thrown when a session booking conflicts with an existing booking
public class BookingConflictException extends SerenityException {
public BookingConflictException(String message) {
super(message);
}
}
When does each exception get thrown?
- AuthenticationException: wrong password, user not found during login.
- ValidationException: invalid phone number format, name too short, email missing @ symbol.
- DuplicateEntityException: registering a patient whose email already exists in the database.
- EntityNotFoundException: looking up a therapist ID that does not exist.
- BookingConflictException: scheduling a session when the therapist already has one at that time.
- Save all six exception files.
- Verify that IntelliJ shows no red underlines in any of the exception classes. Each child class should resolve SerenityException without errors.
Seeing 'cannot resolve symbol SerenityException'?
Make sure all six files are in the same package: com.serenity.exception. Check that the package declaration at the top of each file matches exactly.
If IntelliJ still shows red, try right-clicking your project root and selecting Reload from Disk or pressing Ctrl+Shift+O to reimport Maven dependencies.
Help me fix my custom exception hierarchy
Build the ValidationUtil class with RegEx patterns
Now that your exceptions exist, you need code that actually throws them when input is invalid. The ValidationUtil class will use Regular Expressions (RegEx) to check if input matches a valid pattern.
What is RegEx?
A Regular Expression is a pattern that describes what valid text looks like. Think of it as a template with slots: ^[a-zA-Z ]{2,100}$ means "start to end, only letters and spaces, between 2 and 100 characters."
Key building blocks: ^ anchors to the start, $ anchors to the end, [0-9] matches any digit, {n} means "exactly n times," and (?:...) groups alternatives without capturing.
- Create a new class called ValidationUtil.java in the com.serenity.util package.
- Start with the package declaration, import, and the first two validation methods:
package com.serenity.util;
import com.serenity.exception.ValidationException;
public class ValidationUtil {
// Validates that a name contains only letters and spaces, 2-100 chars
public static void validateName(String name) {
if (name == null || !name.matches("^[a-zA-Z ]{2,100}$")) {
throw new ValidationException("Name must contain only letters and spaces, between 2 and 100 characters.");
}
}
// Validates standard email format: something@domain.extension
public static void validateEmail(String email) {
if (email == null || !email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) {
throw new ValidationException("Invalid email address format.");
}
}
}
What do these patterns mean?
- ^[a-zA-Z ]{2,100}$: from start to end, allow only uppercase letters, lowercase letters, and spaces. Minimum 2 characters, maximum 100.
- ^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$: one or more word characters/dots/hyphens, then an @ symbol, then more word characters/dots/hyphens, then a dot and at least 2 letters for the domain extension.
Both methods throw a ValidationException with a human-readable message if the input fails the pattern check.
- Save the file, then add the phone number validation method below the email method:
// Validates Sri Lankan phone numbers (mobile or landline)
public static void validatePhone(String phone) {
boolean mobile = phone != null && phone.matches("^(?:0|\\+94|0094)7[0-9]{8}$");
boolean landline = phone != null && phone.matches("^(?:0|\\+94|0094)[1-9][1-9][0-9]{7}$");
if (!mobile && !landline) {
throw new ValidationException("Invalid Sri Lankan phone number. Use format: 07XXXXXXXX, +947XXXXXXXX, or landline equivalent.");
}
}
How does the Sri Lankan phone regex work?
Sri Lankan mobile numbers start with 07 (local), +947 (international with +), or 00947 (international with 00). The (?:0|\\+94|0094) group matches any of these three prefixes.
After the prefix, mobile numbers have a 7 followed by exactly 8 digits. Landline numbers have two non-zero area code digits followed by 7 digits. The method accepts either format as valid.
- Save the file, then add the password and program ID validation methods below the phone method:
// Validates password strength: min 8 chars, upper, lower, digit, special char
public static void validatePassword(String password) {
if (password == null || password.length() < 8
|| !password.matches(".*[A-Z].*")
|| !password.matches(".*[a-z].*")
|| !password.matches(".*[0-9].*")
|| !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':,./<>?].*")) {
throw new ValidationException("Password must be at least 8 characters and include uppercase, lowercase, digit, and special character.");
}
}
// Validates therapy program ID format: MT followed by 4 digits
public static void validateProgramId(String programId) {
if (programId == null || !programId.matches("^MT[0-9]{4}$")) {
throw new ValidationException("Program ID must follow the format MT followed by 4 digits (e.g., MT1001).");
}
}
What does the password validation check?
Instead of one big regex, this method runs multiple checks. Each .matches(".*[A-Z].*") call checks for at least one character of that type anywhere in the string.
- .*[A-Z].* confirms at least one uppercase letter.
- .*[a-z].* confirms at least one lowercase letter.
- .*[0-9].* confirms at least one digit.
- The last pattern confirms at least one special character from the allowed set.
- Save ValidationUtil.java.
- Verify IntelliJ shows no compilation errors. The import for ValidationException should resolve cleanly.
Seeing red on the ValidationException import?
Make sure you created ValidationException.java in com.serenity.exception during the previous substep. The import statement at the top of ValidationUtil must match the exact package path.
Help me fix the ValidationException import in ValidationUtil
✔️ Awesome, I've got everything!
Great. Double check you have saved ValidationUtil.java with all five methods.
ⓧ I'd like to double check the full code
package com.serenity.util;
import com.serenity.exception.ValidationException;
public class ValidationUtil {
public static void validateName(String name) {
if (name == null || !name.matches("^[a-zA-Z ]{2,100}$")) {
throw new ValidationException("Name must contain only letters and spaces, between 2 and 100 characters.");
}
}
public static void validateEmail(String email) {
if (email == null || !email.matches("^[\\w.-]+@[\\w.-]+\\.[a-zA-Z]{2,}$")) {
throw new ValidationException("Invalid email address format.");
}
}
public static void validatePhone(String phone) {
boolean mobile = phone != null && phone.matches("^(?:0|\\+94|0094)7[0-9]{8}$");
boolean landline = phone != null && phone.matches("^(?:0|\\+94|0094)[1-9][1-9][0-9]{7}$");
if (!mobile && !landline) {
throw new ValidationException("Invalid Sri Lankan phone number. Use format: 07XXXXXXXX, +947XXXXXXXX, or landline equivalent.");
}
}
public static void validatePassword(String password) {
if (password == null || password.length() < 8
|| !password.matches(".*[A-Z].*")
|| !password.matches(".*[a-z].*")
|| !password.matches(".*[0-9].*")
|| !password.matches(".*[!@#$%^&*()_+\\-=\\[\\]{};':,./<>?].*")) {
throw new ValidationException("Password must be at least 8 characters and include uppercase, lowercase, digit, and special character.");
}
}
public static void validateProgramId(String programId) {
if (programId == null || !programId.matches("^MT[0-9]{4}$")) {
throw new ValidationException("Program ID must follow the format MT followed by 4 digits (e.g., MT1001).");
}
}
}
Integrate validation into your service layer
Your exception classes and validation utility are ready. The final piece is wiring them into your services so every input gets validated before it touches the database.
The pattern is consistent across all services: validate input first, check for duplicates, then call the DAO.
Why validate in the service layer?
The service layer is the gatekeeper between your UI and your database. If you validate in the controller (UI), a different controller could skip validation and corrupt your data. If you validate in the DAO, your error messages would be database-level gibberish.
Placing validation in the service layer means every path to the database goes through the same checks. This is the Facade pattern in action. The service is a single point of control.
- Open AuthenticationService.java in the com.serenity.service package.
- Add these imports at the top of the file, below the existing imports:
import com.serenity.exception.AuthenticationException;
import com.serenity.exception.DuplicateEntityException;
import com.serenity.exception.EntityNotFoundException;
import com.serenity.util.ValidationUtil;
- Find the login method. It currently returns null when authentication fails. Replace the entire login method with this version that throws specific exceptions:
public User login(String username, String password) {
// Validate input before attempting database lookup
ValidationUtil.validateName(username);
ValidationUtil.validatePassword(password);
User user = userDAO.findByUsername(username);
if (user == null) {
throw new AuthenticationException("User not found: " + username);
}
if (!PasswordUtil.verifyPassword(password, user.getPasswordHash())) {
throw new AuthenticationException("Invalid password for user: " + username);
}
user.setLastLogin(LocalDateTime.now());
userDAO.update(user);
SessionContext.getInstance().setCurrentUser(user);
return user;
}
What changed here?
- ValidationUtil.validateName(username) and ValidationUtil.validatePassword(password) run before any database call. If input is malformed, execution stops immediately with a clear ValidationException.
- Instead of returning null when the user is not found, the method throws an AuthenticationException with a message explaining the problem.
- The caller (your future UI controller) can now catch these specific exceptions and display the right error message to the user.
- Now replace the register method with this version that validates and checks for duplicates:
public void register(String username, String password, UserRole role) {
// Validate input format
ValidationUtil.validateName(username);
ValidationUtil.validatePassword(password);
// Check for duplicate username
if (userDAO.findByUsername(username) != null) {
throw new DuplicateEntityException("Username already exists: " + username);
}
User user = new User();
user.setUsername(username);
user.setPasswordHash(PasswordUtil.hashPassword(password));
user.setRole(role);
user.setCreatedAt(LocalDateTime.now());
userDAO.save(user);
}
- Replace the changePassword method with this version:
public void changePassword(Long userId, String oldPassword, String newPassword) {
// Validate the new password meets strength requirements
ValidationUtil.validatePassword(newPassword);
User user = userDAO.getById(User.class, userId);
if (user == null) {
throw new EntityNotFoundException("User not found with ID: " + userId);
}
if (!PasswordUtil.verifyPassword(oldPassword, user.getPasswordHash())) {
throw new AuthenticationException("Current password is incorrect.");
}
user.setPasswordHash(PasswordUtil.hashPassword(newPassword));
userDAO.update(user);
}
What does this pattern look like in other services?
Apply the same pattern across TherapistService, PatientService, BookingService, and PaymentService. Each method that accepts user input should:
- Call the relevant ValidationUtil methods first (validateName for names, validatePhone for phone numbers, validateEmail for emails).
- Check for duplicates by querying the DAO before persisting, and throw DuplicateEntityException if a match is found.
- Throw EntityNotFoundException when looking up an ID that returns null from the DAO.
- Throw BookingConflictException in BookingService when a session time conflicts with an existing booking.
- Save AuthenticationService.java.
- Update your TherapistService, PatientService, BookingService, and PaymentService following the same pattern. Validate inputs, check for duplicates, throw specific exceptions.
- Verify IntelliJ shows no compilation errors across your service package.
How controllers will use these exceptions
In the next step when you build the JavaFX UI, your controllers will wrap service calls in try-catch blocks. Catching ValidationException shows a red label with the validation message. Catching AuthenticationException shows a login error. Each exception type maps to a specific UI response.
Getting compilation errors after updating services?
If your changePassword method previously returned boolean, make sure to change its return type to void. With exceptions, the method either succeeds (no return needed) or throws.
Also check that you added all four import statements for the exception classes and ValidationUtil at the top of each service file.
Help me fix compilation errors after adding validation to my services
Your application now has a professional error handling system. Invalid input gets caught with clear messages before it hits the database. Next up, you will build the JavaFX user interface that catches these exceptions and displays them as friendly error labels to the user.
Build the JavaFX User Interface
Your exception hierarchy and validation logic are locked and loaded. Every invalid input now gets caught with a specific, meaningful error message before it ever touches the database.
Now it is time to give your system a face. You will build a JavaFX desktop application that users can actually interact with. Think of it this way: FXML is like an architect's blueprint of what the screen looks like (buttons, labels, layout). The Controller is the electrician who wires the buttons to actual functionality. The Service layer is the plumbing behind the walls that does the real work.
In this step, get ready to:
- Create the application entry point and a secure login screen with show/hide password toggle.
- Build a main dashboard with sidebar navigation that swaps content in the center area.
- Build management screens for therapists, patients, sessions, and payments with CRUD operations.
Create the application entry point and login screen
Every JavaFX application starts with a class that extends Application. The start(Stage) method is your entry point. The Stage is the window, and the Scene is the content inside it.
What is FXML?
FXML is XML that describes your UI layout. The fx:controller attribute links the FXML file to a Java controller class. Inside the controller, the @FXML annotation injects UI elements so your Java code can read their values and respond to button clicks.
This separation means you can redesign the layout without touching your logic, and vice versa. This IS the MVC pattern in action.
- Create a new Java class called SerenityApp.java inside the com.serenity.view package with the following code:
package com.serenity.view;
import com.serenity.service.AuthenticationService;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class SerenityApp extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
// Seed a default admin user so you can log in on first run
new AuthenticationService().seedDefaultAdmin();
// Load the login screen from FXML
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/login.fxml"));
Scene scene = new Scene(loader.load(), 400, 300);
primaryStage.setTitle("Serenity Mental Health Therapy Center");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
What does this code do?
The seedDefaultAdmin() call ensures a default admin user exists in the database on every launch. Without this, you could not log in the first time you run the app.
The FXMLLoader reads the login.fxml file and creates the UI nodes from it. The path starts with / because it is loaded from the classpath root (inside src/main/resources).
Now you need the FXML file that this class loads. FXML files must live on the classpath, which means inside src/main/resources mirroring the package path.
- Create the folder path src/main/resources/com/serenity/view/ if it does not already exist.
- Create a new file called login.fxml inside that folder with the following content:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<VBox xmlns:fx="http://javafx.com/fxml" fx:controller="com.serenity.controller.LoginController"
alignment="CENTER" spacing="15" style="-fx-padding: 40;">
<Label text="Serenity Therapy Center" style="-fx-font-size: 18; -fx-font-weight: bold;"/>
<TextField fx:id="usernameField" promptText="Username"/>
<PasswordField fx:id="passwordField" promptText="Password"/>
<TextField fx:id="passwordVisible" promptText="Password" managed="false" visible="false"/>
<CheckBox fx:id="showPasswordCheck" text="Show Password" onAction="#togglePasswordVisibility"/>
<Button text="Login" onAction="#handleLogin" maxWidth="Infinity"/>
<Label fx:id="errorLabel" style="-fx-text-fill: red;" text=""/>
</VBox>
What does this FXML do?
- The fx:controller attribute tells JavaFX which Java class handles events from this screen.
- The fx:id attributes link each field to a variable in the controller (injected via @FXML).
- The passwordVisible TextField starts hidden (managed="false" visible="false"). It becomes visible when the show password checkbox is selected.
- The onAction="#handleLogin" wires the Login button to the handleLogin() method in the controller.
The login screen needs a controller to handle user input and authentication logic.
- Create a new Java class called LoginController.java inside the com.serenity.controller package with the following code:
package com.serenity.controller;
import com.serenity.entity.User;
import com.serenity.service.AuthenticationService;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;
public class LoginController {
@FXML private TextField usernameField;
@FXML private PasswordField passwordField;
@FXML private TextField passwordVisible;
@FXML private CheckBox showPasswordCheck;
@FXML private Label errorLabel;
private final AuthenticationService authService = new AuthenticationService();
@FXML
private void togglePasswordVisibility() {
// Swap between the masked PasswordField and a plain TextField
if (showPasswordCheck.isSelected()) {
passwordVisible.setText(passwordField.getText());
passwordVisible.setVisible(true);
passwordVisible.setManaged(true);
passwordField.setVisible(false);
passwordField.setManaged(false);
} else {
passwordField.setText(passwordVisible.getText());
passwordField.setVisible(true);
passwordField.setManaged(true);
passwordVisible.setVisible(false);
passwordVisible.setManaged(false);
}
}
@FXML
private void handleLogin() {
String username = usernameField.getText().trim();
// Read from whichever field is currently visible
String password = showPasswordCheck.isSelected() ? passwordVisible.getText() : passwordField.getText();
User user = authService.login(username, password);
if (user != null) {
try {
// Navigate to the main dashboard on success
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/main.fxml"));
Scene scene = new Scene(loader.load(), 900, 600);
Stage stage = (Stage) usernameField.getScene().getWindow();
stage.setScene(scene);
} catch (Exception e) {
errorLabel.setText("Failed to load main screen.");
}
} else {
errorLabel.setText("Invalid username or password.");
}
}
}
What does this controller do?
- The @FXML fields match the fx:id values in login.fxml. JavaFX injects the actual UI nodes into these variables at startup.
- The togglePasswordVisibility() method swaps between the masked PasswordField and a plain TextField. Setting managed(false) removes the node from the layout so it does not take up space.
- The handleLogin() method delegates to AuthenticationService. If login succeeds, it replaces the current scene with the main dashboard. If it fails, it shows an error message.
- Save all three files: SerenityApp.java, login.fxml, and LoginController.java.
Seeing a ClassNotFoundException or FXML load error?
- Make sure login.fxml is located at exactly src/main/resources/com/serenity/view/login.fxml. The folder path must mirror the package path.
- Verify that fx:controller in the FXML matches the fully qualified class name com.serenity.controller.LoginController.
- Check that the javafx-controls and javafx-fxml dependencies are present in your pom.xml.
Still stuck? Ask: My JavaFX application throws an error when loading login.fxml. How do I fix FXML path issues?
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved before moving on.
ⓧ I'd like to double check the full code
Here is SerenityApp.java:
package com.serenity.view;
import com.serenity.service.AuthenticationService;
import javafx.application.Application;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.stage.Stage;
public class SerenityApp extends Application {
@Override
public void start(Stage primaryStage) throws Exception {
new AuthenticationService().seedDefaultAdmin();
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/login.fxml"));
Scene scene = new Scene(loader.load(), 400, 300);
primaryStage.setTitle("Serenity Mental Health Therapy Center");
primaryStage.setScene(scene);
primaryStage.show();
}
public static void main(String[] args) {
launch(args);
}
}
Here is login.fxml:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<VBox xmlns:fx="http://javafx.com/fxml" fx:controller="com.serenity.controller.LoginController"
alignment="CENTER" spacing="15" style="-fx-padding: 40;">
<Label text="Serenity Therapy Center" style="-fx-font-size: 18; -fx-font-weight: bold;"/>
<TextField fx:id="usernameField" promptText="Username"/>
<PasswordField fx:id="passwordField" promptText="Password"/>
<TextField fx:id="passwordVisible" promptText="Password" managed="false" visible="false"/>
<CheckBox fx:id="showPasswordCheck" text="Show Password" onAction="#togglePasswordVisibility"/>
<Button text="Login" onAction="#handleLogin" maxWidth="Infinity"/>
<Label fx:id="errorLabel" style="-fx-text-fill: red;" text=""/>
</VBox>
Here is LoginController.java:
package com.serenity.controller;
import com.serenity.entity.User;
import com.serenity.service.AuthenticationService;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.control.*;
import javafx.stage.Stage;
public class LoginController {
@FXML private TextField usernameField;
@FXML private PasswordField passwordField;
@FXML private TextField passwordVisible;
@FXML private CheckBox showPasswordCheck;
@FXML private Label errorLabel;
private final AuthenticationService authService = new AuthenticationService();
@FXML
private void togglePasswordVisibility() {
if (showPasswordCheck.isSelected()) {
passwordVisible.setText(passwordField.getText());
passwordVisible.setVisible(true);
passwordVisible.setManaged(true);
passwordField.setVisible(false);
passwordField.setManaged(false);
} else {
passwordField.setText(passwordVisible.getText());
passwordField.setVisible(true);
passwordField.setManaged(true);
passwordVisible.setVisible(false);
passwordVisible.setManaged(false);
}
}
@FXML
private void handleLogin() {
String username = usernameField.getText().trim();
String password = showPasswordCheck.isSelected() ? passwordVisible.getText() : passwordField.getText();
User user = authService.login(username, password);
if (user != null) {
try {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/main.fxml"));
Scene scene = new Scene(loader.load(), 900, 600);
Stage stage = (Stage) usernameField.getScene().getWindow();
stage.setScene(scene);
} catch (Exception e) {
errorLabel.setText("Failed to load main screen.");
}
} else {
errorLabel.setText("Invalid username or password.");
}
}
}
Build the main dashboard with sidebar navigation
Once a user logs in successfully, they land on the main dashboard. This screen uses a BorderPane layout with a sidebar on the left for navigation and a center area that swaps content depending on which button is clicked.
- Create a new file called main.fxml inside src/main/resources/com/serenity/view/ with the following content:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns:fx="http://javafx.com/fxml" fx:controller="com.serenity.controller.MainController">
<left>
<VBox spacing="5" style="-fx-padding: 10; -fx-background-color: #2c3e50; -fx-min-width: 160;">
<Button text="Therapists" onAction="#loadTherapists" maxWidth="Infinity"/>
<Button text="Patients" onAction="#loadPatients" maxWidth="Infinity"/>
<Button text="Programs" onAction="#loadPrograms" maxWidth="Infinity"/>
<Button text="Sessions" onAction="#loadSessions" maxWidth="Infinity"/>
<Button text="Payments" onAction="#loadPayments" maxWidth="Infinity"/>
<Button text="Change Password" onAction="#changePassword" maxWidth="Infinity"/>
<Button text="Logout" onAction="#logout" maxWidth="Infinity"/>
</VBox>
</left>
<center>
<StackPane fx:id="contentPane"/>
</center>
</BorderPane>
What does this layout do?
- The BorderPane divides the window into regions: left, center, top, bottom, and right. You are using the left region for navigation and the center for content.
- Each sidebar button triggers a different method in the controller (e.g., #loadTherapists).
- The StackPane in the center is a container whose children stack on top of each other. You will swap its content when sidebar buttons are clicked.
Now create the controller that handles navigation and content swapping.
- Create a new Java class called MainController.java inside the com.serenity.controller package with the following code:
package com.serenity.controller;
import com.serenity.util.SessionContext;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class MainController {
@FXML private StackPane contentPane;
// Load any FXML file into the center content area
private void loadView(String fxmlFile) {
try {
Pane view = FXMLLoader.load(getClass().getResource("/com/serenity/view/" + fxmlFile));
contentPane.getChildren().setAll(view);
} catch (Exception e) {
e.printStackTrace();
}
}
// Sidebar navigation handlers
@FXML private void loadTherapists() { loadView("therapist.fxml"); }
@FXML private void loadPatients() { loadView("patient.fxml"); }
@FXML private void loadPrograms() { loadView("program.fxml"); }
@FXML private void loadSessions() { loadView("session.fxml"); }
@FXML private void loadPayments() { loadView("payment.fxml"); }
@FXML
private void changePassword() {
loadView("changepassword.fxml");
}
@FXML
private void logout() {
// Clear the current user session
SessionContext.getInstance().logout();
try {
// Navigate back to the login screen
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/login.fxml"));
Scene scene = new Scene(loader.load(), 400, 300);
Stage stage = (Stage) contentPane.getScene().getWindow();
stage.setScene(scene);
} catch (Exception e) {
e.printStackTrace();
}
}
}
What does this controller do?
- The private loadView() helper loads any FXML file and replaces the center content. Every sidebar button calls this method with a different file name.
- The contentPane.getChildren().setAll(view) call clears the previous content and sets the new view in one operation.
- The logout() method clears the SessionContext singleton, then navigates back to the login screen.
- Save both main.fxml and MainController.java.
Sidebar buttons not loading content?
- Each sidebar button references a method name like #loadTherapists. The method name in the FXML must exactly match the method name in the controller (case-sensitive).
- The FXML files referenced (like therapist.fxml) must exist before you click those buttons. You will create them in the next substep.
Need help? Ask: My MainController loadView method is not finding FXML files. How do I debug JavaFX resource loading?
✔️ Awesome, I've got everything!
Great. Make sure both files are saved before continuing to the management screens.
ⓧ I'd like to double check the full code
Here is main.fxml:
<?xml version="1.0" encoding="UTF-8"?>
<?import javafx.scene.control.*?>
<?import javafx.scene.layout.*?>
<BorderPane xmlns:fx="http://javafx.com/fxml" fx:controller="com.serenity.controller.MainController">
<left>
<VBox spacing="5" style="-fx-padding: 10; -fx-background-color: #2c3e50; -fx-min-width: 160;">
<Button text="Therapists" onAction="#loadTherapists" maxWidth="Infinity"/>
<Button text="Patients" onAction="#loadPatients" maxWidth="Infinity"/>
<Button text="Programs" onAction="#loadPrograms" maxWidth="Infinity"/>
<Button text="Sessions" onAction="#loadSessions" maxWidth="Infinity"/>
<Button text="Payments" onAction="#loadPayments" maxWidth="Infinity"/>
<Button text="Change Password" onAction="#changePassword" maxWidth="Infinity"/>
<Button text="Logout" onAction="#logout" maxWidth="Infinity"/>
</VBox>
</left>
<center>
<StackPane fx:id="contentPane"/>
</center>
</BorderPane>
Here is MainController.java:
package com.serenity.controller;
import com.serenity.util.SessionContext;
import javafx.fxml.FXML;
import javafx.fxml.FXMLLoader;
import javafx.scene.Scene;
import javafx.scene.layout.Pane;
import javafx.scene.layout.StackPane;
import javafx.stage.Stage;
public class MainController {
@FXML private StackPane contentPane;
private void loadView(String fxmlFile) {
try {
Pane view = FXMLLoader.load(getClass().getResource("/com/serenity/view/" + fxmlFile));
contentPane.getChildren().setAll(view);
} catch (Exception e) {
e.printStackTrace();
}
}
@FXML private void loadTherapists() { loadView("therapist.fxml"); }
@FXML private void loadPatients() { loadView("patient.fxml"); }
@FXML private void loadPrograms() { loadView("program.fxml"); }
@FXML private void loadSessions() { loadView("session.fxml"); }
@FXML private void loadPayments() { loadView("payment.fxml"); }
@FXML
private void changePassword() {
loadView("changepassword.fxml");
}
@FXML
private void logout() {
SessionContext.getInstance().logout();
try {
FXMLLoader loader = new FXMLLoader(getClass().getResource("/com/serenity/view/login.fxml"));
Scene scene = new Scene(loader.load(), 400, 300);
Stage stage = (Stage) contentPane.getScene().getWindow();
stage.setScene(scene);
} catch (Exception e) {
e.printStackTrace();
}
}
}
Build the management screens and wire them to services
The sidebar references five content screens: therapist, patient, program, session, and payment. Each screen follows the same pattern you have already seen.
What is the pattern for each management screen?
Every management screen has two parts:
- An FXML file in src/main/resources/com/serenity/view/ that defines the layout (TableView for listing records, form fields for input, and buttons for Add/Edit/Delete).
- A Controller class in com.serenity.controller that uses the appropriate Service from ServiceFactory to perform CRUD operations.
The controller populates a JavaFX ObservableList from the service layer's getAll() method and binds it to the TableView. Each TableColumn uses a PropertyValueFactory to map an entity field name to a column.
Follow these guidelines for each screen you build.
- Create therapist.fxml with a TableView containing columns for ID, Name, Specialization, Phone, Email, and Available. Below the table, add TextFields for each field and three Buttons: Add, Update, and Delete.
- Create TherapistController.java that gets TherapistService from ServiceFactory. Use FXCollections.observableArrayList() to populate the table and refresh it after every CRUD action.
- Create patient.fxml and PatientController.java following the exact same structure, wired to PatientService.
- Create program.fxml and its controller for managing TherapyProgram records.
- Create session.fxml with ComboBox controls for selecting a patient, therapist, and program. Add a DatePicker for the session date and a TextField for the time. Wire the Book Session button to BookingService.bookSession().
- Create payment.fxml and PaymentController.java to record payments linked to patients and programs. Display a payment history TableView.
- Create changepassword.fxml with fields for old password, new password, and confirm password. Wire the submit button to AuthenticationService.changePassword().
How do I handle errors in controllers?
Wrap every service call in a try-catch block. Catch ValidationException for input errors, DuplicateEntityException for conflicts, and the base SerenityException as a fallback.
Display the exception's getMessage() in a JavaFX Alert dialog. Use Alert.AlertType.ERROR for the alert type and call .showAndWait() to show it. This gives users clear, specific error messages from your custom exception hierarchy.
Add CSS validation feedback
For real-time input validation, add a red border to invalid fields using inline CSS in your controller. When a field fails validation, call field.setStyle("-fx-border-color: red;") on it. Clear the style with field.setStyle("") when the user corrects the input.
- Save all FXML files and controller classes.
- Run the application using the Maven javafx:run goal in IntelliJ. You should see the login screen appear.
- Log in with the default admin credentials (the username and password your seedDefaultAdmin() method creates).
- After logging in, you should see the main dashboard with the dark sidebar on the left and an empty center area.
- Click each sidebar button to verify it loads the corresponding management screen.
Application not starting?
- If you see a Module not found error, make sure the javafx-maven-plugin in your pom.xml has mainClass set to com.serenity.view.SerenityApp.
- If the login screen appears but nothing happens when you click Login, check your terminal output for stack traces. The most common issue is the database not running or serenity_db not existing in MySQL.
- If clicking a sidebar button causes a null pointer error, confirm the FXML file it references exists at the correct path inside src/main/resources/com/serenity/view/.
Still stuck? Ask: My JavaFX application fails to launch with Maven javafx:run. How do I fix module and classpath issues?
Your application now has a complete user interface. Users can log in securely, navigate between screens, manage records, and log out. Next up, you will extend this system with a statistics dashboard in the Secret Mission.
Secret mission
Add a Statistics Dashboard with Revenue Reports
Your therapy center system works great for managing day-to-day operations. But what about the big picture? In this secret mission, you will build an analytics dashboard that uses HQL aggregate queries to surface revenue trends, therapist workloads, and program popularity at a glance.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- MySQL database serenity_db with all entity tables and test data.
- Local Maven project folder with all Java source files, FXML layouts, and configuration.
- IntelliJ IDEA project configuration and run settings.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project with the Secret Mission.
- Your serenity_db database persists in MySQL and takes minimal disk space.
- Your project files remain in your project folder on your Desktop.
- There are no ongoing costs for keeping everything as-is.
Pause - I'll come back to this later
Shut down running processes to free up memory while keeping all your files and database intact.
- Close IntelliJ IDEA to free up memory.
- Stop the MySQL service if you do not need it for other projects. On macOS, run brew services stop mysql. On Windows, open Services and stop the MySQL service.
- Your database and project files remain untouched and ready for when you return.
Delete - I don't want to use this again
Remove all project resources and start fresh. Only do this after you have submitted your assignment.
- Drop the database by opening your MySQL command line and running:
DROP DATABASE serenity_db;
- Delete your project folder from your Desktop.
- Remove the IntelliJ IDEA project from your recent projects list by right-clicking it on the Welcome screen and selecting Remove from Recent Projects.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete enterprise-style Java application from scratch, covering everything from database design with Hibernate ORM to a fully functional JavaFX desktop interface.
You've learned how to:
- Design and map Hibernate entities with One-to-Many and Many-to-Many relationships, write HQL queries for search and aggregate reporting, and implement cascade types for managing entity lifecycles.
- Secure an application with BCrypt password hashing, build a custom exception hierarchy for meaningful error handling, and validate all input fields using RegEx patterns including Sri Lankan phone numbers.
- Architect a layered application using the Facade and Factory design patterns, and build a full JavaFX desktop UI with FXML, sidebar navigation, and real-time validation feedback.
- Secret Mission: Added a statistics dashboard with revenue reports and HQL aggregate queries for therapy session analytics.
Every concept from this project, from ORM and design patterns to security and layered architecture, will appear in professional Java development and your VIVA exam. You now have a complete set of study notes and a working application to reference.
Ready to quiz yourself?