Real-World Project
Plan and build a small Student-Course enrollment system with Hibernate, tying together entity mapping, many-to-many relationships, HQL, and transactions from across the course.
Introduction
This final lesson pulls together everything from the course into one small but complete project: a Student-Course enrollment system, the same one introduced back in the course overview. You will plan the domain model, map a many-to-many relationship, run CRUD and HQL through real transactions, and see how fetch strategy and caching decisions play out in a concrete example.
- A Student entity and a Course entity in a many-to-many relationship
- An enrollment operation wrapped in a transaction
- HQL queries to report on enrollments
- A safe unenrollment flow using orphan-free relationship management
Planning the Domain Model
Before writing any code, sketch the relationship: a Student can enroll in many Courses, and a Course can have many Students — a many-to-many relationship, exactly like the one covered earlier in the course. Because it is many-to-many, the database needs a join table (student_course) to represent it, which Hibernate manages for you through the mapping.
| Entity | Key Fields | Relationship |
|---|---|---|
| Student | id, name, email | Many-to-Many with Course |
| Course | id, title, credits | Many-to-Many with Student |
Mapping the Entities
Student owns the relationship (it declares the @JoinTable), and Course is the inverse side, mapped back with mappedBy. This mirrors the many-to-many mapping lesson earlier in the course.
import javax.persistence.*;import javax.validation.constraints.*;import java.util.HashSet;import java.util.Set;
@Entity@Table(name = "students")public class Student {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@NotBlank(message = "Name must not be blank") private String name;
@NotBlank @Email(message = "Email must be a valid address") private String email;
@ManyToMany(fetch = FetchType.LAZY) @JoinTable( name = "student_course", joinColumns = @JoinColumn(name = "student_id"), inverseJoinColumns = @JoinColumn(name = "course_id") ) private Set<Course> courses = new HashSet<>();
// Getters and setters
public void enrollIn(Course course) { courses.add(course); course.getStudents().add(this); }
public void unenrollFrom(Course course) { courses.remove(course); course.getStudents().remove(this); }
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Student)) return false; return id != null && id.equals(((Student) o).id); }
@Override public int hashCode() { return getClass().hashCode(); }}import javax.persistence.*;import javax.validation.constraints.*;import java.util.HashSet;import java.util.Set;
@Entity@Table(name = "courses")public class Course {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@NotBlank(message = "Title must not be blank") private String title;
@Min(value = 1, message = "Credits must be at least 1") private Integer credits;
@ManyToMany(mappedBy = "courses", fetch = FetchType.LAZY) private Set<Student> students = new HashSet<>();
// Getters and setters
@Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Course)) return false; return id != null && id.equals(((Course) o).id); }
@Override public int hashCode() { return getClass().hashCode(); }}Hibernate Configuration
<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/schooldb</property> <property name="hibernate.connection.username">root</property> <property name="hibernate.connection.password">secret</property> <property name="hibernate.dialect">org.hibernate.dialect.MySQL8Dialect</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.example.enrollment.Student"/> <mapping class="com.example.enrollment.Course"/> </session-factory></hibernate-configuration>Enrolling a Student
Every write goes through a transaction, exactly as covered in the transactions lesson, and validation runs automatically via Hibernate Validator before the insert.
SessionFactory factory = new Configuration().configure().buildSessionFactory();
try (Session session = factory.openSession()) { Transaction tx = session.beginTransaction(); try { Course java = new Course(); java.setTitle("Introduction to Java"); java.setCredits(4); session.persist(java);
Student aditi = new Student(); aditi.setName("Aditi Sharma"); aditi.setEmail("aditi.sharma@example.com"); session.persist(aditi);
aditi.enrollIn(java); session.merge(aditi);
tx.commit(); System.out.println("Enrollment successful."); } catch (Exception e) { tx.rollback(); throw e; }}Click Run to see what this code prints.
Querying Enrollments with HQL
This report uses JOIN FETCH from the N+1 lesson so the students and their courses come back in a single query instead of triggering lazy loads per student.
try (Session session = factory.openSession()) { List<Student> students = session.createQuery( "SELECT DISTINCT s FROM Student s JOIN FETCH s.courses", Student.class) .getResultList();
for (Student s : students) { System.out.println(s.getName() + " is enrolled in:"); s.getCourses().forEach(c -> System.out.println(" - " + c.getTitle())); }}Click Run to see what this code prints.
List<Object[]> report = session.createQuery( "SELECT c.title, COUNT(s) FROM Course c JOIN c.students s GROUP BY c.title") .getResultList();
for (Object[] row : report) { System.out.println(row[0] + ": " + row[1] + " students");}Click Run to see what this code prints.
Handling Unenrollment
Because Student and Course share the relationship in memory, unenrolling needs to update both sides consistently — exactly the helper method pattern used in enrollIn()/unenrollFrom() above — and the whole operation still runs inside one transaction.
try (Session session = factory.openSession()) { Transaction tx = session.beginTransaction(); try { Student student = session.get(Student.class, 1L); Course course = session.get(Course.class, 1L);
student.unenrollFrom(course);
tx.commit(); System.out.println("Unenrollment successful."); } catch (Exception e) { tx.rollback(); throw e; }}Click Run to see what this code prints.
Notice there is no CascadeType.REMOVE on this @ManyToMany, matching the cascade guidance from the previous lesson — removing an enrollment link should never delete the Student or the Course themselves, since both are shared, independent entities.
Putting It All Together
In roughly 100 lines of entity code and a handful of transactional operations, this project touches nearly every concept from the course: @Entity mapping, @ManyToMany with a join table, Bean Validation, transactions with rollback, JOIN FETCH to avoid N+1, and equals()/hashCode() done correctly. From here, the natural next steps are adding a second-level cache for the Course lookup data, and wrapping this same logic in Spring Data JPA repositories as covered in the Hibernate with Spring lesson.
Common Mistakes
- Only updating one side of the Student/Course relationship in memory, leaving the in-memory object graph inconsistent with the database.
- Forgetting DISTINCT on a JOIN FETCH over a collection, producing duplicate Student rows in the result list.
- Applying CascadeType.REMOVE to the many-to-many mapping and accidentally deleting shared Course or Student rows.
- Skipping validation annotations and allowing a blank name or invalid email to reach the database.
- Not wrapping enrollment/unenrollment in a transaction, risking a half-applied join table change.
Best Practices
- Keep relationship-management helper methods (enrollIn/unenrollFrom) on the entity so both sides always stay in sync.
- Wrap every multi-step write in a transaction with a rollback path, as covered throughout this course.
- Use JOIN FETCH for reporting queries that walk the relationship, to avoid N+1.
- Validate entity fields with Bean Validation before they ever reach the database.
- Reach for Spring Data JPA once the domain model stabilizes, to replace this manual Session code with repository interfaces.
Frequently Asked Questions
A Set naturally prevents a Student from being enrolled in the same Course twice, which matches the real-world meaning of an enrollment relationship.
Yes, and that is a natural extension — introducing an Enrollment entity with its own id lets you store extra data per enrollment, like an enrollment date or grade.
Mark Course as @Cacheable with an appropriate concurrency strategy, since course catalog data is read far more often than it changes, matching the guidance from the second-level cache lesson.
Pairing this Hibernate knowledge with the Spring Boot course to rebuild this same enrollment system using Spring Data JPA repositories and @Transactional services is the natural next step.
Key Takeaways
- A many-to-many Student-Course relationship needs a join table, managed automatically through @JoinTable.
- Helper methods that update both sides of a relationship keep the in-memory object graph consistent.
- Every write belongs inside a transaction with a clear rollback path.
- JOIN FETCH avoids N+1 when reporting across the relationship.
- This project touches nearly every major concept covered across the course's 28 lessons.
Summary
This enrollment system is small, but it exercises the full Hibernate toolkit built up across this course — mapping, relationships, validation, transactions, HQL, and fetch strategy — the same skills that scale directly to larger, production Hibernate and Spring Data JPA applications.