LearnAI ToolsCareerPractice BuildsPlayContact
HibernateBeginner~1.5 hours

Student-Course Enrollment System

Model a many-to-many relationship between Students and Courses.

Entity MappingMany-to-ManyHQL

Overview

A student can enroll in many courses, and a course can hold many students — the same many-to-many shape a relational schema solves with a junction table, but Hibernate lets you express it directly on two Java classes instead of hand-writing that table yourself. Annotate `Student` and `Course` with `@ManyToMany`, and Hibernate generates the join table, its foreign keys, and every `INSERT`/`SELECT` needed to keep both sides in sync — you work in terms of `student.getCourses()` and `course.getStudents()`, plain Java collections, while Hibernate translates that into SQL behind the scenes.

By the end of this tutorial you will have two mapped entities, `Student` and `Course`, joined through a Hibernate-managed `student_course` table that you never write `CREATE TABLE` for by hand, plus HQL queries that read almost like Java itself — `FROM Student s JOIN s.courses c WHERE c.title = :title` — to answer "which courses is this student in?" and "who is enrolled in this course?" in both directions.

What You'll Build
  • A `Student` entity with `@Id`/`@GeneratedValue` and a `@ManyToMany` collection of courses.
  • A `Course` entity that is the inverse (`mappedBy`) side of the same `@ManyToMany` relationship.
  • An explicit `@JoinTable` on the owning side naming the join columns Hibernate generates.
  • A `hibernate.cfg.xml` configuration wiring Hibernate to a MySQL database.
  • HQL queries finding all courses for a student, and all students for a course.
  • A demonstration of why enrolling a student updates the join table with no manual SQL.

Prerequisites

  • Java classes — fields, constructors, getters/setters, and collections like `Set`/`List`.
  • What an ORM is — mapping Java objects to relational rows instead of writing SQL by hand for every operation.
  • Basic JPA annotations — `@Entity`, `@Table`, `@Id`, `@GeneratedValue`.
  • Relational basics — primary keys, foreign keys, and what a many-to-many relationship requires (a junction table).
  • Maven or Gradle basics, since a Hibernate project needs `hibernate-core` and a JDBC driver on the classpath.

Project Structure

Two entity classes make up the whole domain model. `Student` and `Course` are peers — neither is "owned" by the other in a real-world sense, which is exactly the situation `@ManyToMany` exists for. One side has to be designated the *owning* side in the mapping (the side whose changes Hibernate actually writes to the database); the other is the *inverse* side, marked with `mappedBy`, which only reflects the relationship back for convenience and never triggers writes on its own.

EntityPurposeKey Fields / Mapping
StudentOne row per studentid (PK), name, email, courses (owning @ManyToMany with @JoinTable)
CourseOne row per course offeredid (PK), title, credits, students (inverse @ManyToMany, mappedBy = "courses")
student_courseHibernate-generated join table (not a Java class)student_id (FK), course_id (FK) — composite key, no entity of its own

Notice `student_course` has no corresponding Java class — that is normal for a plain many-to-many relationship with no extra columns of its own. If the join table ever needed extra data (an enrollment date, a grade), you would refactor it into its own entity, which is exactly the shape the Order Management System and its `OrderItem` entity use later in this course's projects.

Step 1: Map the Student Entity

`@Entity` tells Hibernate this class corresponds to a table, and `@Table(name = "students")` pins down that table's actual name (without it, Hibernate would default to the class name, `Student`). `@Id` marks the primary key field, and `@GeneratedValue(strategy = GenerationType.IDENTITY)` delegates id generation to the database's own auto-increment column rather than Hibernate picking ids itself — the simplest and most portable strategy for a MySQL-backed project like this one. `courses` is declared as a `Set<Course>`, not a `List<Course>`, because a set naturally forbids the same course appearing twice for one student, which matches the real-world rule that a student either is or is not enrolled in a course.

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity // Marks this class as a Hibernate-managed entity, mapped to a table
@Table(name = "students") // Explicit table name; without this Hibernate would default to "Student"
public class Student {
@Id // Primary key field
@GeneratedValue(strategy = GenerationType.IDENTITY) // Delegates id generation to MySQL's AUTO_INCREMENT column
private Long id;
@Column(nullable = false) // A student without a name is never valid data
private String name;
@Column(nullable = false, unique = true) // No two students may share a login email
private String email;
// Owning side of the many-to-many: this is the side whose in-memory changes
// Hibernate actually writes to the join table. @JoinTable spells out that
// table's name and both foreign-key columns explicitly, rather than letting
// Hibernate invent a default name that would be harder to read in raw SQL.
@ManyToMany
@JoinTable(
name = "student_course", // The join table Hibernate creates and manages
joinColumns = @JoinColumn(name = "student_id"), // FK column pointing back at this Student
inverseJoinColumns = @JoinColumn(name = "course_id") // FK column pointing at the related Course
)
private Set<Course> courses = new HashSet<>(); // Set, not List: a student cannot be enrolled in the same course twice
public Student() {} // No-arg constructor required by Hibernate/JPA to instantiate entities via reflection
public Student(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public Set<Course> getCourses() { return courses; }
// A helper, not a raw setter: enrolling always updates both sides of the
// relationship in memory, which keeps the object graph consistent even
// before anything is flushed to the database.
public void enrollIn(Course course) {
this.courses.add(course);
course.getStudents().add(this);
}
}
Generated Join Table DDL

Click Run to see what this code prints.

Step 2: Map the Course Entity

`Course` is the inverse side of the same relationship, marked with `mappedBy = "courses"` — that string must match the field name declared on `Student` exactly, since it is how Hibernate connects the two ends of one mapping instead of accidentally creating two separate join tables. The inverse side never gets a `@JoinTable`: only one side of a bidirectional `@ManyToMany` owns the join table, and duplicating it on both sides would make Hibernate try to manage two different tables for what is really one relationship.

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private int credits;
// Inverse side: mappedBy points at the field name on Student that owns the
// relationship. This side is read-only from Hibernate's perspective — adding
// to this set alone would NOT be written to the database, which is why
// Student.enrollIn() above updates both collections together.
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
public Course() {}
public Course(String title, int credits) {
this.title = title;
this.credits = credits;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public int getCredits() { return credits; }
public Set<Student> getStudents() { return students; }
}
Owning Side vs. Inverse Side

Only the side holding `@JoinTable` (Student, in Step 1) actually causes Hibernate to insert or delete rows in `student_course`. The `mappedBy` side (Course) is purely for reading the relationship back from the other direction — calling `course.getStudents().add(someStudent)` alone, without also updating `someStudent.getCourses()`, would silently do nothing at the database level.

Step 3: Configure Hibernate and Open a Session

`hibernate.cfg.xml` is where Hibernate learns which database to connect to, which JDBC driver to use, and which entity classes to manage. `hbm2ddl.auto` set to `update` is a deliberate development-only choice: it tells Hibernate to create or adjust tables to match your entity mappings automatically, which is convenient while iterating on a tutorial project but is normally replaced with a proper migration tool (like Flyway or Liquibase) before this kind of application reaches production.

<!-- hibernate.cfg.xml -->
<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/enrollment_db</property>
<property name="hibernate.connection.username">root</property>
<property name="hibernate.connection.password">password</property>
<property name="hibernate.dialect">org.hibernate.dialect.MySQLDialect</property>
<!-- "update": adjust the schema to match entities automatically — convenient for
a learning project, but swapped for real migrations (Flyway/Liquibase) in production -->
<property name="hibernate.hbm2ddl.auto">update</property>
<property name="hibernate.show_sql">true</property> <!-- Prints generated SQL to the console, useful while learning -->
<mapping class="com.example.Student"/>
<mapping class="com.example.Course"/>
</session-factory>
</hibernate-configuration>

With that configuration in place, a `SessionFactory` is built once for the whole application, and each unit of work opens its own short-lived `Session` from it — mirroring how a JDBC connection pool hands out individual connections from one shared pool.

import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.cfg.Configuration;
public class HibernateUtil {
private static final SessionFactory sessionFactory = buildSessionFactory();
private static SessionFactory buildSessionFactory() {
// Reads hibernate.cfg.xml from the classpath and builds one SessionFactory
// for the entire application's lifetime — building it is expensive, so it
// must never be re-created per request or per operation.
return new Configuration().configure().buildSessionFactory();
}
public static Session openSession() {
return sessionFactory.openSession(); // Cheap to call; a fresh Session per unit of work
}
}

Step 4: Persist Students and Courses Together

Every write in Hibernate happens inside a transaction, even a single `save()` — `session.beginTransaction()` and `transaction.commit()` bracket the work, and only `commit()` actually flushes pending changes to the database. Saving `alice` here cascades nothing automatically (there is no `CascadeType` set on the `@ManyToMany` in Step 1), so `course1` and `course2` are persisted explicitly before `alice.enrollIn(...)` links them — a deliberate choice, since cascading a many-to-many save could easily re-insert a course that already exists.

Session session = HibernateUtil.openSession();
Transaction transaction = session.beginTransaction(); // Every write happens inside an explicit transaction
Course course1 = new Course("Introduction to Computer Science", 4);
Course course2 = new Course("Discrete Mathematics", 3);
session.persist(course1); // Must exist in the database before a Student can be linked to it
session.persist(course2);
Student alice = new Student("Alice Kumar", "alice.kumar@example.com");
alice.enrollIn(course1); // Updates both alice.courses and course1.students in memory
alice.enrollIn(course2);
session.persist(alice); // Persisting Student also writes the two student_course rows, since Student owns the mapping
transaction.commit(); // Nothing above hits the database until this line runs
session.close();
Generated SQL (hibernate.show_sql = true)

Click Run to see what this code prints.

Step 5: Query With HQL

HQL (Hibernate Query Language) is not SQL — it queries entity names and field names, not table and column names, and it understands object references the way SQL never could. `FROM Student s JOIN s.courses c WHERE c.title = :title` reads almost like a sentence: "give me every Student whose courses collection contains a Course with this title." Hibernate translates it into the equivalent SQL join against `students`, `student_course`, and `courses` for you.

// HQL query: find every course a given student is enrolled in.
// Written against entity/field names (Student, s.courses), not table/column names.
String hql1 = "SELECT c FROM Student s JOIN s.courses c WHERE s.email = :email";
List<Course> aliceCourses = session.createQuery(hql1, Course.class)
.setParameter("email", "alice.kumar@example.com")
.getResultList();
// The reverse direction: find every student enrolled in a given course.
String hql2 = "SELECT s FROM Course c JOIN c.students s WHERE c.title = :title";
List<Student> csStudents = session.createQuery(hql2, Student.class)
.setParameter("title", "Introduction to Computer Science")
.getResultList();
Generated SQL for hql1

Click Run to see what this code prints.

Complete Code

Here are the two entity classes assembled in full, with every annotation from the steps above in place. Configuration (`hibernate.cfg.xml`, `HibernateUtil`) stays as shown in Step 3.

import jakarta.persistence.*;
import java.util.HashSet;
import java.util.Set;
@Entity
@Table(name = "students")
public class Student {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false, unique = true)
private String email;
@ManyToMany
@JoinTable(
name = "student_course",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "course_id")
)
private Set<Course> courses = new HashSet<>();
public Student() {}
public Student(String name, String email) {
this.name = name;
this.email = email;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public Set<Course> getCourses() { return courses; }
public void enrollIn(Course course) {
this.courses.add(course);
course.getStudents().add(this);
}
}
@Entity
@Table(name = "courses")
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String title;
private int credits;
@ManyToMany(mappedBy = "courses")
private Set<Student> students = new HashSet<>();
public Course() {}
public Course(String title, int credits) {
this.title = title;
this.credits = credits;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public int getCredits() { return credits; }
public Set<Student> getStudents() { return students; }
}

Sample Run

Three realistic operations against the schema built above: enrolling a student in two courses, then querying both directions of the relationship.

// 1. Enroll Alice in two courses (Step 4)
alice.enrollIn(course1);
alice.enrollIn(course2);
session.persist(alice);
transaction.commit();
Result

Click Run to see what this code prints.

// 2. HQL: which courses is Alice enrolled in?
String hql = "SELECT c.title FROM Student s JOIN s.courses c WHERE s.email = :email";
List<String> titles = session.createQuery(hql, String.class)
.setParameter("email", "alice.kumar@example.com")
.getResultList();
System.out.println(titles);
Result

Click Run to see what this code prints.

// 3. HQL: who is enrolled in "Introduction to Computer Science"?
String hql = "SELECT s.name FROM Course c JOIN c.students s WHERE c.title = :title";
List<String> names = session.createQuery(hql, String.class)
.setParameter("title", "Introduction to Computer Science")
.getResultList();
System.out.println(names);
Result

Click Run to see what this code prints.

Extend This Project

  • Add a `Set<String> unenrollFrom(Course course)` method on `Student` that removes the course from both sides, mirroring `enrollIn()`.
  • Refactor the join table into its own `Enrollment` entity with an added `enrollmentDate` and `grade` field, the same shift the Order Management System project makes for `OrderItem`.
  • Add a `@ManyToMany` uniqueness safeguard at the application layer by checking `student.getCourses().contains(course)` before calling `enrollIn()` again.
  • Write an HQL query using `COUNT` and `GROUP BY` to report how many students are enrolled in each course.
  • Add `CascadeType.PERSIST` to the `@ManyToMany` on `Student` so a brand-new `Course` can be persisted automatically the first time a student enrolls in it.

Summary

You mapped a many-to-many relationship between `Student` and `Course` using `@ManyToMany`, `@JoinTable` on the owning side, and `mappedBy` on the inverse side, then let Hibernate generate and manage the `student_course` join table entirely from those annotations. The HQL queries you wrote — walking `s.courses` and `c.students` as if they were plain Java collections — are the same pattern you will reuse for every relationship mapping you build with Hibernate going forward.