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.
- 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.
| Entity | Purpose | Key Fields / Mapping |
|---|---|---|
| Student | One row per student | id (PK), name, email, courses (owning @ManyToMany with @JoinTable) |
| Course | One row per course offered | id (PK), title, credits, students (inverse @ManyToMany, mappedBy = "courses") |
| student_course | Hibernate-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); }}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; }}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 itsession.persist(course2);
Student alice = new Student("Alice Kumar", "alice.kumar@example.com");alice.enrollIn(course1); // Updates both alice.courses and course1.students in memoryalice.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 runssession.close();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();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();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);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);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.